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
+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()
})