feat: add durable ASR-to-patient extraction and guarded review
This commit is contained in:
@@ -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 '未记录'
|
||||
|
||||
@@ -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