feat(followup-audio): review full dialogue and bind roles before extraction
This commit is contained in:
@@ -127,7 +127,7 @@ export async function followupAudioUploadChunk(uploadId: string, index: number,
|
||||
}, options))
|
||||
}
|
||||
export const followupAudioUploadComplete = (uploadId: string, signal?: AbortSignal) => post<{ upload_id: string; duration_seconds: number; sha256: string }>('uploadComplete', { upload_id: uploadId }, signal)
|
||||
export const followupAudioCreate = (data: { diagnosis_id: number; upload_id: string; recorded_at: string; model_key: string }, signal?: AbortSignal) => post<{ task_id: number; status: string; reused?: boolean; reuse_message?: string }>('create', data, signal)
|
||||
export const followupAudioCreate = (data: { diagnosis_id: number; upload_id: string; recorded_at: string; model_key: string; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post<{ task_id: number; status: string; reused?: boolean; reuse_message?: string }>('create', data, signal)
|
||||
export const followupAudioSaveDraft = (data: { id: number; version: number; items: FollowupReviewItem[]; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post<FollowupTaskDetail>('saveDraft', data, signal)
|
||||
export const followupAudioApply = (data: { id: number; version: number; items: FollowupReviewItem[] }, signal?: AbortSignal) => post<{ id: number; status: 'applied'; applied_items: unknown[] }>('apply', data, signal)
|
||||
export const followupAudioRetry = (id: number, signal?: AbortSignal) => post<{ id: number; status: string }>('retry', { id }, signal)
|
||||
|
||||
@@ -61,24 +61,31 @@
|
||||
<p class="muted">声道不等于已核实的说话人身份。角色标注不自动重新提炼;变更后保存草稿会取消全部采纳,须重新核对。</p>
|
||||
<p v-if="roleDirty" class="review-error" role="status">角色标注尚未保存,请先保存草稿,再逐项核对。</p>
|
||||
</section>
|
||||
<FollowupAudioReview :key="selectedId" ref="reviewRef" v-model:items="reviewItems" :fields="detail.fields || capabilities.fields" :disabled="!canEditReview || busy" :can-daily="capabilities.can_daily" :applied="detail.status === 'applied'" :uncertainties="detail.uncertainties" :snapshot-items="detail.items" :channel-roles="savedChannelRoles" @seek="loadAudio" />
|
||||
<FollowupAudioReview :key="selectedId" ref="reviewRef" v-model:items="reviewItems" :fields="detail.fields || capabilities.fields" :disabled="!canEditReview || busy" :can-daily="capabilities.can_daily" :applied="detail.status === 'applied'" :uncertainties="detail.uncertainties" :snapshot-items="detail.items" :channel-roles="savedChannelRoles" @seek="loadDialogue" />
|
||||
<el-collapse v-model="detailSections" class="original-collapse">
|
||||
<el-collapse-item title="录音原文" name="original">
|
||||
<el-collapse-item title="完整对话与录音回听" name="original">
|
||||
<div class="audio-player">
|
||||
<el-button v-if="detail.audio_available" size="small" :loading="audioLoading" @click="reloadAudio">{{ audioUrl ? '重新加载录音' : '加载录音播放' }}</el-button>
|
||||
<span v-else class="muted">录音不可播放或已清理</span>
|
||||
<audio v-if="audioUrl" :key="audioUrl" ref="audioElement" :src="audioUrl" controls preload="metadata" />
|
||||
</div>
|
||||
<div v-if="hasStereo && detail.audio_available" class="channel-playback" aria-label="声道回听">
|
||||
<el-button size="small" :disabled="audioLoading || channelLoading" @click="playChannel('original')">原音</el-button><el-button size="small" :disabled="audioLoading || channelLoading" @click="playChannel(0)">仅听左声道</el-button><el-button size="small" :disabled="audioLoading || channelLoading" @click="playChannel(1)">仅听右声道</el-button>
|
||||
<span class="muted" role="status">{{ channelLoading ? '正在切换声道…' : !audioUrl ? '尚未加载录音' : playbackChannel === 'original' ? '回听模式:原音' : `回听模式:仅${playbackChannel === 0 ? '左' : '右'}声道` }}</span>
|
||||
<el-button size="small" :type="playbackChannel === 'original' ? 'primary' : 'default'" :aria-pressed="playbackChannel === 'original'" :disabled="audioLoading || channelLoading" @click="playChannel('original')">完整对话</el-button>
|
||||
<el-button v-for="channel in ([0, 1] as const)" :key="channel" size="small" :type="playbackChannel === channel ? 'primary' : 'default'" :aria-pressed="playbackChannel === channel" :disabled="audioLoading || channelLoading" @click="playChannel(channel)">{{ channelPlaybackLabel(channel, savedChannelRoles) }}</el-button>
|
||||
<span class="muted" role="status">{{ channelLoading ? '正在切换声道…' : !audioUrl ? '尚未加载录音' : playbackChannel === 'original' ? '回听模式:双方对话' : `回听模式:仅${playbackChannel === 0 ? '左' : '右'}声道` }}</span>
|
||||
</div>
|
||||
<p v-if="playbackError" class="review-error" role="alert">{{ playbackError }}</p>
|
||||
<p v-if="detail.audio_available" class="muted">回听对话默认从该区间前 3 秒开始,连续播放双方声音;可随时切换为只听一方。</p>
|
||||
<div v-if="transcriptSegments.length" class="transcript-segments">
|
||||
<p class="muted">以下按录音区间展示,非逐字对齐;同一区间两侧可能同时发言,不表示发言先后。</p>
|
||||
<section v-for="segment in transcriptSegments" :key="segment.id" class="transcript-segment">
|
||||
<div class="segment-heading"><span>{{ audioTimestamp(segment.start_ms) }}–{{ audioTimestamp(segment.end_ms) }} · {{ followupChannelLabel(segment.channel, savedChannelRoles) }}</span><el-button v-if="detail.audio_available" link type="primary" @click="loadAudio(segment.start_ms, segment.channel)">回听片段</el-button></div>
|
||||
<pre>{{ segment.text || '(该区间无转写文本)' }}</pre>
|
||||
<p class="muted">双方转写按同一时间区间并列展示,不表示发言先后;重叠发言与具体问答请结合录音核对。</p>
|
||||
<section v-for="window in conversationWindows" :key="window.id" class="transcript-segment">
|
||||
<div class="segment-heading"><span>{{ audioTimestamp(window.start_ms) }}–{{ audioTimestamp(window.end_ms) }}</span><el-button v-if="detail.audio_available" link type="primary" @click="loadDialogue(window.start_ms)">回听双方对话</el-button></div>
|
||||
<div class="dialogue-sides">
|
||||
<div v-for="segment in window.segments" :key="segment.id" class="dialogue-side">
|
||||
<strong>{{ followupChannelLabel(segment.channel, savedChannelRoles) }}</strong>
|
||||
<pre>{{ segment.text || '(该区间无转写文本,不代表没有说话)' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<pre v-else class="transcript-text">{{ detail.transcript || '暂无转录' }}</pre>
|
||||
@@ -103,6 +110,14 @@
|
||||
<el-form-item label="实际通话时间(北京时间)">
|
||||
<el-date-picker v-model="recordedAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="用于识别昨天、前天等相对日期" :disabled="!canUpload || busy || createUncertain" />
|
||||
</el-form-item>
|
||||
<el-form-item label="本次录音的声道角色">
|
||||
<el-select v-model="uploadChannelRoles" aria-label="本次录音的声道角色" :disabled="!canUpload || busy || createUncertain">
|
||||
<el-option label="暂不确定 / 单声道" value="unconfirmed" />
|
||||
<el-option label="左客服 / 右患者或家属" value="left_service" />
|
||||
<el-option label="右客服 / 左患者或家属" value="right_service" />
|
||||
</el-select>
|
||||
<small>已知角色会在提炼前提供给模型;不确定请勿猜测。单声道不按左右区分身份。</small>
|
||||
</el-form-item>
|
||||
<el-form-item label="分析模型">
|
||||
<el-select v-model="modelKey" :disabled="!canUpload || busy || createUncertain" placeholder="选择已配置的模型"><el-option v-for="model in capabilities.models" :key="model.value" :label="model.label" :value="model.value" /></el-select>
|
||||
</el-form-item>
|
||||
@@ -126,7 +141,7 @@ import {
|
||||
type FollowupAudioChannel, type FollowupCapabilities, type FollowupChannelRoles, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary
|
||||
} from '@/api/followupAudio'
|
||||
import FollowupAudioReview from './FollowupAudioReview.vue'
|
||||
import { audioTimestamp, changedReviewTarget, cloneReviewItems, createFollowupScope, followupAudioReady, followupCanApply, followupCanReview, followupChannelLabel, followupError, followupFeatureEnabled, followupProgressText, followupTaskStatus, hasStereoTranscript, normalizedChannelRoles, rebaseReviewItems, retainedTranscriptNotice, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState'
|
||||
import { channelPlaybackLabel, dialogueWindows, dialoguePlaybackStart, audioTimestamp, changedReviewTarget, cloneReviewItems, createFollowupScope, followupAudioReady, followupCanApply, followupCanReview, followupChannelLabel, followupError, followupFeatureEnabled, followupProgressText, followupTaskStatus, hasStereoTranscript, normalizedChannelRoles, rebaseReviewItems, retainedTranscriptNotice, reviewFieldIssue, reviewIssue, reviewPending, uploadChunks, uploadIssue } from './followupAudioState'
|
||||
import { createFollowupAudioPlayback, type FollowupPlaybackChannel } from './followupAudioPlayback'
|
||||
const props = withDefaults(defineProps<{
|
||||
diagnosisId: number; viewOnly?: boolean; active?: boolean; capabilities: FollowupCapabilities;
|
||||
@@ -143,6 +158,7 @@ const roleSnapshot = ref<FollowupChannelRoles>('unconfirmed')
|
||||
const roleDirty = computed(() => channelRoles.value !== roleSnapshot.value)
|
||||
const savedChannelRoles = computed(() => normalizedChannelRoles(detail.value?.channel_roles))
|
||||
const transcriptSegments = computed(() => [...(detail.value?.transcript_segments || [])].sort((a, b) => a.start_ms - b.start_ms || (a.channel ?? -1) - (b.channel ?? -1)))
|
||||
const conversationWindows = computed(() => dialogueWindows(transcriptSegments.value))
|
||||
const hasStereo = computed(() => hasStereoTranscript(transcriptSegments.value))
|
||||
const reviewRef = ref<{ hasUnsavedChanges: () => boolean } | null>(null)
|
||||
const dirty = computed(() => roleDirty.value || JSON.stringify(reviewItems.value) !== snapshot.value || !!reviewRef.value?.hasUnsavedChanges())
|
||||
@@ -152,6 +168,7 @@ const detailSections = ref<string[]>([])
|
||||
const file = ref<File | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const recordedAt = ref('')
|
||||
const uploadChannelRoles = ref<FollowupChannelRoles>('unconfirmed')
|
||||
const modelKey = ref(props.capabilities.models[0]?.value || '')
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
@@ -283,7 +300,7 @@ function statusType(status: string) {
|
||||
if (status === 'review') return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
function chooseFile(event: Event) { file.value = (event.target as HTMLInputElement).files?.[0] || null; uploadProgress.value = 0; uploadStage.value = '' }
|
||||
function chooseFile(event: Event) { uploadChannelRoles.value = 'unconfirmed'; file.value = (event.target as HTMLInputElement).files?.[0] || null; uploadProgress.value = 0; uploadStage.value = '' }
|
||||
async function submitUpload() {
|
||||
if (busy.value || createUncertain.value) return
|
||||
const problem = uploadIssue(file.value, recordedAt.value, modelKey.value, props.capabilities, props.viewOnly)
|
||||
@@ -306,7 +323,7 @@ async function submitUpload() {
|
||||
await followupAudioUploadComplete(session.upload_id, ticket.signal)
|
||||
if (!scope.current(ticket) || !canUpload.value) return
|
||||
uploadStage.value = '正在创建分析任务'; creating = true; createUncertain.value = true
|
||||
const result = await followupAudioCreate({ diagnosis_id: props.diagnosisId, upload_id: session.upload_id, recorded_at: recordedAt.value, model_key: modelKey.value }, ticket.signal)
|
||||
const result = await followupAudioCreate({ diagnosis_id: props.diagnosisId, upload_id: session.upload_id, recorded_at: recordedAt.value, model_key: modelKey.value, channel_roles: uploadChannelRoles.value }, ticket.signal)
|
||||
if (!scope.current(ticket)) return
|
||||
createUncertain.value = false; uploading.value = false; uploadVisible.value = false; file.value = null; if (fileInput.value) fileInput.value.value = ''
|
||||
uploadStage.value = result.reused
|
||||
@@ -373,6 +390,7 @@ async function retryTask() {
|
||||
catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) }
|
||||
finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } }
|
||||
}
|
||||
async function loadDialogue(milliseconds: number) { await loadAudio(dialoguePlaybackStart(milliseconds)) }
|
||||
async function reloadAudio() { releaseAudio(); await loadAudio() }
|
||||
async function playChannel(channel: FollowupPlaybackChannel) { await loadAudio((audioElement.value?.currentTime || 0) * 1000, channel === 'original' ? undefined : channel) }
|
||||
async function loadAudio(milliseconds?: number, channel?: FollowupAudioChannel) {
|
||||
@@ -420,7 +438,7 @@ watch([() => props.diagnosisId, effectiveActive, () => !!props.capabilities.prev
|
||||
stop()
|
||||
if (id !== lastDiagnosis) {
|
||||
resetRoles(); lastDiagnosis = id; selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'
|
||||
tasks.value = []; file.value = null; recordedAt.value = ''; uploadVisible.value = false; detailSections.value = []; helpVisible.value = false; errorMessage.value = ''; uploadStage.value = ''; createUncertain.value = false
|
||||
tasks.value = []; file.value = null; recordedAt.value = ''; uploadChannelRoles.value = 'unconfirmed'; uploadVisible.value = false; detailSections.value = []; helpVisible.value = false; errorMessage.value = ''; uploadStage.value = ''; createUncertain.value = false
|
||||
}
|
||||
if (active) void refresh()
|
||||
}, { immediate: true })
|
||||
@@ -468,6 +486,9 @@ h3 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.transcript-segment { padding: 10px 0; border-bottom: 1px solid #e2e8f0; }
|
||||
.segment-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.transcript-segment pre { margin: 5px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: inherit; }
|
||||
.dialogue-sides { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 10px; margin-top: 8px; }
|
||||
.dialogue-side { padding: 10px; background: var(--el-fill-color-light); border-radius: 6px; min-width: 0; }
|
||||
.dialogue-side strong { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.original-collapse { --el-collapse-border-color: #e2e8f0; border-top: 0; }
|
||||
.original-collapse :deep(.el-collapse-item__header) { height: 38px; font-size: 13px; color: var(--el-text-color-regular); }
|
||||
.original-collapse :deep(.el-collapse-item__content) { padding-bottom: 10px; }
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="evidence">
|
||||
<div class="evidence-title">录音原话</div>
|
||||
<blockquote v-for="(quote, index) in editor.evidence" :key="index"><div v-if="quote.channel === 0 || quote.channel === 1" class="muted">{{ followupChannelLabel(quote.channel, channelRoles) }}</div><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.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>
|
||||
@@ -157,8 +157,7 @@ function candidateLabel(item: FollowupReviewItem, candidate: NonNullable<Followu
|
||||
}
|
||||
function seekEvidence(quote: FollowupReviewItem['evidence'][number]) {
|
||||
if (!evidencePlaybackLabel(quote) || typeof quote.start_ms !== 'number') return
|
||||
if (quote.channel === 0 || quote.channel === 1) emit('seek', quote.start_ms, quote.channel)
|
||||
else emit('seek', quote.start_ms)
|
||||
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 => {
|
||||
|
||||
@@ -27,7 +27,7 @@ export function evidencePlaybackLabel(quote: FollowupReviewItem['evidence'][numb
|
||||
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)}${quote.channel === undefined ? '' : ` · ${quote.channel === 0 ? '左' : '右'}声道`}`
|
||||
return `回听对话 ${audioTimestamp(quote.start_ms)}–${audioTimestamp(quote.end_ms)}`
|
||||
}
|
||||
return `播放 ${audioTimestamp(quote.start_ms)}`
|
||||
}
|
||||
@@ -42,6 +42,25 @@ export function followupChannelLabel(channel?: FollowupAudioChannel, roles: Foll
|
||||
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 '未记录'
|
||||
|
||||
@@ -189,7 +189,7 @@ test('upload actual panel sends actual recorded_at/model and chunks before compl
|
||||
f.panel.recordedAt.value = '2026-09-29 09:00:00'; f.panel.modelKey.value = 'openai'
|
||||
await f.panel.submitUpload(); await settle()
|
||||
assert.deepEqual(f.calls.filter(call => call[0] === 'chunk').map(call => call.slice(2)), [[0, 2097152], [1, 1]])
|
||||
assert.deepEqual(f.calls.find(call => call[0] === 'create')[1], { diagnosis_id: 10, upload_id: 'private-id', recorded_at: '2026-09-29 09:00:00', model_key: 'openai' })
|
||||
assert.deepEqual(f.calls.find(call => call[0] === 'create')[1], { diagnosis_id: 10, upload_id: 'private-id', recorded_at: '2026-09-29 09:00:00', model_key: 'openai', channel_roles: 'unconfirmed' })
|
||||
assert.ok(f.calls.findIndex(call => call[0] === 'complete') < f.calls.findIndex(call => call[0] === 'create'))
|
||||
assert.equal(f.panel.createUncertain.value, false)
|
||||
f.dispose()
|
||||
@@ -738,7 +738,7 @@ test('old-patient retained-transcript completion cannot expose text or reopen cu
|
||||
|
||||
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(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] })] })
|
||||
@@ -914,7 +914,7 @@ test('stereo transcript order is window/channel, roles are unconfirmed until exp
|
||||
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
f.dispose()
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.match(source, /同一区间两侧可能同时发言,不表示发言先后/)
|
||||
assert.match(source, /双方转写按同一时间区间并列展示,不表示发言先后/)
|
||||
assert.match(source, /<pre v-else class="transcript-text">/)
|
||||
})
|
||||
|
||||
@@ -1126,12 +1126,12 @@ test('late stereo blob and routing resume do not play or relabel another patient
|
||||
g.dispose()
|
||||
})
|
||||
|
||||
test('channel evidence is immutable through editor/draft and seek emits channel without guessing a role', async () => {
|
||||
test('channel evidence stays immutable while seek defaults to full dialogue', async () => {
|
||||
const quote = { text: '合成右侧原话', segment_id: 'right-1', start_ms: 15000, end_ms: 30000, position_type: 'segment', channel: 1 }
|
||||
const f = setupReview({ items: [item({ evidence: [quote] })], channelRoles: 'unconfirmed' })
|
||||
f.review.openReview('blood-1'); f.review.seekEvidence(f.review.editor.value.evidence[0])
|
||||
assert.deepEqual(f.events, [['seek', 15000, 1]])
|
||||
assert.equal(state.evidencePlaybackLabel(quote), '回听片段 0:15–0:30 · 右声道')
|
||||
assert.deepEqual(f.events, [['seek', 15000]])
|
||||
assert.equal(state.evidencePlaybackLabel(quote), '回听对话 0:15–0:30')
|
||||
f.review.editor.value.evidence[0].channel = 0
|
||||
f.review.setValue('fasting_blood_sugar', 7); f.review.confirmReview(true); f.review.saveReview(true)
|
||||
assert.deepEqual(f.props.items[0].evidence, [quote])
|
||||
@@ -1459,3 +1459,63 @@ test('manual human-reviewed business mode exposes upload and explicit Apply with
|
||||
await f.panel.applySelected(); assert.equal(f.calls.filter(x => x[0] === 'apply').length, 1)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
|
||||
test('dialogue windows retain both channels, empty replies and coarse times without mutating evidence', () => {
|
||||
const segments = stereoSegments().reverse()
|
||||
const before = JSON.stringify(segments)
|
||||
const windows = state.dialogueWindows(segments)
|
||||
assert.deepEqual(windows.map(row => [row.start_ms, row.end_ms, row.segments.map(segment => segment.channel)]), [[0, 15000, [0, 1]], [15000, 30000, [0, 1]]])
|
||||
assert.equal(JSON.stringify(segments), before)
|
||||
assert.equal(state.dialogueWindows([{ ...segments[0], text: '' }])[0].segments[0].text, '')
|
||||
assert.equal(state.dialoguePlaybackStart(15000), 12000)
|
||||
assert.equal(state.dialoguePlaybackStart(1000), 0)
|
||||
assert.equal(state.dialoguePlaybackStart(NaN), 0)
|
||||
assert.equal(state.channelPlaybackLabel(1, 'left_service'), '只听患者/家属')
|
||||
assert.equal(state.channelPlaybackLabel(0, 'right_service'), '只听患者/家属')
|
||||
assert.equal(state.channelPlaybackLabel(1, 'unconfirmed'), '只听右声道')
|
||||
})
|
||||
|
||||
test('full dialogue evidence restores both channels after isolated playback and adds bounded context', async () => {
|
||||
const audio = fakeAudioContext()
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask({ channel_roles: 'left_service' }) }, {}, { playback: audio.playback })
|
||||
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
|
||||
await f.panel.loadAudio(15000, 0)
|
||||
assert.equal(f.panel.playbackChannel.value, 0)
|
||||
await f.panel.loadDialogue(15000)
|
||||
assert.equal(f.panel.playbackChannel.value, 'original')
|
||||
assert.equal(audio.element.currentTime, 12)
|
||||
assert.deepEqual(audio.nodes.find(node => node.name === 'media').edges.map(edge => edge.target.name), ['destination'])
|
||||
assert.deepEqual(audio.nodes.find(node => node.name === 'splitter').edges, [])
|
||||
assert.equal(audio.element.paused, false)
|
||||
assert.equal(f.panel.conversationWindows.value.length, 2)
|
||||
f.dispose()
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.match(source, /@seek="loadDialogue"/)
|
||||
assert.match(source, /@click="loadDialogue\(window.start_ms\)"/)
|
||||
assert.doesNotMatch(source, /@click="loadAudio\(segment.start_ms, segment.channel\)"/)
|
||||
})
|
||||
|
||||
test('upload sends explicitly selected pre-extraction roles and clears them on patient switch', async () => {
|
||||
for (const roles of ['left_service', 'right_service']) {
|
||||
const f = setupPanel()
|
||||
await settle(); f.panel.openUpload()
|
||||
f.panel.file.value = new File(['synthetic'], 'test.wav')
|
||||
f.panel.recordedAt.value = '2026-09-29 09:00:00'
|
||||
f.panel.uploadChannelRoles.value = roles
|
||||
await f.panel.submitUpload()
|
||||
assert.equal(f.calls.find(call => call[0] === 'create')[1].channel_roles, roles)
|
||||
f.props.diagnosisId = 20; await settle()
|
||||
assert.equal(f.panel.uploadChannelRoles.value, 'unconfirmed')
|
||||
f.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
test('a new recording never inherits the previous file role assignment', async () => {
|
||||
const f = setupPanel(); await settle()
|
||||
f.panel.uploadChannelRoles.value = 'left_service'
|
||||
f.panel.chooseFile({ target: { files: [new File(['synthetic'], 'new.wav')] } })
|
||||
assert.equal(f.panel.uploadChannelRoles.value, 'unconfirmed')
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user