This commit is contained in:
Your Name
2026-11-09 10:02:27 +08:00
parent 0c43d54c08
commit 8466acef9f
35 changed files with 3584 additions and 111 deletions
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service;
use app\common\model\tcm\CallRecord;
use DomainException;
use think\facade\Db;
/** Persist a locally recovered transcript without reopening the realtime session. */
final class LocalAudioTranscriptRecovery
{
public static function save(array $params): array
{
foreach (['diagnosis_id', 'call_record_id', 'admin_id'] as $key) {
$value = $params[$key] ?? null;
if ((!is_int($value) && !is_string($value))
|| filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) === false) {
throw new DomainException('录音补转写身份参数无效');
}
}
foreach (['recovery_id', 'expected_session_id', 'expected_status', 'audio_url', 'transcript_text'] as $key) {
if (!isset($params[$key]) || !is_string($params[$key])) {
throw new DomainException('录音补转写参数无效');
}
}
if (!preg_match('/\Aaudio-[a-f0-9]{32}\z/D', $params['recovery_id'])
|| mb_strlen($params['expected_session_id']) > 128
|| !in_array($params['expected_status'], ['', 'pending', 'running', 'partial', 'failed', 'completed'], true)
|| trim($params['audio_url']) === '' || strlen($params['audio_url']) > 8192) {
throw new DomainException('录音补转写会话参数无效');
}
$text = trim($params['transcript_text']);
if ($text === '' || mb_strlen($params['transcript_text']) > 100000) {
throw new DomainException('录音补转写文字必须为1到100000个字符');
}
return Db::transaction(static function () use ($params, $text): array {
// The realtime upsert/finish endpoints lock the same row, so a late
// old-session write cannot run after this session switch succeeds.
$record = CallRecord::where('id', (int) $params['call_record_id'])
->where('diagnosis_id', (int) $params['diagnosis_id'])
->where('caller_id', (int) $params['admin_id'])
->where('caller_type', 'doctor')->lock(true)->find();
if (!$record) { throw new DomainException('通话记录不存在或无权操作'); }
if ((int) $record['status'] !== 2) { throw new DomainException('通话结束后才能补转写'); }
$urls = json_decode((string) ($record['local_audio_urls'] ?? ''), true);
if ((int) ($record['local_audio_status'] ?? 0) !== 2 || !is_array($urls)
|| !in_array($params['audio_url'], $urls, true)) {
throw new DomainException('录音尚未上传或不属于当前通话');
}
$result = ['call_record_id' => (int) $record['id'], 'transcription_status' => 'completed'];
$session = (string) ($record['transcription_session_id'] ?? '');
$status = (string) ($record['transcription_status'] ?? '');
if ($status === 'completed' && trim((string) ($record['transcript_text'] ?? '')) !== '') {
// Lost response retries succeed even with the original CAS values;
// a different completed session always wins without being changed.
return $session === $params['recovery_id']
? $result + ['saved' => true]
: $result + ['saved' => false, 'already_complete' => true];
}
if ($session !== $params['expected_session_id'] || $status !== $params['expected_status']) {
throw new DomainException('通话转写状态已变化,请刷新后重试');
}
$now = time();
$segments = [];
$lines = [];
// Recognition supplied no diarization or alignment. Do not invent
// doctor/patient attribution or timestamps; preserve source order by id.
for ($offset = 0, $index = 0, $length = mb_strlen($text); $offset < $length; $offset += 4000, $index++) {
$part = mb_substr($text, $offset, 4000);
$segments[] = [
'call_record_id' => (int) $record['id'],
'transcription_session_id' => $params['recovery_id'],
'segment_id' => $params['recovery_id'] . '-' . $index,
'speaker_user_id' => '', 'speaker_role' => 'unknown', 'timestamp_ms' => 0,
'text' => $part, 'create_time' => $now, 'update_time' => $now,
];
$lines[] = '未知说话人:' . $part;
}
// Keep all old-session segments for audit; the current session is the
// only one consumed by transcript readers and clinical context builders.
Db::name('tcm_call_transcript_segment')->insertAll($segments);
$record->save([
'transcription_session_id' => $params['recovery_id'],
'transcription_status' => 'completed',
'transcription_segment_count' => count($segments),
'transcript_text' => implode("\n", $lines),
'transcription_started_at' => $now, 'transcription_finished_at' => $now,
'update_time' => $now,
]);
return $result + ['saved' => true];
});
}
public static function completionStatus(string $requested, int $actual, int $expected, string $text): string
{
if ($requested !== 'completed') { return $requested; }
if ($actual === 0 || trim($text) === '') { return 'failed'; }
return $actual < $expected ? 'partial' : 'completed';
}
}