feat: add durable ASR-to-patient extraction and guarded review
This commit is contained in:
@@ -653,3 +653,216 @@ test('narrow review tables keep the review action reachable and show each event
|
||||
assert.match(source, /class="item-meta mobile-event-time"/)
|
||||
assert.match(source, /\.mobile-event-time \{ display: block; \}/)
|
||||
})
|
||||
|
||||
test('two-stage labels expose real stage and bounded chunk progress without declaring success', () => {
|
||||
for (const [stage, label] of [['transcribing', '正在转写'], ['extracting', '正在提炼'], ['validating', '正在校验']]) {
|
||||
const current = task(1, { status: 'running', stage, pipeline_progress: { transcribed: 2, total: 3 } })
|
||||
assert.equal(state.followupTaskStatus(current), label)
|
||||
assert.equal(state.followupProgressText(current), `${label} · 转写片段 2 / 3;人工审核前不会写入业务记录。`)
|
||||
}
|
||||
for (const pipeline_progress of [undefined, { transcribed: 0, total: 0 }, { transcribed: 4, total: 3 }, { transcribed: -1, total: 3 }, { transcribed: 1.5, total: 3 }, { transcribed: 2, total: Infinity }]) {
|
||||
assert.doesNotMatch(state.followupProgressText(task(1, { status: 'running', stage: 'transcribing', pipeline_progress })), /转写片段/)
|
||||
}
|
||||
assert.match(state.followupProgressText(task(1, { status: 'running', stage: 'transcribing', pipeline_progress: { transcribed: 0, total: 3 } })), /0 \/ 3/)
|
||||
assert.equal(state.followupTaskStatus(task(1, { status: 'failed', stage: 'extracting' })), '处理失败')
|
||||
assert.equal(state.followupTaskStatus(task(1, { status: 'needs_reconciliation', stage: 'extracting' })), '结果待核对')
|
||||
assert.equal(state.followupTaskStatus(task(1, { status: 'running', stage: 'legacy_unknown' })), '正在分析')
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.match(source, /followupTaskStatus\(row\)/)
|
||||
assert.match(source, /followupProgressText\(detail\)/)
|
||||
})
|
||||
|
||||
test('failed extraction retains canonical ASR text visibly but blocks review writes and does not retry', async () => {
|
||||
const result = task(1, { status: 'failed', stage: 'extracting', transcript_available: true, transcript: '合成转写:今天早晨测了6.5。', summary: '', items: [], error_message: '提炼阶段失败', pipeline_progress: { transcribed: 3, total: 3 } })
|
||||
const f = setupPanel({ followupAudioLists: async () => ({ items: [result] }), followupAudioDetail: async () => result })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
assert.equal(f.panel.detail.value.status, 'failed')
|
||||
assert.equal(f.panel.detail.value.transcript, result.transcript)
|
||||
assert.equal(f.panel.detail.value.error_message, '提炼阶段失败')
|
||||
assert.deepEqual(f.panel.detailSections.value, ['original'])
|
||||
assert.match(state.retainedTranscriptNotice(f.panel.detail.value), /不代表提炼成功/)
|
||||
assert.equal(f.panel.canEditReview.value, false)
|
||||
await f.panel.saveDraft(); await f.panel.applySelected(); await f.panel.retryTask()
|
||||
assert.equal(f.calls.filter(call => ['save', 'apply', 'retry'].includes(call[0])).length, 0)
|
||||
assert.equal(f.notices.filter(([kind]) => kind === 'success').length, 0)
|
||||
await f.panel.loadAudio()
|
||||
assert.equal(f.panel.audioUrl.value, 'blob:private-0')
|
||||
assert.deepEqual(f.calls.filter(call => call[0] === 'audio'), [['audio', 1]])
|
||||
f.dispose()
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.match(source, /detail\.error_message \|\| detail\.status === 'failed'/)
|
||||
assert.match(source, /detail\.status === 'failed' \? 'error' : 'warning'/)
|
||||
assert.match(source, /detail\.transcript \|\| '暂无转录'/)
|
||||
})
|
||||
|
||||
test('poll transition to failed extraction exposes retained text; unknown upstream stays unretryable', async () => {
|
||||
let current = task(1, { status: 'running', stage: 'transcribing', transcript_available: false, transcript: '', items: [], pipeline_progress: { transcribed: 0, total: 3 } })
|
||||
const f = setupPanel({ followupAudioLists: async () => ({ items: [current] }), followupAudioDetail: async () => current })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
assert.deepEqual(f.panel.detailSections.value, [])
|
||||
current = { ...current, stage: 'extracting', transcript_available: true, transcript: '合成已完成转写', pipeline_progress: { transcribed: 3, total: 3 } }
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.panel.detail.value.stage, 'extracting')
|
||||
assert.equal(f.panel.detail.value.transcript, current.transcript)
|
||||
current = { ...current, status: 'failed', error_message: '提炼失败' }
|
||||
f.tick(); await settle()
|
||||
assert.deepEqual(f.panel.detailSections.value, ['original'])
|
||||
f.panel.detailSections.value = []
|
||||
await f.panel.refresh()
|
||||
assert.deepEqual(f.panel.detailSections.value, [])
|
||||
current = { ...current, status: 'needs_reconciliation', error_message: '提炼结果待核对', can_retry: false }
|
||||
f.tick(); await settle()
|
||||
assert.match(state.retainedTranscriptNotice(f.panel.detail.value), /不要重复发送/)
|
||||
await f.panel.retryTask()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'retry').length, 0)
|
||||
f.dispose()
|
||||
for (const patch of [{ status: 'expired', transcript_available: true }, { status: 'failed', transcript_available: false }, { status: 'failed', transcript_available: true, transcript: '' }]) assert.equal(state.retainedTranscriptNotice(task(1, patch)), '')
|
||||
})
|
||||
|
||||
test('old-patient retained-transcript completion cannot expose text or reopen current detail', async () => {
|
||||
const pending = deferred()
|
||||
const f = setupPanel({ followupAudioDetail: async () => pending.promise })
|
||||
await settle()
|
||||
const opening = f.panel.openTask(1); await settle()
|
||||
f.props.diagnosisId = 20; await settle()
|
||||
pending.resolve(task(1, { status: 'failed', stage: 'extracting', transcript_available: true, transcript: '旧患者合成文本' }))
|
||||
await opening; await settle()
|
||||
assert.equal(f.panel.detail.value, null)
|
||||
assert.equal(f.panel.selectedId.value, 0)
|
||||
assert.deepEqual(f.panel.detailSections.value, [])
|
||||
assert.deepEqual(f.events, [])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('segment playback uses trusted original-audio boundaries and never claims word-level alignment', () => {
|
||||
const quote = { text: '今天早晨测了6.5', segment_id: 'chunk-2', start_ms: 60000, end_ms: 120000, position_type: 'segment' }
|
||||
assert.equal(state.evidencePlaybackLabel(quote), '回听片段 1:00–2:00')
|
||||
assert.equal(state.evidencePlaybackLabel(item().evidence[0]), '播放 0:05')
|
||||
for (const patch of [{ start_ms: -1 }, { start_ms: Infinity }, { end_ms: NaN }, { end_ms: 60000 }, { segment_id: '' }, { end_ms: undefined }]) assert.equal(state.evidencePlaybackLabel({ ...quote, ...patch }), '')
|
||||
const f = setupReview({ items: [item({ evidence: [quote] })] })
|
||||
f.review.openReview('blood-1')
|
||||
f.review.seekEvidence(f.review.editor.value.evidence[0])
|
||||
assert.deepEqual(f.events, [['seek', 60000]])
|
||||
f.review.seekEvidence({ ...quote, start_ms: -1 })
|
||||
assert.equal(f.events.length, 1)
|
||||
f.dispose()
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioReview.vue'), 'utf8')
|
||||
assert.match(source, /不是原话的逐字时间戳/)
|
||||
assert.match(source, /evidencePlaybackLabel\(quote\)/)
|
||||
})
|
||||
|
||||
test('segment identities and bounds remain immutable through single-item edit, draft and apply', async () => {
|
||||
const evidence = [{ text: '今天早晨测了6.5', segment_id: 'chunk-2', start_ms: 60000, end_ms: 120000, position_type: 'segment' }]
|
||||
const original = item({ evidence })
|
||||
const review = setupReview({ items: [original] })
|
||||
review.review.openReview('blood-1'); review.review.setValue('fasting_blood_sugar', 7)
|
||||
review.review.editor.value.evidence[0] = { text: '不可保存的伪造证据', segment_id: 'fake', start_ms: 0, end_ms: 1, position_type: 'segment' }
|
||||
review.review.confirmReview(true); review.review.saveReview(true)
|
||||
assert.deepEqual(review.props.items[0].evidence, evidence)
|
||||
const f = setupPanel({ followupAudioDetail: async () => task(1, { items: review.props.items }) })
|
||||
await settle(); await f.panel.openTask(1); await f.panel.saveDraft(); await f.panel.applySelected()
|
||||
for (const action of ['save', 'apply']) {
|
||||
const sent = f.calls.find(call => call[0] === action)
|
||||
assert.ok(sent, action)
|
||||
assert.deepEqual(sent[1].items[0].evidence, evidence)
|
||||
assert.equal(sent[1].items[0].expected_hash, 'immutable-hash')
|
||||
}
|
||||
const freshEvidence = [{ ...evidence[0], segment_id: 'server-refreshed' }]
|
||||
assert.deepEqual(state.rebaseReviewItems([original], [item({ evidence: freshEvidence })])[0].evidence, freshEvidence)
|
||||
f.dispose(); review.dispose()
|
||||
})
|
||||
|
||||
test('selected undated or uncertain segment-backed suggestions remain blocked from apply', async () => {
|
||||
const evidence = [{ text: '最近测了6.5', segment_id: 'chunk-1', start_ms: 0, end_ms: 60000, position_type: 'segment' }]
|
||||
for (const patch of [{ record_date: null, date_text: '最近' }, { needs_review: true }]) {
|
||||
const f = setupPanel({ followupAudioDetail: async () => task(1, { items: [item({ evidence, ...patch })] }) })
|
||||
await settle(); await f.panel.openTask(1); await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
assert.notEqual(f.panel.applyIssue.value, '')
|
||||
f.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('review cannot offer or add catalog fields absent from the item, including a polluted draft snapshot', () => {
|
||||
const fields = caps().fields
|
||||
fields.blood.push({ key: 'systolic_pressure', label: '收缩压', type: 'number' })
|
||||
const f = setupReview({ fields, snapshotItems: [item({ values: { fasting_blood_sugar: 6.5, systolic_pressure: 120 } })] })
|
||||
f.review.openReview('blood-1')
|
||||
assert.equal(f.review.availableFields, undefined)
|
||||
assert.equal(f.review.addField, undefined)
|
||||
f.review.setValue('systolic_pressure', 120)
|
||||
assert.deepEqual(f.review.editor.value.values, { fasting_blood_sugar: 6.5 })
|
||||
f.review.patchEditor({ values: { fasting_blood_sugar: 7, systolic_pressure: 120 } })
|
||||
assert.deepEqual(f.review.editor.value.values, { fasting_blood_sugar: 6.5 })
|
||||
f.review.setValue('fasting_blood_sugar', 7)
|
||||
f.review.confirmReview(true); f.review.saveReview(true)
|
||||
assert.deepEqual(f.props.items[0].values, { fasting_blood_sugar: 7 })
|
||||
assert.deepEqual(f.props.items[0].evidence, item().evidence)
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioReview.vue'), 'utf8')
|
||||
assert.doesNotMatch(source, /placeholder="补充字段"|availableFields|function addField|class="add-field"/)
|
||||
assert.match(source, /新增内容请返回原诊单编辑人工补录/)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('removal can be cancelled but saved drafts cannot expand their field scope on reopening', () => {
|
||||
const fields = caps().fields
|
||||
fields.blood.push({ key: 'systolic_pressure', label: '收缩压', type: 'number' })
|
||||
const original = item({ values: { fasting_blood_sugar: 6.5, systolic_pressure: 120 } })
|
||||
const f = setupReview({ fields, items: [original], snapshotItems: [original] })
|
||||
f.review.openReview('blood-1'); f.review.removeField('systolic_pressure')
|
||||
f.review.setValue('systolic_pressure', 125)
|
||||
assert.deepEqual(f.review.editor.value.values, { fasting_blood_sugar: 6.5 })
|
||||
f.review.cancelReview(); f.review.openReview('blood-1')
|
||||
assert.deepEqual(f.review.editor.value.values, original.values)
|
||||
f.review.removeField('systolic_pressure'); f.review.saveReview(false)
|
||||
f.review.openReview('blood-1'); f.review.setValue('systolic_pressure', 125)
|
||||
assert.deepEqual(f.review.editor.value.values, { fasting_blood_sugar: 6.5 })
|
||||
f.review.removeField('fasting_blood_sugar')
|
||||
assert.deepEqual(f.review.editor.value.values, { fasting_blood_sugar: 6.5 }, 'last field cannot be removed into an unsaveable empty draft')
|
||||
assert.equal(f.props.items[0].selected, false)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('actual review payload remains within production mergeItems proposal scope; server rejects a forged extra field', () => {
|
||||
const fields = caps().fields
|
||||
fields.blood.push({ key: 'systolic_pressure', label: '收缩压', type: 'number' })
|
||||
const original = item({ record_time: '08:00' })
|
||||
const f = setupReview({ fields, items: [original] })
|
||||
f.review.openReview('blood-1'); f.review.setValue('systolic_pressure', 120)
|
||||
f.review.setValue('fasting_blood_sugar', 7); f.review.confirmReview(true); f.review.saveReview(true)
|
||||
const submitted = f.events.find(([name]) => name === 'update:items')[1]
|
||||
const serviceDir = path.resolve(root, '../server/app/common/service/followupaudio')
|
||||
const script = `
|
||||
require $argv[1] . '/FollowupAudioFields.php';
|
||||
require $argv[1] . '/FollowupAudioPolicy.php';
|
||||
require $argv[1] . '/FollowupAudioApply.php';
|
||||
$input = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR);
|
||||
$class = 'app\\\\common\\\\service\\\\followupaudio\\\\FollowupAudioApply';
|
||||
$merged = $class::mergeItems($input['stored'], $input['submitted'], $input['source']);
|
||||
$forged = $input['submitted']; $forged[0]['values']['systolic_pressure'] = 120;
|
||||
try { $class::mergeItems($input['stored'], $forged, $input['source']); $error = null; }
|
||||
catch (DomainException $exception) { $error = $exception->getMessage(); }
|
||||
echo json_encode(['merged_values' => $merged[0]['values'], 'evidence' => $merged[0]['evidence'], 'forged_error' => $error], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
`
|
||||
const temp = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'followup-merge-test-'))
|
||||
let result
|
||||
try {
|
||||
const filename = path.join(temp, 'merge.php')
|
||||
fs.writeFileSync(filename, `<?php\n${script}`)
|
||||
result = require('node:child_process').spawnSync(process.env.PHP_BINARY || 'php', [filename, serviceDir], {
|
||||
encoding: 'utf8', input: JSON.stringify({ stored: [original], submitted, source: [original] })
|
||||
})
|
||||
} finally { fs.rmSync(temp, { recursive: true, force: true }) }
|
||||
assert.equal(result.status, 0, result.stderr || String(result.error || 'PHP merge process failed'))
|
||||
assert.equal(result.stderr, '')
|
||||
const merged = JSON.parse(result.stdout)
|
||||
assert.deepEqual(merged.merged_values, { fasting_blood_sugar: 7 })
|
||||
assert.deepEqual(merged.evidence, original.evidence)
|
||||
assert.equal(merged.forged_error, 'FOLLOWUP_AUDIO_UNPROPOSED_FIELD')
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('summary is explicitly an unverified AI draft even after selected rows have been applied', () => {
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.match(source, /<div class="summary-box">\s*<div class="muted">回访摘要 · AI未核实草稿,不代表已确认病情<\/div>/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user