;rgb:0000/0000/0000ii:
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user