feat: preserve stereo channels and require reviewed speaker roles

This commit is contained in:
2026-10-08 16:15:41 +08:00
parent 13948f5982
commit a20d3565af
28 changed files with 1134 additions and 113 deletions
+340
View File
@@ -15,6 +15,7 @@ function execute(source, dependencies, globals = {}) {
}
function loadTs(filename, dependencies = require) { return execute(fs.readFileSync(filename, 'utf8'), dependencies) }
const state = loadTs(path.join(componentDir, 'followupAudioState.ts'))
const playbackModule = loadTs(path.join(componentDir, 'followupAudioPlayback.ts'))
const apiModule = loadTs(path.join(root, 'src/api/followupAudio.ts'), () => ({ default: {} }))
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve() }
@@ -48,6 +49,7 @@ function setupPanel(overrides = {}, propsPatch = {}, options = {}) {
if (name === 'element-plus') return { ElMessage: Object.fromEntries(['success', 'warning', 'error'].map(kind => [kind, msg => notices.push([kind, msg])])), ElMessageBox: { confirm: options.confirm || (async () => true) } }
if (name === '@/api/followupAudio') return deps
if (name === './followupAudioState') return state
if (name === './followupAudioPlayback') return options.playback || playbackModule
if (name === './FollowupAudioReview.vue') return { default: {} }
throw new Error(`Unexpected import: ${name}`)
}, {
@@ -866,3 +868,341 @@ test('summary is explicitly an unverified AI draft even after selected rows have
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
assert.match(source, /<div class="summary-box">\s*<div class="muted">回访摘要 · AI未核实草稿,不代表已确认病情<\/div>/)
})
const stereoSegments = () => [
{ id: 'left-0', channel: 0, start_ms: 0, end_ms: 15000, text: '合成左声道文本' },
{ id: 'right-0', channel: 1, start_ms: 0, end_ms: 15000, text: '合成右声道文本' },
{ id: 'left-1', channel: 0, start_ms: 15000, end_ms: 30000, text: '合成左侧后续' },
{ id: 'right-1', channel: 1, start_ms: 15000, end_ms: 30000, text: '合成右侧后续' }
]
const stereoTask = (extra = {}) => task(1, { transcript_segments: stereoSegments(), channel_roles: 'unconfirmed', ...extra })
function fakeAudioContext(options = {}) {
const nodes = [], events = []
const node = name => {
const result = { name, edges: [], connect(target, output = 0, input = 0) { this.edges.push({ target, output, input }); events.push(['connect', name, target.name, output, input]) }, disconnect() { this.edges = []; events.push(['disconnect', name]) } }
nodes.push(result); return result
}
const context = {
state: 'suspended', destination: node('destination'),
createMediaElementSource(element) { events.push(['media', element]); return node('media') },
createChannelSplitter(count) { events.push(['splitter', count]); return node('splitter') },
createChannelMerger(count) { events.push(['merger', count]); return node('merger') },
async resume() { events.push(['resume']); if (options.resume) await options.resume(); context.state = 'running' },
async close() { events.push(['close']); context.state = 'closed' }
}
const element = { currentTime: 0, paused: true, pause() { this.paused = true; events.push(['pause']) }, async play() { this.paused = false; events.push(['play']) } }
return { context, element, nodes, events, playback: { createFollowupAudioPlayback: element => playbackModule.createFollowupAudioPlayback(element, () => context) } }
}
test('stereo transcript order is window/channel, roles are unconfirmed until explicitly saved, legacy stays plain', async () => {
const response = stereoTask({ transcript_segments: [stereoSegments()[3], stereoSegments()[1], stereoSegments()[2], stereoSegments()[0]] })
const f = setupPanel({ followupAudioDetail: async () => response })
await settle(); await f.panel.openTask(1)
assert.deepEqual(f.panel.transcriptSegments.value.map(segment => segment.id), ['left-0', 'right-0', 'left-1', 'right-1'])
assert.equal(response.transcript_segments[0].id, 'right-1', 'display sorting never mutates server evidence')
assert.equal(f.panel.hasStereo.value, true)
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
assert.match(state.followupChannelLabel(0), /左声道 · 角色待确认/)
assert.match(state.followupChannelLabel(1, 'left_service'), /右声道 · 患者\/家属端(人工标注)/)
assert.match(state.followupChannelLabel(1, 'right_service'), /右声道 · 客服端(人工标注)/)
assert.match(state.followupChannelLabel(0, 'invalid'), /角色待确认/)
assert.equal(state.followupChannelLabel(undefined, 'left_service'), '未标注声道')
assert.equal(state.hasStereoTranscript([{ ...stereoSegments()[0], channel: undefined }]), false)
assert.equal(state.hasStereoTranscript([stereoSegments()[0]]), false)
assert.match(f.panel.applyIssue.value, /确认声道角色/)
await f.panel.applySelected()
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, /<pre v-else class="transcript-text">/)
})
test('stereo role draft is dirty, blocks apply and leave, saves exact role/version then requires item re-review', async () => {
let current = stereoTask()
const saves = []
const f = setupPanel({ followupAudioDetail: async () => current, followupAudioSaveDraft: async payload => {
saves.push(payload)
current = { ...current, version: payload.version + 1, channel_roles: payload.channel_roles, items: payload.items.map(row => ({ ...row, selected: false, needs_review: true })) }
return current
} }, {}, { confirm: async () => { throw new Error('cancel') } })
await settle(); await f.panel.openTask(1)
f.panel.setChannelRoles('left_service')
assert.equal(f.panel.roleDirty.value, true)
assert.equal(f.panel.dirty.value, true)
assert.equal(f.panel.savedChannelRoles.value, 'unconfirmed', 'unsaved role never labels a transcript as confirmed')
assert.equal(await f.panel.confirmLeave(), false)
await f.panel.applySelected(); assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
await f.panel.saveDraft()
assert.equal(saves.length, 1)
assert.equal(saves[0].channel_roles, 'left_service')
assert.equal(saves[0].version, 1)
assert.equal(f.panel.roleDirty.value, false)
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
assert.equal(f.panel.selectedCount.value, 0)
assert.equal(f.panel.reviewItems.value[0].needs_review, true)
assert.match(f.notices.at(-1)[1], /不会自动重新提炼/)
f.dispose()
})
test('poll and stale save preserve unsaved role; refreshed save installs authoritative role and clears selections', async () => {
let current = stereoTask()
const f = setupPanel({ followupAudioLists: async () => ({ items: [current] }), followupAudioDetail: async () => current,
followupAudioSaveDraft: async () => { throw new apiModule.FollowupAudioError('版本已变化', 'FOLLOWUP_AUDIO_STALE_REVIEW') } })
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('right_service')
current = { ...current, version: 2, channel_roles: 'left_service' }
f.tick(); await settle()
assert.equal(f.panel.channelRoles.value, 'right_service')
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
assert.equal(f.panel.roleDirty.value, true)
assert.equal(f.panel.reviewItems.value[0].selected, false)
await f.panel.saveDraft()
assert.equal(f.panel.channelRoles.value, 'right_service')
assert.equal(f.panel.roleDirty.value, true)
f.dispose()
const g = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioSaveDraft: async payload => stereoTask({ version: 2, channel_roles: payload.channel_roles, review_refreshed: true, items: [item({ selected: false, needs_review: true })] }) })
await settle(); await g.panel.openTask(1); g.panel.setChannelRoles('right_service'); await g.panel.saveDraft()
assert.equal(g.panel.channelRoles.value, 'right_service')
assert.equal(g.panel.savedChannelRoles.value, 'right_service')
assert.equal(g.panel.roleDirty.value, false)
assert.equal(g.panel.reviewItems.value[0].selected, false)
g.dispose()
})
test('stereo roles honor permission/status gates and mono saves omit channel_roles', async () => {
for (const props of [{ viewOnly: true }, { capabilities: { ...caps(), can_apply: false } }]) {
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, props)
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft()
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
assert.equal(f.calls.filter(call => call[0] === 'save').length, 0)
f.dispose()
}
const f = setupPanel()
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft()
assert.equal(f.panel.hasStereo.value, false)
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
assert.equal(Object.hasOwn(f.calls.find(call => call[0] === 'save')[1], 'channel_roles'), false)
f.dispose()
const g = setupPanel({ followupAudioDetail: async () => stereoTask({ status: 'applied', channel_roles: 'left_service' }) })
await settle(); await g.panel.openTask(1); g.panel.setChannelRoles('right_service')
assert.equal(g.panel.channelRoles.value, 'left_service')
g.dispose()
})
test('permission or unsaved roles changed during apply confirmation prevent sending apply', async () => {
for (const change of [f => f.panel.setChannelRoles('right_service'), f => { f.props.viewOnly = true }]) {
const confirm = deferred()
const f = setupPanel({ followupAudioDetail: async () => stereoTask({ channel_roles: 'left_service' }) }, {}, { confirm: () => confirm.promise })
await settle(); await f.panel.openTask(1)
const applying = f.panel.applySelected(); await settle(); change(f); confirm.resolve(true); await applying
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
f.dispose()
}
})
test('old stereo role save/detail completions cannot populate a new patient or emit apply', async () => {
const pending = deferred()
const f = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioSaveDraft: async () => pending.promise })
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service')
const saving = f.panel.saveDraft(); await settle()
f.props.diagnosisId = 20; await settle()
pending.resolve(stereoTask({ version: 2, channel_roles: 'left_service' })); await saving
assert.equal(f.panel.detail.value, null)
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
assert.equal(f.panel.roleDirty.value, false)
assert.deepEqual(f.panel.transcriptSegments.value, [])
assert.deepEqual(f.events, [])
assert.deepEqual(f.notices, [])
f.dispose()
})
test('WebAudio real routing helper connects only chosen splitter output to both speakers, switches and disposes', async () => {
const f = fakeAudioContext()
const playback = playbackModule.createFollowupAudioPlayback(f.element, () => f.context)
assert.equal(await playback.select('original'), true)
assert.equal(f.events.filter(event => event[0] === 'media').length, 0, 'legacy original playback does not require Web Audio')
for (const channel of [0, 1]) {
assert.equal(await playback.select(channel), true)
const media = f.nodes.find(node => node.name === 'media'), split = f.nodes.find(node => node.name === 'splitter'), merge = f.nodes.find(node => node.name === 'merger')
assert.deepEqual(media.edges.map(edge => edge.target.name), ['splitter'])
assert.deepEqual(split.edges.map(({ target, output, input }) => [target.name, output, input]), [['merger', channel, 0], ['merger', channel, 1]])
assert.deepEqual(merge.edges.map(edge => edge.target.name), ['destination'])
}
assert.equal(f.events.filter(event => event[0] === 'media').length, 1)
assert.equal(await playback.select('original'), true)
assert.deepEqual(f.nodes.find(node => node.name === 'media').edges.map(edge => edge.target.name), ['destination'])
assert.deepEqual(f.nodes.find(node => node.name === 'splitter').edges, [])
playback.dispose(); playback.dispose()
assert.ok(f.nodes.every(node => node.edges.length === 0))
assert.equal(f.events.filter(event => event[0] === 'close').length, 1)
assert.equal(await playback.select(0), false)
})
test('WebAudio unsupported/resume failure never claims isolated playback and pending resume is fenced after dispose', async () => {
const unsupported = fakeAudioContext()
const missing = playbackModule.createFollowupAudioPlayback(unsupported.element, () => null)
await assert.rejects(missing.select(0), /无法隔离声道/)
assert.equal(unsupported.element.paused, true)
assert.equal(await missing.select('original'), true)
missing.dispose()
const failed = fakeAudioContext({ resume: async () => { throw new Error('NotAllowedError') } })
const route = playbackModule.createFollowupAudioPlayback(failed.element, () => failed.context)
await assert.rejects(route.select(1), /无法隔离声道/)
assert.deepEqual(failed.nodes.find(node => node.name === 'media').edges.map(edge => edge.target.name), ['destination'])
assert.equal(failed.events.filter(event => event[0] === 'play').length, 0)
route.dispose()
const resumed = deferred(), pending = fakeAudioContext({ resume: () => resumed.promise })
const delayed = playbackModule.createFollowupAudioPlayback(pending.element, () => pending.context)
const selecting = delayed.select(1); delayed.dispose(); resumed.resolve(); assert.equal(await selecting, false)
assert.ok(pending.nodes.every(node => node.edges.length === 0))
})
test('actual panel selects evidence channel, seeks original timeline and releases routing on hide and task switch', async () => {
const audio = fakeAudioContext()
const f = setupPanel({ followupAudioDetail: async id => stereoTask({ id, channel_roles: 'left_service' }) }, {}, { playback: audio.playback })
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
await f.panel.loadAudio(17000, 1)
assert.equal(f.panel.playbackChannel.value, 1)
assert.equal(audio.element.currentTime, 17)
assert.equal(audio.element.paused, false)
assert.deepEqual(f.calls.filter(call => call[0] === 'audio'), [['audio', 1]])
assert.equal(f.panel.playbackError.value, '')
await f.panel.playChannel(0)
assert.equal(f.panel.playbackChannel.value, 0)
assert.equal(f.calls.filter(call => call[0] === 'audio').length, 1)
f.props.active = false; await settle()
assert.equal(audio.element.paused, true)
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
assert.equal(f.panel.playbackChannel.value, 'original')
assert.equal(f.panel.audioUrl.value, '')
assert.deepEqual(f.revoked, ['blob:private-0'])
f.props.active = true; await settle(); await f.panel.openTask(2)
assert.equal(f.panel.selectedId.value, 2)
assert.equal(f.panel.channelLoading.value, false)
f.dispose()
})
test('actual panel reports unavailable isolation, keeps original playback and never falls back to mixed autoplay', async () => {
const audio = fakeAudioContext()
const unsupported = { createFollowupAudioPlayback: element => playbackModule.createFollowupAudioPlayback(element, () => null) }
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, {}, { playback: unsupported })
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
await f.panel.loadAudio(1000, 0)
assert.match(f.panel.playbackError.value, /无法隔离声道/)
assert.equal(f.panel.playbackChannel.value, 'original')
assert.equal(audio.element.paused, true)
assert.equal(audio.events.filter(event => event[0] === 'play').length, 0)
await f.panel.playChannel('original')
assert.equal(f.panel.playbackError.value, '')
assert.equal(audio.element.paused, false)
f.dispose()
const mono = setupPanel()
await settle(); await mono.panel.openTask(1); mono.panel.audioElement.value = audio.element
await mono.panel.loadAudio(1000, 1)
assert.match(mono.panel.playbackError.value, /未取得可验证的双声道/)
assert.equal(mono.calls.filter(call => call[0] === 'audio').length, 0)
mono.dispose()
})
test('late stereo blob and routing resume do not play or relabel another patient', async () => {
const pending = deferred(), audio = fakeAudioContext({ resume: () => pending.promise })
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, {}, { playback: audio.playback })
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
const play = f.panel.loadAudio(5000, 1); await settle()
assert.equal(f.panel.channelLoading.value, true)
f.props.diagnosisId = 20; await settle(); pending.resolve(); await play
assert.equal(f.panel.audioUrl.value, '')
assert.equal(f.panel.playbackChannel.value, 'original')
assert.equal(f.panel.channelLoading.value, false)
assert.equal(audio.events.filter(event => event[0] === 'play').length, 0)
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
f.dispose()
const blob = deferred(), g = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioBlob: async () => blob.promise }, {}, { playback: audio.playback })
await settle(); await g.panel.openTask(1); g.panel.audioElement.value = audio.element
const loading = g.panel.loadAudio(5000, 0); await settle(); g.props.diagnosisId = 30; await settle()
blob.resolve(new Blob(['synthetic'])); await loading
assert.deepEqual(g.urls, [])
assert.equal(g.panel.detail.value, null)
g.dispose()
})
test('channel evidence is immutable through editor/draft and seek emits channel without guessing a role', 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 · 右声道')
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])
const panel = setupPanel({ followupAudioDetail: async () => stereoTask({ channel_roles: 'right_service', items: f.props.items }) })
await settle(); await panel.panel.openTask(1); await panel.panel.saveDraft()
assert.equal(panel.calls.find(call => call[0] === 'save')[1].items[0].evidence[0].channel, 1)
f.dispose(); panel.dispose()
})
test('API forwards optional channel_roles only in draft and stereo apply uses server roles after explicit re-review', async () => {
const requests = []
const api = loadTs(path.join(root, 'src/api/followupAudio.ts'), () => ({ default: { post: async (config, options) => { requests.push({ config, options }); return { code: 1, data: stereoTask({ channel_roles: 'left_service' }) } } } }))
await api.followupAudioSaveDraft({ id: 1, version: 1, items: [item()], channel_roles: 'left_service' })
assert.equal(requests[0].config.url, '/tcm.followupAudio/saveDraft')
assert.equal(requests[0].config.data.channel_roles, 'left_service')
assert.equal(requests[0].options.isOpenRetry, false)
let current = stereoTask()
const applied = []
const f = setupPanel({ followupAudioDetail: async () => current, followupAudioSaveDraft: async payload => {
current = { ...current, version: 2, channel_roles: payload.channel_roles, items: payload.items.map(row => ({ ...row, selected: false, needs_review: true })) }
return current
}, followupAudioApply: async payload => { applied.push(payload); current = { ...current, status: 'applied', items: payload.items }; return { id: 1, status: 'applied', applied_items: [] } } })
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft()
await f.panel.applySelected(); assert.equal(applied.length, 0)
const review = setupReview({ items: f.panel.reviewItems.value })
review.review.openReview('blood-1'); review.review.confirmReview(true); review.review.saveReview(true)
f.panel.reviewItems.value = review.props.items
await f.panel.applySelected()
assert.equal(applied.length, 1)
assert.equal(applied[0].version, 2)
assert.equal(Object.hasOwn(applied[0], 'channel_roles'), false, 'apply uses authoritative server-saved role mapping')
assert.equal(f.panel.detail.value.status, 'applied')
assert.deepEqual(f.events, [['applied', 10]])
review.dispose(); f.dispose()
})
test('rapid opposing evidence seeks use only the newest channel and original-audio reload closes the old graph', async () => {
const resume = deferred(), audio = fakeAudioContext({ resume: () => resume.promise })
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, {}, { playback: audio.playback })
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
const first = f.panel.loadAudio(2000, 0); await settle()
const second = f.panel.loadAudio(9000, 1); await settle(); resume.resolve(); await Promise.all([first, second])
assert.equal(f.panel.playbackChannel.value, 1)
assert.equal(audio.element.currentTime, 9)
assert.equal(audio.events.filter(event => event[0] === 'play').length, 1)
assert.deepEqual(audio.nodes.find(node => node.name === 'splitter').edges.map(edge => edge.output), [1, 1])
await f.panel.reloadAudio()
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
assert.equal(f.panel.playbackChannel.value, 'original')
assert.deepEqual(f.revoked, ['blob:private-0'])
assert.equal(f.calls.filter(call => call[0] === 'audio').length, 2)
f.dispose()
})
test('task switch and observed audio expiry both close live stereo routing and revoke the authorized blob', async () => {
for (const transition of ['task', 'expired']) {
const audio = fakeAudioContext()
let current = stereoTask({ channel_roles: 'left_service' })
const f = setupPanel({ followupAudioDetail: async id => ({ ...current, id }), followupAudioLists: async () => ({ items: [current] }) }, {}, { playback: audio.playback })
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element; await f.panel.loadAudio(1000, 1)
if (transition === 'task') await f.panel.openTask(2)
else {
current = { ...current, version: 2, status: 'expired', audio_available: false, transcript: '', transcript_segments: [] }
f.tick(); await settle()
assert.deepEqual(f.panel.transcriptSegments.value, [])
}
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
assert.equal(audio.element.paused, true)
assert.deepEqual(f.revoked, ['blob:private-0'])
assert.equal(f.panel.audioUrl.value, '')
assert.equal(f.panel.playbackChannel.value, 'original')
f.dispose()
}
})