;rgb:0000/0000/0000ii:

This commit is contained in:
gr
2026-11-12 09:04:58 +08:00
parent a62311a8da
commit 875c1049c0
6 changed files with 212 additions and 14 deletions
@@ -694,6 +694,19 @@ class LocalAudioUploadManager:
finally:
self._transcription_jobs.task_done()
def _find_call(self, record: LocalAudioRecord) -> dict[str, Any] | None:
records = self.repository.list_call_records(record.diagnosis_id)
return next(
(row for row in records if isinstance(row, dict)
and str(row.get("id")) == str(record.call_record_id)),
None,
)
@staticmethod
def _server_text_complete(call: dict[str, Any]) -> bool:
return (call.get("transcription_status") == "completed"
and bool(str(call.get("transcript_text") or "").strip()))
def _transcribe(self, record_id: int) -> bool:
record = self.store.claim_transcription(record_id)
if record is None:
@@ -702,23 +715,28 @@ class LocalAudioUploadManager:
save = getattr(self.repository, "save_local_audio_transcript", None)
if not callable(save):
raise RecordingTranscriptionError("当前服务未提供文字补全接口,请更新应用及服务端。")
records = self.repository.list_call_records(record.diagnosis_id)
call = next(
(row for row in records if isinstance(row, dict)
and str(row.get("id")) == str(record.call_record_id)),
None,
)
call = self._find_call(record)
if call is None:
raise RecordingTranscriptionError("未找到对应通话记录,请检查账号和诊单后重试文字。")
if call.get("transcription_status") == "completed" and str(call.get("transcript_text") or "").strip():
# 本机队列跨服务器、跨账号保留:换了服务器后旧录音的通话记录在当前服务器不存在。
raise RecordingTranscriptionError(
f"当前服务器的诊单 {record.diagnosis_id} 下没有通话记录 "
f"{record.call_record_id},请确认登录的服务器与录音时一致后重试文字。"
)
if self._server_text_complete(call):
self.store.finish_transcription(record.id)
self._notify_uploaded(record.id)
return True
user = self.repository.get_current_user()
if (int(getattr(user, "id", 0) or 0) <= 0
or str(call.get("caller_id")) != str(user.id)
user_id = int(getattr(user, "id", 0) or 0)
if user_id <= 0:
raise AuthenticationExpiredError("登录信息不完整,请重新登录后重试文字。")
if (str(call.get("caller_id")) != str(user_id)
or call.get("caller_type") != "doctor"):
raise RecordingTranscriptionError("请使用发起这次通话的医生账号补全文字。")
# 服务端只允许发起通话的医生账号补写文字;共用电脑时另一账号的录音会留在队列里。
raise RecordingTranscriptionError(
f"这段录音的通话由账号 #{call.get('caller_id')} 发起,当前登录账号为 "
f"#{user_id},请用发起通话的医生账号登录后重试文字。"
)
if int(call.get("status") or 0) == 1:
raise _CallStillEnding()
if int(call.get("status") or 0) != 2:
@@ -739,6 +757,15 @@ class LocalAudioUploadManager:
if not isinstance(text, str) or not text.strip() or len(text) > 100_000:
raise RecordingTranscriptionError("未生成可保存的文字,请检查录音及本地模型后重试。")
self.store.save_transcription_draft(record.id, text)
# 本机识别可能耗时数分钟,期间实时转写可能刚完成收尾。按服务端最新状态提交,
# 否则 expected_* 校验会把这份已识别的文字拒掉。
refreshed = self._find_call(record)
if refreshed is not None:
call = refreshed
if self._server_text_complete(call):
self.store.finish_transcription(record.id)
self._notify_uploaded(record.id)
return True
result = save(
diagnosis_id=record.diagnosis_id,
call_record_id=record.call_record_id,
@@ -215,11 +215,44 @@ def test_upload_finishes_before_call_end_then_text_recovers_automatically(tmp_pa
)
assert manager.submit(record_id).result(3)
assert saved.wait(3)
assert len(calls) == 2
# One poll while the call is still ending, one after it ended, one refresh right before saving.
assert len(calls) == 3
assert repository.uploads == 1
assert len(repository.saves) == 1
def test_save_compares_against_server_state_refreshed_after_recognition(tmp_path):
store = LocalAudioQueueStore(tmp_path)
record_id = ready(store)
repository = Repository()
states = [
{**repository.call, "transcription_status": "running", "transcript_text": ""},
{**repository.call, "transcription_status": "partial", "transcript_text": "旧的片段"},
]
repository.list_call_records = lambda _diagnosis_id: [states.pop(0)]
manager = LocalAudioUploadManager(repository, store, transcriber=lambda _path: "合成文字")
assert manager.submit_transcription(record_id).result(3)
# The realtime finish landed while local recognition ran; the CAS uses the new status.
assert repository.saves[0]["expected_status"] == "partial"
assert repository.saves[0]["expected_session_id"] == "live-session"
assert store.require(record_id).transcription_status == "completed"
def test_text_completed_during_recognition_skips_save_without_failing(tmp_path):
store = LocalAudioQueueStore(tmp_path)
record_id = ready(store)
repository = Repository()
states = [
{**repository.call, "transcription_status": "partial"},
{**repository.call, "transcription_status": "completed", "transcript_text": "医生:已经保存"},
]
repository.list_call_records = lambda _diagnosis_id: [states.pop(0)]
manager = LocalAudioUploadManager(repository, store, transcriber=lambda _path: "合成文字")
assert manager.submit_transcription(record_id).result(3)
assert repository.saves == []
assert store.require(record_id).transcription_status == "completed"
def test_waiting_for_call_end_is_bounded_and_retry_remains_available(tmp_path, monkeypatch):
monkeypatch.setattr(queue_module, "_TRANSCRIPTION_END_RETRY_DELAYS", (0.01, 0.01))
store = LocalAudioQueueStore(tmp_path)
+41 -2
View File
@@ -1817,8 +1817,44 @@ function normalizeRoomId(raw: unknown): string {
return value && value !== '0' ? value : ''
}
function normalizeRoomPair(roomID: unknown, strRoomID: unknown): string {
return normalizeRoomScalar(strRoomID) || normalizeRoomScalar(roomID)
}
function normalizeRoomScalar(raw: unknown): string {
return typeof raw === 'string' || typeof raw === 'number' ? normalizeRoomId(raw) : ''
}
/** 引擎 calls()/call() 的返回值通常直接带房间号(含 data 嵌套)。 */
function extractRoomFromEngineResult(result: unknown): string {
if (!result || typeof result !== 'object') return ''
const record = result as Record<string, unknown>
const direct = normalizeRoomPair(record.roomID, record.strRoomID)
|| normalizeRoomPair(record.roomId, record.strRoomId)
if (direct) return direct
const nested = record.data
if (!nested || typeof nested !== 'object') return ''
const data = nested as Record<string, unknown>
return normalizeRoomPair(data.roomID, data.strRoomID) || normalizeRoomPair(data.roomId, data.strRoomId)
}
/** 部分 CallKit 版本会在宿主落库前清掉 Store 里的 ROOM_ID,引擎实例上的字段作兜底。 */
function readRoomIdFromEngine(): string {
try {
const engine = TUICallKitAPI.getTUICallEngineInstance?.() as Record<string, unknown> | null | undefined
if (!engine) return ''
for (const key of ['roomId', 'roomID', '_roomId', '_roomID', 'strRoomID', 'strRoomId']) {
const found = normalizeRoomScalar(engine[key])
if (found) return found
}
} catch {
// 引擎未初始化或字段不可访问时只用 Store。
}
return ''
}
function readRoomId(): string {
return normalizeRoomId(TUIStore.getData(StoreName.CALL, NAME.ROOM_ID))
return normalizeRoomScalar(TUIStore.getData(StoreName.CALL, NAME.ROOM_ID)) || readRoomIdFromEngine()
}
function observeRoomId(rawRoomId: unknown): boolean {
@@ -2004,10 +2040,13 @@ async function startVideo(): Promise<void> {
boundRoomId = ''
roomBindingSentAt = 0
roomBindingAttempts = 0
await TUICallKitAPI.calls({
const callResult: unknown = await TUICallKitAPI.calls({
userIDList: [activeConfig.targetUserId],
type: TUICallType.VIDEO_CALL,
})
// 房间号优先取引擎返回值:Store 的 ROOM_ID 可能晚一拍或已被清空;
// 房间不绑定,服务端就不会发起云端录制,这次面诊的回放会直接丢失。
observeRoomId(extractRoomFromEngineResult(callResult) || readRoomId())
void pollRoomId()
emit({ source: 'doctor-call', event: 'status', diagnosisId: activeConfig.diagnosisId, status: 'dialing' })
} catch (error) {
@@ -0,0 +1,99 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ts = require('typescript')
// 房间号是云端录制的前提:宿主没有绑定房间,服务端就不会发起合流录制,
// 回放随之丢失。这里验证房间号可以从引擎返回值 / 引擎实例兜底得到,
// 而不是只依赖 CallKit Store 里可能晚一拍或被清空的 ROOM_ID。
const source = fs.readFileSync(path.join(__dirname, '../src/main.ts'), 'utf8')
const ast = ts.createSourceFile('main.ts', source, ts.ScriptTarget.Latest, true)
const names = new Set([
'normalizeRoomId', 'normalizeRoomScalar', 'normalizeRoomPair', 'extractRoomFromEngineResult',
'readRoomIdFromEngine', 'readRoomId', 'observeRoomId', 'emitRoomId', 'roomBindingResult',
'recordingResult',
])
const declarations = ast.statements.filter(node => ts.isFunctionDeclaration(node) && names.has(node.name?.text))
assert.equal(declarations.length, names.size)
const compiled = ts.transpileModule(declarations.map(node => node.getText(ast)).join('\n'), {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText
function harness(options = {}) {
return new Function('options', `${compiled}
let activeConfig = {userID:'doctor_1',targetUserId:'patient_1',diagnosisId:501};
let endNotified=false, notice={value:''};
let emittedRoomId='', pendingRoomId='', boundRoomId='', roomBindingSentAt=0, roomBindingAttempts=0;
let now=100000;
const Date=class extends globalThis.Date { constructor(...args){super(...(args.length?args:[now]))} static now(){return now} };
const events=[];
const emit=event=>events.push(event);
const StoreName={CALL:'call'}, NAME={ROOM_ID:'roomId'};
const TUIStore={getData(){return options.store}};
const TUICallKitAPI={getTUICallEngineInstance(){ if(options.engineThrows) throw new Error('no engine'); return options.engine }};
return {
events, extractRoomFromEngineResult, readRoomId, observeRoomId, emitRoomId, roomBindingResult,
advance(ms){now+=ms}, bound:()=>boundRoomId, pending:()=>pendingRoomId, notice:()=>notice.value,
end(){endNotified=true},
}`)(options)
}
test('engine call result yields the room id, including nested and string forms', () => {
const h = harness()
assert.equal(h.extractRoomFromEngineResult({ roomID: 1666328191 }), '1666328191')
assert.equal(h.extractRoomFromEngineResult({ roomID: 0, strRoomID: 'room-abc' }), 'room-abc')
assert.equal(h.extractRoomFromEngineResult({ data: { roomId: '42' } }), '42')
assert.equal(h.extractRoomFromEngineResult({ roomID: 0 }), '')
assert.equal(h.extractRoomFromEngineResult({ roomID: {} }), '')
assert.equal(h.extractRoomFromEngineResult(null), '')
assert.equal(h.extractRoomFromEngineResult('1234'), '')
})
test('store room id wins, engine instance fields are the fallback', () => {
assert.equal(harness({ store: '77', engine: { _roomId: 123 } }).readRoomId(), '77')
assert.equal(harness({ store: 0, engine: { _roomId: 123 } }).readRoomId(), '123')
assert.equal(harness({ store: undefined, engine: { strRoomID: 'str-room' } }).readRoomId(), 'str-room')
assert.equal(harness({ store: '', engine: { roomId: { nested: true } } }).readRoomId(), '')
assert.equal(harness({ store: '', engineThrows: true }).readRoomId(), '')
assert.equal(harness({ store: '', engine: null }).readRoomId(), '')
})
test('a room learned from calls() is bound once and retried with backoff until acknowledged', () => {
const h = harness({ store: 0, engine: null })
assert.equal(h.observeRoomId(h.extractRoomFromEngineResult({ roomID: 900 }) || h.readRoomId()), false)
assert.deepEqual(h.events.map(e => e.event), ['room'])
assert.equal(h.events[0].roomId, '900')
assert.equal(h.events[0].diagnosisId, 501)
// Polling the same room inside the backoff window does not spam the host.
h.observeRoomId('900')
h.emitRoomId()
assert.equal(h.events.length, 1)
// A failed bind keeps the room pending; after the backoff it is sent again.
h.roomBindingResult('900', false, '合流录制未启动')
assert.equal(h.pending(), '900')
assert.equal(h.bound(), '')
h.advance(2000)
h.observeRoomId('900')
assert.equal(h.events.length, 2)
h.roomBindingResult('900', true, '')
assert.equal(h.bound(), '900')
assert.equal(h.observeRoomId('900'), true)
assert.equal(h.events.length, 2)
// A different room after binding is reported as a mismatch, never re-bound.
assert.equal(h.observeRoomId('901'), false)
assert.equal(h.events.length, 2)
})
test('empty room ids and finished calls never emit a bind request', () => {
const h = harness({ store: 0, engine: null })
assert.equal(h.observeRoomId(''), false)
assert.equal(h.observeRoomId('0'), false)
assert.equal(h.emitRoomId(), false)
h.end()
assert.equal(h.observeRoomId('900'), false)
assert.equal(h.events.length, 0)
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB