更新
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Execute the production recovery and realtime finish logic against in-memory IO.
|
||||
namespace recovery_test {
|
||||
final class Record extends \ArrayObject
|
||||
{
|
||||
public function save(array $values): void
|
||||
{
|
||||
if (!\think\facade\Db::$transaction) { throw new \RuntimeException('Write outside transaction'); }
|
||||
if (\think\facade\Db::$failSave) { throw new \RuntimeException('Synthetic database failure'); }
|
||||
foreach ($values as $key => $value) { $this[$key] = $value; }
|
||||
}
|
||||
}
|
||||
|
||||
final class RecordQuery
|
||||
{
|
||||
private array $filters = [];
|
||||
private bool $locked = false;
|
||||
public function where(string $key, $value): self { $this->filters[$key] = $value; return $this; }
|
||||
public function lock(bool $locked): self { $this->locked = $locked; return $this; }
|
||||
public function find(): ?Record
|
||||
{
|
||||
if (!$this->locked || !\think\facade\Db::$transaction) { throw new \RuntimeException('Missing transaction row lock'); }
|
||||
foreach (\app\common\model\tcm\CallRecord::$rows as $row) {
|
||||
foreach ($this->filters as $key => $value) { if (($row[$key] ?? null) !== $value) { continue 2; } }
|
||||
return $row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final class SegmentQuery
|
||||
{
|
||||
private array $filters = [];
|
||||
public function where(string $key, $value): self { $this->filters[$key] = $value; return $this; }
|
||||
public function order(string $order): self { return $this; }
|
||||
public function select(): self { return $this; }
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_values(array_filter(\think\facade\Db::$segments, function (array $row): bool {
|
||||
foreach ($this->filters as $key => $value) { if ($row[$key] !== $value) { return false; } }
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
public function insertAll(array $rows): void
|
||||
{
|
||||
if (!\think\facade\Db::$transaction) { throw new \RuntimeException('Segment write outside transaction'); }
|
||||
foreach ($rows as $row) {
|
||||
foreach (\think\facade\Db::$segments as $existing) {
|
||||
if ($row['call_record_id'] === $existing['call_record_id']
|
||||
&& $row['transcription_session_id'] === $existing['transcription_session_id']
|
||||
&& $row['segment_id'] === $existing['segment_id']) { throw new \RuntimeException('Duplicate segment'); }
|
||||
}
|
||||
\think\facade\Db::$segments[] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\common\model\tcm {
|
||||
final class CallRecord
|
||||
{
|
||||
public static array $rows = [];
|
||||
public static function where(string $key, $value): \recovery_test\RecordQuery
|
||||
{
|
||||
return (new \recovery_test\RecordQuery())->where($key, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace think\facade {
|
||||
final class Db
|
||||
{
|
||||
public static bool $transaction = false;
|
||||
public static bool $failSave = false;
|
||||
public static array $segments = [];
|
||||
public static function transaction(callable $callback)
|
||||
{
|
||||
$rows = array_map(static fn ($row) => clone $row, \app\common\model\tcm\CallRecord::$rows);
|
||||
$segments = self::$segments;
|
||||
self::$transaction = true;
|
||||
try { return $callback(); }
|
||||
catch (\Throwable $e) {
|
||||
\app\common\model\tcm\CallRecord::$rows = $rows;
|
||||
self::$segments = $segments;
|
||||
throw $e;
|
||||
} finally { self::$transaction = false; }
|
||||
}
|
||||
public static function name(string $name): \recovery_test\SegmentQuery
|
||||
{
|
||||
if ($name !== 'tcm_call_transcript_segment') { throw new \RuntimeException('Unexpected table'); }
|
||||
return new \recovery_test\SegmentQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic as Logic;
|
||||
use app\common\model\tcm\CallRecord;
|
||||
use think\facade\Db;
|
||||
|
||||
$assertions = 0;
|
||||
function check(bool $condition, string $message): void
|
||||
{
|
||||
global $assertions;
|
||||
if (!$condition) { throw new RuntimeException($message); }
|
||||
$assertions++;
|
||||
}
|
||||
function fixture(array $changes = []): array
|
||||
{
|
||||
CallRecord::$rows = [new recovery_test\Record(array_replace([
|
||||
'id' => 17, 'diagnosis_id' => 23, 'caller_id' => 91, 'caller_type' => 'doctor',
|
||||
'status' => 2, 'local_audio_status' => 2, 'local_audio_urls' => '["https://audio.invalid/synthetic.webm"]',
|
||||
'transcription_session_id' => 'original-session', 'transcription_status' => 'partial',
|
||||
'transcription_segment_count' => 1, 'transcript_text' => '未知说话人:片段',
|
||||
], $changes))];
|
||||
Db::$segments = [['call_record_id' => 17, 'transcription_session_id' => 'original-session',
|
||||
'segment_id' => 'old-1', 'text' => '片段', 'speaker_role' => 'unknown', 'timestamp_ms' => 10]];
|
||||
Db::$failSave = false;
|
||||
return ['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91,
|
||||
'recovery_id' => 'audio-' . str_repeat('a', 32), 'expected_session_id' => 'original-session',
|
||||
'expected_status' => 'partial', 'audio_url' => 'https://audio.invalid/synthetic.webm',
|
||||
'transcript_text' => "完整的合成录音。\n保留结尾及否定:没有服药。"];
|
||||
}
|
||||
function rejects(array $params, string $label): void
|
||||
{
|
||||
$before = serialize([CallRecord::$rows, Db::$segments]);
|
||||
check(Logic::saveLocalAudioTranscript($params) === false, $label . ' rejects');
|
||||
check(serialize([CallRecord::$rows, Db::$segments]) === $before, $label . ' does not mutate');
|
||||
}
|
||||
|
||||
$params = fixture();
|
||||
$result = Logic::saveLocalAudioTranscript($params);
|
||||
check($result === ['call_record_id' => 17, 'transcription_status' => 'completed', 'saved' => true], 'recovery saves');
|
||||
check(CallRecord::$rows[0]['transcription_session_id'] === $params['recovery_id'], 'new session is active');
|
||||
check(CallRecord::$rows[0]['transcript_text'] === '未知说话人:' . $params['transcript_text'], 'complete text retained without claiming a speaker');
|
||||
check(count(Db::$segments) === 2 && Db::$segments[0]['text'] === '片段', 'old session retained');
|
||||
check(Db::$segments[1]['speaker_role'] === 'unknown' && Db::$segments[1]['timestamp_ms'] === 0, 'no invented attribution or timing');
|
||||
$before = serialize([CallRecord::$rows, Db::$segments]);
|
||||
check(Logic::saveLocalAudioTranscript($params) === $result, 'lost response retry is idempotent despite stale CAS');
|
||||
check(serialize([CallRecord::$rows, Db::$segments]) === $before, 'retry writes no duplicate segments');
|
||||
$late = Logic::upsertCallTranscriptSegments(['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91,
|
||||
'transcription_session_id' => 'original-session', 'segments' => [['segment_id' => 'late', 'text' => '迟到分段']]]);
|
||||
check($late === false, 'old realtime writer cannot overwrite replacement session');
|
||||
$lateFinish = Logic::finishCallTranscription(['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91,
|
||||
'transcription_session_id' => 'original-session', 'status' => 'completed', 'expected_segment_count' => 1]);
|
||||
check($lateFinish === false && serialize([CallRecord::$rows, Db::$segments]) === $before, 'late finish cannot overwrite replacement');
|
||||
|
||||
$params = fixture(['transcription_status' => 'completed']);
|
||||
$before = serialize([CallRecord::$rows, Db::$segments]);
|
||||
check(Logic::saveLocalAudioTranscript($params) === ['call_record_id' => 17, 'transcription_status' => 'completed',
|
||||
'saved' => false, 'already_complete' => true], 'another complete session wins even with stale CAS');
|
||||
check(serialize([CallRecord::$rows, Db::$segments]) === $before, 'completed transcript never overwritten');
|
||||
foreach (['', 'pending', 'running', 'partial', 'failed', 'completed'] as $status) {
|
||||
$params = fixture(['transcription_status' => $status, 'transcript_text' => '']);
|
||||
$params['expected_status'] = $status;
|
||||
check(Logic::saveLocalAudioTranscript($params)['saved'] === true, 'recover empty/nonterminal session ' . $status);
|
||||
}
|
||||
$params = fixture(['transcription_session_id' => '', 'transcription_status' => '', 'transcript_text' => '']);
|
||||
$params['expected_session_id'] = $params['expected_status'] = '';
|
||||
check(Logic::saveLocalAudioTranscript($params)['saved'] === true, 'record without prior session can recover');
|
||||
|
||||
foreach (['expected_session_id' => 'changed-session', 'expected_status' => 'running',
|
||||
'audio_url' => 'https://audio.invalid/different.webm', 'admin_id' => 92, 'diagnosis_id' => 24,
|
||||
'call_record_id' => 18, 'recovery_id' => 'invalid', 'transcript_text' => ' '] as $key => $value) {
|
||||
$params = fixture(); $params[$key] = $value; rejects($params, 'invalid ' . $key);
|
||||
}
|
||||
foreach ([['status' => 1], ['status' => 3], ['caller_type' => 'patient'], ['local_audio_status' => 0],
|
||||
['local_audio_urls' => 'invalid'], ['local_audio_urls' => '[]']] as $changes) {
|
||||
rejects(fixture($changes), 'invalid record ' . json_encode($changes));
|
||||
}
|
||||
foreach ([null, [], false, 23] as $value) {
|
||||
$params = fixture(); $params['transcript_text'] = $value; rejects($params, 'non-string text');
|
||||
}
|
||||
$params = fixture(); $params['transcript_text'] = str_repeat('字', 100001); rejects($params, 'oversize text');
|
||||
$params = fixture(); $params['transcript_text'] = str_repeat('字', 100000);
|
||||
check(Logic::saveLocalAudioTranscript($params)['saved'] === true, 'Unicode limit counts characters');
|
||||
$newSegments = array_slice(Db::$segments, 1);
|
||||
check(count($newSegments) === 25 && implode('', array_column($newSegments, 'text')) === $params['transcript_text'],
|
||||
'bounded segments preserve all 100000 characters in order');
|
||||
check(max(array_map(static fn ($row) => mb_strlen($row['text']), $newSegments)) <= 4000, 'segment schema limit respected');
|
||||
|
||||
$params = fixture(); Db::$failSave = true;
|
||||
rejects($params, 'transaction rollback after segment insertion');
|
||||
check(!str_contains(Logic::getError(), 'Synthetic'), 'internal failure detail is not exposed');
|
||||
|
||||
fixture(['transcription_status' => 'running', 'transcript_text' => '', 'transcription_segment_count' => 0]);
|
||||
Db::$segments = [];
|
||||
$finish = ['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91,
|
||||
'transcription_session_id' => 'original-session', 'status' => 'completed', 'expected_segment_count' => 0];
|
||||
check(Logic::finishCallTranscription($finish)['status'] === 'failed', 'zero-segment completion is failed');
|
||||
fixture(['transcription_status' => 'running']);
|
||||
$finish['expected_segment_count'] = 2;
|
||||
check(Logic::finishCallTranscription($finish)['status'] === 'partial', 'missing requested segments stays partial');
|
||||
fixture(['transcription_status' => 'running']);
|
||||
$finish['expected_segment_count'] = 1;
|
||||
check(Logic::finishCallTranscription($finish)['status'] === 'completed', 'nonempty complete realtime session still completes');
|
||||
|
||||
echo "Local audio transcript recovery: {$assertions} assertions passed (no DB or network)\n";
|
||||
}
|
||||
Reference in New Issue
Block a user