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 '未记录'
|
||||
|
||||
Reference in New Issue
Block a user