feat: add durable ASR-to-patient extraction and guarded review

This commit is contained in:
2026-10-08 15:08:42 +08:00
parent 8bb4bd07ae
commit 13948f5982
34 changed files with 2377 additions and 56 deletions
+3 -1
View File
@@ -31,7 +31,7 @@ export interface FollowupReviewItem {
time_estimated: boolean
time_text: string
date_text: string
evidence: Array<{ text: string; start_ms?: number; end_ms?: number }>
evidence: Array<{ text: string; start_ms?: number; end_ms?: number; segment_id?: string; position_type?: 'segment' }>
needs_review: boolean
selected: boolean
target_id: number | null
@@ -62,6 +62,8 @@ export interface FollowupTaskSummary {
expires_at: number | string
can_retry: boolean
audio_available: boolean
transcript_available?: boolean
pipeline_progress?: { transcribed: number; total: number }
}
export interface FollowupTaskDetail extends FollowupTaskSummary {
summary: string
@@ -30,7 +30,7 @@
</template>
</el-table-column>
<el-table-column label="状态" width="145">
<template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ followupStatusLabels[row.status] || row.status }}</el-tag></template>
<template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ followupTaskStatus(row) }}</el-tag><div v-if="row.transcript_available && (row.status === 'failed' || row.status === 'needs_reconciliation')" class="task-time">转写已保留</div></template>
</el-table-column>
<el-table-column label="操作" width="65" align="right">
<template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="openTask(row.id)">查看</el-button></template>
@@ -41,14 +41,16 @@
<section v-if="selectedId && detail" class="task-detail" aria-label="录音任务详情">
<div class="detail-heading">
<div class="recording-title"><h3>{{ detail.file_name }}</h3><span>通话时间 {{ detail.recorded_at }}</span></div>
<el-tag size="small" :type="statusType(detail.status)">{{ followupStatusLabels[detail.status] || detail.status }}</el-tag>
<el-tag size="small" :type="statusType(detail.status)">{{ followupTaskStatus(detail) }}</el-tag>
</div>
<p v-if="detail.status === 'running' || detail.status === 'queued'" class="processing-state" role="status">{{ detail.stage || '任务正在后台处理,可稍后回来查看。' }}</p>
<el-alert v-if="detail.error_message" type="warning" :closable="false" :title="detail.error_message" />
<p v-if="detail.status === 'running' || detail.status === 'queued'" class="processing-state" role="status">{{ followupProgressText(detail) }}</p>
<el-alert v-if="detail.error_message || detail.status === 'failed'" :type="detail.status === 'failed' ? 'error' : 'warning'" :closable="false" :title="detail.error_message || '任务处理失败,不能写入;请先核对失败原因。'" />
<el-alert v-if="retainedTranscriptNotice(detail)" type="warning" :closable="false" :title="retainedTranscriptNotice(detail)" />
<el-alert v-if="detail.status === 'needs_reconciliation'" type="error" :closable="false" title="上游结果待核对,禁止重发。请管理员先确认上游任务或账单。" />
<el-alert v-if="detail.status === 'expired'" type="warning" :closable="false" title="录音和转录已过保留期,不能继续写入;已采纳的业务记录保留。" />
<el-button v-if="detail.can_retry && detail.status === 'failed' && !viewOnly" class="retry-button" :disabled="!canUpload || busy" @click="retryTask">重试已确认失败的任务</el-button>
<div class="summary-box">
<div class="muted">回访摘要 · AI未核实草稿,不代表已确认病情</div>
<p>{{ detail.summary || '暂无回访摘要' }}</p>
<div class="summary-counts"><span>{{ detail.status === 'applied' ? '已写入' : '已选' }} <strong>{{ selectedCount }}</strong> 项</span><span>待核实 <strong>{{ pendingCount }}</strong> 项</span></div>
</div>
@@ -105,7 +107,7 @@ import {
type FollowupCapabilities, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary
} from '@/api/followupAudio'
import FollowupAudioReview from './FollowupAudioReview.vue'
import { changedReviewTarget, cloneReviewItems, createFollowupScope, followupError, followupStatusLabels, rebaseReviewItems, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState'
import { changedReviewTarget, cloneReviewItems, createFollowupScope, followupError, followupProgressText, followupTaskStatus, rebaseReviewItems, retainedTranscriptNotice, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState'
const props = withDefaults(defineProps<{
diagnosisId: number; viewOnly?: boolean; active?: boolean; capabilities: FollowupCapabilities;
basicDirty?: boolean; beforeApply?: () => boolean
@@ -162,6 +164,9 @@ function stop() {
loading.value = false; uploading.value = false; mutation.value = ''; pollRunning = false
}
function installDetail(fresh: FollowupTaskDetail, preserve: boolean) {
if (retainedTranscriptNotice(fresh) && (!detail.value || detail.value.status !== fresh.status || !detail.value.transcript_available)) {
if (!detailSections.value.includes('original')) detailSections.value = [...detailSections.value, 'original']
}
detail.value = fresh
reviewItems.value = preserve ? rebaseReviewItems(reviewItems.value, fresh.items) : cloneReviewItems(fresh.items)
snapshot.value = JSON.stringify(fresh.items)
@@ -42,7 +42,8 @@
<p v-if="editorNotice" class="editor-notice" role="status">{{ editorNotice }}</p>
<div class="evidence">
<div class="evidence-title">录音原话</div>
<blockquote v-for="(quote, index) in editor.evidence" :key="index"><span>{{ quote.text }}</span><el-button v-if="typeof quote.start_ms === 'number'" link type="primary" @click="emit('seek', quote.start_ms)">播放 {{ timestamp(quote.start_ms) }}</el-button></blockquote>
<blockquote v-for="(quote, index) in editor.evidence" :key="index"><span>{{ quote.text }}</span><el-button v-if="evidencePlaybackLabel(quote)" link type="primary" @click="seekEvidence(quote)">{{ evidencePlaybackLabel(quote) }}</el-button></blockquote>
<p v-if="editor.evidence.some(quote => quote.position_type === 'segment')" class="muted">回听位置为转写片段范围,不是原话的逐字时间戳;请结合原始录音核对。</p>
<p v-if="!editor.evidence.length" class="muted">未提供原话证据,请人工核实。</p>
<p v-if="editor.date_text || editor.time_text" class="muted">时间原话:{{ editor.date_text || '未提及日期' }} · {{ editor.time_text || '未提及时间' }}</p>
</div>
@@ -61,9 +62,9 @@
<div v-for="field in itemFields(editor)" :key="field.key" class="field-grid">
<div class="field-label">{{ field.label }}<small v-if="field.readonly">只读</small></div>
<div class="current-value">{{ fieldText(editor.current_values[field.key], field) }}</div>
<div class="suggested-value"><FollowupAudioField :field="field" :model-value="editor.values[field.key]" :readonly="disabled || applied || field.readonly" @update:model-value="value => setValue(field.key, value)" /><el-button v-if="!disabled && !applied" link type="danger" @click="removeField(field.key)">移除</el-button></div>
<div class="suggested-value"><FollowupAudioField :field="field" :model-value="editor.values[field.key]" :readonly="disabled || applied || field.readonly" @update:model-value="value => setValue(field.key, value)" /><el-button v-if="!disabled && !applied" link type="danger" :disabled="Object.keys(editor.values).length <= 1" @click="removeField(field.key)">移除</el-button></div>
</div>
<el-select v-if="!disabled && !applied && availableFields(editor).length" class="add-field" placeholder="补充字段" :model-value="''" @update:model-value="(value: string) => addField(value)"><el-option v-for="field in availableFields(editor)" :key="field.key" :label="field.label" :value="field.key" /></el-select>
<p v-if="!disabled && !applied" class="muted">仅可更正或移除本项提议字段,不采纳整项请取消勾选;新增内容请返回原诊单编辑人工补录。</p>
<el-checkbox v-if="!disabled && !applied" :model-value="!editor.needs_review" class="review-confirm" @update:model-value="(value: boolean | string | number) => confirmReview(!!value)">我已核对原话、冲突 / 重复及日期时间,确认本项内容</el-checkbox>
<p v-if="editorIssue && !disabled && !applied" class="review-error" role="status">{{ editorIssue }}</p>
</section>
@@ -77,7 +78,7 @@
import { computed, ref, watch } from 'vue'
import type { FollowupField, FollowupFields, FollowupReviewItem, FollowupValue } from '@/api/followupAudio'
import FollowupAudioField from './FollowupAudioField.vue'
import { cloneReviewItems, fieldText, followupKindLabels, hasEditableReviewFields, reviewCounts, reviewFieldIssue, reviewIssue, reviewPending, reviewTimeValue } from './followupAudioState'
import { cloneReviewItems, evidencePlaybackLabel, fieldText, followupKindLabels, hasEditableReviewFields, reviewCounts, reviewFieldIssue, reviewIssue, reviewPending, reviewTimeValue } from './followupAudioState'
const props = defineProps<{ items: FollowupReviewItem[]; fields: FollowupFields; disabled: boolean; canDaily: boolean; applied?: boolean; uncertainties?: unknown[]; snapshotItems?: FollowupReviewItem[] }>()
const emit = defineEmits<{ (event: 'update:items', items: FollowupReviewItem[]): void; (event: 'seek', milliseconds: number): void }>()
type Group = 'all' | 'diagnosis' | 'daily' | 'pending'
@@ -104,7 +105,6 @@ function patch(id: string, update: Editable) { emit('update:items', props.items.
function itemFields(item: FollowupReviewItem): FollowupField[] { return Object.keys(item.values).map(key => (props.fields[item.kind] || []).find(field => field.key === key) || { key, label: key, type: 'text', readonly: true }) }
function itemKindLabel(item: FollowupReviewItem) { return followupKindLabels[item.kind] }
function itemTitle(item: FollowupReviewItem) { return itemFields(item).map(field => field.label).join('、') || followupKindLabels[item.kind] }
function availableFields(item: FollowupReviewItem) { return (props.fields[item.kind] || []).filter(field => !field.readonly && !Object.prototype.hasOwnProperty.call(item.values, field.key)) }
function selectionBlocked(item: FollowupReviewItem) { return props.disabled || !!props.applied || !hasEditableReviewFields(item, props.fields) || (item.kind !== 'diagnosis' && !props.canDaily) || !!reviewFieldIssue({ ...item, selected: true }, props.fields) }
function toggleSelection(item: FollowupReviewItem, selected: boolean) {
if (props.disabled || props.applied) return
@@ -127,18 +127,16 @@ function openReview(id: string) {
function cancelReview(done?: () => void) { editor.value = null; editorSource.value = ''; editorNotice.value = ''; done?.() }
function patchEditor(update: Editable) {
if (!editor.value || props.disabled || props.applied) return
// Detail/snapshot items are editable drafts, not the server-owned original proposal.
if (update.values && Object.keys(update.values).some(key => !Object.prototype.hasOwnProperty.call(editor.value?.values, key))) return
editor.value = { ...editor.value, ...update, selected: false, needs_review: true }
}
function setValue(key: string, value: FollowupValue) {
if (!editor.value || !(props.fields[editor.value.kind] || []).some(field => field.key === key && !field.readonly)) return
if (!editor.value || !Object.prototype.hasOwnProperty.call(editor.value.values, key)
|| !(props.fields[editor.value.kind] || []).some(field => field.key === key && !field.readonly)) return
patchEditor({ values: { ...editor.value.values, [key]: value } })
}
function addField(key: string) {
if (!editor.value) return
const field = availableFields(editor.value).find(candidate => candidate.key === key)
if (field) setValue(key, field.type === 'multiselect' ? [] : null)
}
function removeField(key: string) { if (!editor.value) return; const values = { ...editor.value.values }; delete values[key]; patchEditor({ values }) }
function removeField(key: string) { if (!editor.value || Object.keys(editor.value.values).length <= 1) return; const values = { ...editor.value.values }; delete values[key]; patchEditor({ values }) }
function chooseTarget(id: number) {
if (!editor.value || (id && !editorCandidates.value.some(candidate => candidate.id === id && !candidate.delete_time))) return
patchEditor({ target_id: id || null })
@@ -157,7 +155,9 @@ function candidateLabel(item: FollowupReviewItem, candidate: NonNullable<Followu
const preview = (props.fields[item.kind] || []).filter(field => Object.prototype.hasOwnProperty.call(candidate.values, field.key)).slice(0, 2).map(field => `${field.label} ${fieldText(candidate.values[field.key], field)}`).join(',')
return `#${candidate.id} · ${candidate.record_time || candidate.time_period || '时间未记录'}${candidate.time_estimated ? '(推定)' : ''}${preview ? ` · ${preview}` : ''}`
}
function timestamp(ms: number) { const seconds = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` }
function seekEvidence(quote: FollowupReviewItem['evidence'][number]) {
if (evidencePlaybackLabel(quote) && typeof quote.start_ms === 'number') emit('seek', quote.start_ms)
}
function uncertaintyText(value: unknown) { return typeof value === 'string' ? value : JSON.stringify(value) }
watch(() => props.items.find(item => item.id === editor.value?.id), fresh => {
if (!editor.value) return
@@ -211,7 +211,6 @@ defineExpose({ hasUnsavedChanges: () => editorDirty.value })
.current-value { white-space: pre-wrap; overflow-wrap: anywhere; }
.suggested-value { min-width: 0; display: grid; gap: 4px; }
.suggested-value > :deep(.el-button) { justify-self: end; font-size: 12px; }
.add-field { width: 100%; }
.review-confirm { white-space: normal; height: auto; min-height: 32px; align-items: flex-start; }
.review-confirm :deep(.el-checkbox__input) { margin-top: 4px; }
.review-confirm :deep(.el-checkbox__label) { white-space: normal; line-height: 1.6; font-size: 13px; }
@@ -1,4 +1,4 @@
import type { FollowupCapabilities, FollowupField, FollowupFields, FollowupReviewItem, FollowupValue } from '@/api/followupAudio'
import type { FollowupCapabilities, FollowupField, FollowupFields, FollowupReviewItem, FollowupTaskDetail, FollowupTaskSummary, FollowupValue } from '@/api/followupAudio'
export const FOLLOWUP_CHUNK_BYTES = 2097152
export const followupKindLabels = { diagnosis: '诊单资料', blood: '血糖 / 血压', diet: '饮食', exercise: '运动', tracking_note: '跟踪备注' }
@@ -6,6 +6,31 @@ export const followupStatusLabels: Record<string, string> = {
queued: '等待处理', running: '正在分析', review: '待人工审核', applied: '已采纳',
failed: '处理失败', needs_reconciliation: '结果待核对', expired: '已过保留期'
}
const followupStageLabels: Record<string, string> = { transcribing: '正在转写', extracting: '正在提炼', validating: '正在校验', review: '待人工审核' }
export function followupTaskStatus(task: Pick<FollowupTaskSummary, 'status' | 'stage'>): string {
return (task.status === 'running' && followupStageLabels[task.stage]) || followupStatusLabels[task.status] || task.status
}
export function followupProgressText(task: FollowupTaskSummary): string {
const progress = task.pipeline_progress
const valid = progress && Number.isSafeInteger(progress.transcribed) && Number.isSafeInteger(progress.total)
&& progress.total > 0 && progress.transcribed >= 0 && progress.transcribed <= progress.total
return `${followupTaskStatus(task)}${valid ? ` · 转写片段 ${progress.transcribed} / ${progress.total}` : ''};人工审核前不会写入业务记录。`
}
export function retainedTranscriptNotice(task: FollowupTaskDetail): string {
if (!task.transcript_available || !task.transcript.trim()) return ''
if (task.status === 'failed') return '处理失败,已完成片段的转写仍可查看。保留转写不代表提炼成功,不能直接写入病历。'
if (task.status === 'needs_reconciliation') return '结果待核对,已完成片段的转写仍可查看。请先核对上游结果,不要重复发送。'
return ''
}
export function evidencePlaybackLabel(quote: FollowupReviewItem['evidence'][number]): string {
if (typeof quote.start_ms !== 'number' || !Number.isFinite(quote.start_ms) || quote.start_ms < 0) return ''
const timestamp = (ms: number) => { const seconds = Math.floor(ms / 1000); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` }
if (quote.position_type === 'segment') {
if (!quote.segment_id || typeof quote.end_ms !== 'number' || !Number.isFinite(quote.end_ms) || quote.end_ms <= quote.start_ms) return ''
return `回听片段 ${timestamp(quote.start_ms)}–${timestamp(quote.end_ms)}`
}
return `播放 ${timestamp(quote.start_ms)}`
}
export const cloneReviewItems = (items: FollowupReviewItem[]): FollowupReviewItem[] => JSON.parse(JSON.stringify(items))
export function fieldText(value: FollowupValue | undefined, field: FollowupField): string {
if (value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length)) return '未记录'
+213
View File
@@ -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>/)
})