feat(followup-audio): review full dialogue and bind roles before extraction

This commit is contained in:
2026-10-10 14:25:03 +08:00
parent f015b26ae1
commit b4acad8510
14 changed files with 360 additions and 43 deletions
+1 -1
View File
@@ -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 '未记录'
+66 -6
View File
@@ -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()
})