197 lines
13 KiB
TypeScript
197 lines
13 KiB
TypeScript
import type { FollowupAudioChannel, FollowupCapabilities, FollowupChannelRoles, FollowupField, FollowupFields, FollowupReviewItem, FollowupTaskDetail, FollowupTaskSummary, FollowupTranscriptSegment, FollowupValue } from '@/api/followupAudio'
|
||
|
||
export const FOLLOWUP_CHUNK_BYTES = 2097152
|
||
export const followupKindLabels = { diagnosis: '诊单资料', blood: '血糖 / 血压', diet: '饮食', exercise: '运动', tracking_note: '跟踪备注' }
|
||
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 ''
|
||
if (quote.channel !== undefined && quote.channel !== 0 && quote.channel !== 1) return ''
|
||
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 `回听对话 ${audioTimestamp(quote.start_ms)}–${audioTimestamp(quote.end_ms)}`
|
||
}
|
||
return `播放 ${audioTimestamp(quote.start_ms)}`
|
||
}
|
||
export function audioTimestamp(ms: number): string { const seconds = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}` }
|
||
export function normalizedChannelRoles(value: unknown): FollowupChannelRoles { return value === 'left_service' || value === 'right_service' ? value : 'unconfirmed' }
|
||
export function hasStereoTranscript(segments: FollowupTranscriptSegment[] = []): boolean { return segments.some(segment => segment.channel === 0) && segments.some(segment => segment.channel === 1) }
|
||
export function followupChannelLabel(channel?: FollowupAudioChannel, roles: FollowupChannelRoles = 'unconfirmed'): string {
|
||
if (channel !== 0 && channel !== 1) return '未标注声道'
|
||
const side = channel === 0 ? '左声道' : '右声道'
|
||
const confirmed = normalizedChannelRoles(roles)
|
||
if (confirmed === 'unconfirmed') return `${side} · 角色待确认`
|
||
const service = confirmed === 'left_service' ? channel === 0 : channel === 1
|
||
return `${side} · ${service ? '客服端' : '患者/家属端'}(人工标注)`
|
||
}
|
||
/** Preserve coarse ASR windows; never invent speaker turn order or word-level timestamps. */
|
||
export function dialogueWindows(segments: FollowupTranscriptSegment[]) {
|
||
const windows = new Map<string, { id: string; start_ms: number; end_ms: number; segments: FollowupTranscriptSegment[] }>()
|
||
for (const segment of segments) {
|
||
const id = `${segment.start_ms}:${segment.end_ms}`
|
||
if (!windows.has(id)) windows.set(id, { id, start_ms: segment.start_ms, end_ms: segment.end_ms, segments: [] })
|
||
windows.get(id)!.segments.push(segment)
|
||
}
|
||
return [...windows.values()].sort((a, b) => a.start_ms - b.start_ms || a.end_ms - b.end_ms)
|
||
.map(window => ({ ...window, segments: [...window.segments].sort((a, b) => (a.channel ?? -1) - (b.channel ?? -1)) }))
|
||
}
|
||
export function dialoguePlaybackStart(milliseconds: number): number {
|
||
return Number.isFinite(milliseconds) ? Math.max(0, milliseconds - 3000) : 0
|
||
}
|
||
export function channelPlaybackLabel(channel: FollowupAudioChannel, roles: FollowupChannelRoles): string {
|
||
if (roles === 'unconfirmed') return channel === 0 ? '只听左声道' : '只听右声道'
|
||
const service = roles === 'left_service' ? channel === 0 : channel === 1
|
||
return service ? '只听客服' : '只听患者/家属'
|
||
}
|
||
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 '未记录'
|
||
const display = (part: string | number | boolean) => field.options?.find(option => String(option.value) === String(part))?.label || String(part)
|
||
return Array.isArray(value) ? value.map(display).join('、') : display(value)
|
||
}
|
||
export function hasEditableReviewFields(item: FollowupReviewItem, fields: FollowupFields): boolean {
|
||
return Object.keys(item.values).some(key => fields[item.kind]?.some(field => field.key === key && !field.readonly))
|
||
}
|
||
/** Prevent multiple selected writes to the same diagnosis field; never silently pick a last value. */
|
||
export function reviewBatchIssue(items: FollowupReviewItem[], fields: FollowupFields): string {
|
||
const touched = new Set<string>()
|
||
for (const item of items.filter(item => item.selected)) {
|
||
if (item.kind === 'diagnosis') {
|
||
for (const key of Object.keys(item.values)) {
|
||
if (touched.has(key)) {
|
||
const label = fields.diagnosis?.find(field => field.key === key)?.label || key
|
||
return `${label}存在多条选中建议,请合并内容到一项或仅保留一项后再确认写入`
|
||
}
|
||
touched.add(key)
|
||
}
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
export function reviewFieldIssue(item: FollowupReviewItem, fields: FollowupFields): string {
|
||
if (!item.selected) return ''
|
||
for (const key of Object.keys(item.values)) {
|
||
const field = fields[item.kind]?.find(candidate => candidate.key === key)
|
||
if (!field || field.readonly) return `${field?.label || key} 当前不可编辑,请先取消采纳该字段`
|
||
}
|
||
return ''
|
||
}
|
||
export function reviewIssue(item: FollowupReviewItem, canDaily: boolean): string {
|
||
if (!item.selected) return ''
|
||
if (item.needs_review) return '请明确核对不确定、冲突或重复内容'
|
||
if (item.kind !== 'diagnosis' && !canDaily) return '没有日常记录编辑权限'
|
||
if (!Object.keys(item.values).length) return '至少填写一个有效字段'
|
||
if (item.kind !== 'diagnosis' && !validDate(item.record_date)) return '请核对并填写有效的记录日期'
|
||
if (item.record_time && !/^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/.test(item.record_time)) return '请填写有效的记录时间'
|
||
return ''
|
||
}
|
||
/** Pending is independent of selection so filtering never hides unresolved items. */
|
||
export function reviewPending(item: FollowupReviewItem, fields: FollowupFields, canDaily: boolean): boolean {
|
||
return item.needs_review || !!reviewFieldIssue({ ...item, selected: true }, fields) || !!reviewIssue({ ...item, selected: true }, canDaily)
|
||
}
|
||
export function reviewCounts(items: FollowupReviewItem[], fields: FollowupFields, canDaily: boolean) {
|
||
return {
|
||
all: items.length,
|
||
diagnosis: items.filter(item => item.kind === 'diagnosis').length,
|
||
daily: items.filter(item => item.kind !== 'diagnosis').length,
|
||
pending: items.filter(item => reviewPending(item, fields, canDaily)).length,
|
||
selected: items.filter(item => item.selected).length
|
||
}
|
||
}
|
||
export function changedReviewTarget(items: FollowupReviewItem[], snapshot: FollowupReviewItem[]): boolean {
|
||
const previous = new Map(snapshot.map(item => [item.id, item]))
|
||
return items.some(item => {
|
||
const original = previous.get(item.id)
|
||
return item.selected && original && (item.target_id !== original.target_id || item.record_date !== original.record_date)
|
||
})
|
||
}
|
||
/** The API accepts legacy seconds, while the review editor exposes minute precision. */
|
||
export function reviewTimeValue(value: string | null): string | null {
|
||
return value && /^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/.test(value) ? value.slice(0, 5) : null
|
||
}
|
||
export function validDate(value: string | null): boolean {
|
||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
||
const [year, month, day] = value.split('-').map(Number)
|
||
const date = new Date(Date.UTC(year, month - 1, day))
|
||
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
||
}
|
||
/** A stale snapshot refresh never discards user values or reselects an item for them. */
|
||
export function rebaseReviewItems(local: FollowupReviewItem[], fresh: FollowupReviewItem[]): FollowupReviewItem[] {
|
||
const previous = new Map(local.map(item => [item.id, item]))
|
||
return cloneReviewItems(fresh).map(item => {
|
||
const edit = previous.get(item.id)
|
||
return edit ? { ...item, values: { ...edit.values }, record_date: edit.record_date, record_time: edit.record_time,
|
||
time_period: edit.time_period, target_id: edit.target_id, selected: false, needs_review: true } : item
|
||
})
|
||
}
|
||
/** Missing preview readiness/scope fails closed; normal deployments retain their full-verification rules. */
|
||
export function followupFeatureEnabled(caps: FollowupCapabilities | null): boolean {
|
||
return !!caps?.enabled && (!caps.preview_only || caps.test_scope_allowed === true)
|
||
}
|
||
export function followupAudioReady(caps: FollowupCapabilities | null): boolean {
|
||
return !!caps && (caps.preview_only ? caps.preview_ready === true && caps.test_scope_allowed === true : caps.audio_verified || (caps.manual_review_enabled === true && caps.manual_review_ready === true))
|
||
}
|
||
export function followupCanReview(caps: FollowupCapabilities | null, previewTask = false): boolean {
|
||
return followupFeatureEnabled(caps) && !!caps && (caps.preview_only || previewTask ? caps.can_review === true : caps.can_apply)
|
||
}
|
||
export function followupCanApply(caps: FollowupCapabilities | null, previewTask = false): boolean {
|
||
return followupFeatureEnabled(caps) && followupAudioReady(caps) && !!caps && !caps.preview_only && !previewTask && caps.can_apply
|
||
}
|
||
export function uploadIssue(file: Pick<File, 'size' | 'name'> | null, recordedAt: string, model: string, caps: FollowupCapabilities | null, viewOnly: boolean): string {
|
||
if (viewOnly || !caps || !followupFeatureEnabled(caps) || !followupAudioReady(caps) || !caps.can_upload) return '当前不可上传录音'
|
||
if (!file || !file.size) return '请选择非空录音文件'
|
||
if (file.size > caps.limits.max_bytes) return '录音文件超过大小限制'
|
||
if (!/\.(mp3|wav|m4a|amr)$/i.test(file.name)) return '请选择支持的录音格式'
|
||
if (!/^\d{4}-\d{2}-\d{2} ([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/.test(recordedAt) || !validDate(recordedAt.slice(0, 10))) return '请填写实际录音时间(北京时间)'
|
||
if (!caps.models.some(option => option.value === model)) return '请选择可用的分析模型'
|
||
return ''
|
||
}
|
||
/** All async operations capture one generation; invalidate on patient switch, hide or dispose. */
|
||
export function createFollowupScope() {
|
||
let generation = 0
|
||
let controller = new AbortController()
|
||
return {
|
||
capture: () => ({ generation, signal: controller.signal }),
|
||
current: (ticket: { generation: number; signal: AbortSignal }) => ticket.generation === generation && !ticket.signal.aborted,
|
||
invalidate() { controller.abort(); generation++; controller = new AbortController() }
|
||
}
|
||
}
|
||
export async function uploadChunks(
|
||
file: Blob,
|
||
chunkBytes: number,
|
||
send: (index: number, chunk: Blob) => Promise<{ received: boolean }>,
|
||
isCurrent: () => boolean,
|
||
progress: (percent: number) => void
|
||
): Promise<boolean> {
|
||
if (!Number.isSafeInteger(chunkBytes) || chunkBytes < 65536 || chunkBytes > FOLLOWUP_CHUNK_BYTES) throw new Error('服务端分片配置不匹配,请联系管理员')
|
||
for (let start = 0, index = 0; start < file.size; start += chunkBytes, index++) {
|
||
if (!isCurrent()) return false
|
||
const result = await send(index, file.slice(start, start + chunkBytes))
|
||
if (!isCurrent()) return false
|
||
if (!result.received) throw new Error('录音分片未被服务端确认,上传已停止')
|
||
progress(Math.round(Math.min(start + chunkBytes, file.size) / file.size * 100))
|
||
}
|
||
return isCurrent()
|
||
}
|
||
export function followupError(error: unknown): string {
|
||
return error instanceof Error ? error.message : '请求失败;当前审核内容已保留,请刷新任务状态后确认'
|
||
}
|