328 lines
29 KiB
PHP
328 lines
29 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
/** Synthetic only: real request/controller, durable DB revisions, mock network and actual media pipeline. */
|
|
require __DIR__ . '/fixtures/followup_audio/database.php';
|
|
use app\adminapi\controller\tcm\FollowupAudioController;
|
|
use app\common\service\followupaudio\FollowupAudioStore as Store;
|
|
use app\common\service\followupaudio\FollowupAudioWorker as Worker;
|
|
use app\common\service\followupaudio\FollowupAudioDify as Dify;
|
|
use app\common\service\followupaudio\FollowupAudioPipelineCheckpoint as Checkpoint;
|
|
use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
|
|
use app\common\service\followupaudio\FollowupAudioReextract as Reextract;
|
|
use app\common\service\followupaudio\FollowupAudioPipeline as Pipeline;
|
|
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
|
|
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
|
|
use think\Container;
|
|
use think\facade\Db;
|
|
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23319) { throw new RuntimeException('Dedicated disposable MySQL 23319 required'); }
|
|
function reextractRequest(string $action, array $body, int $actor = 1, string $method = 'POST'): array
|
|
{
|
|
// Preserve the same JSON scalar types received by app Request; never call Logic directly here.
|
|
$body = json_decode(json_encode($body, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
|
|
$request = (new app\Request())->withServer(['REQUEST_METHOD' => $method]);
|
|
$request = $method === 'GET' ? $request->withGet($body) : $request->withPost($body);
|
|
$controller = (new ReflectionClass(FollowupAudioController::class))->newInstanceWithoutConstructor();
|
|
// The endpoint itself reloads the current actor; injected info must not grant root rights.
|
|
foreach (['request' => $request, 'adminId' => $actor, 'adminInfo' => ['root' => 1]] as $key => $value) {
|
|
$property = new ReflectionProperty($controller, $key);
|
|
$property->setAccessible(true); $property->setValue($controller, $value);
|
|
}
|
|
return $controller->$action()->getData();
|
|
}
|
|
|
|
if (($argv[1] ?? '') === '--request') {
|
|
$path = $argv[2] ?? '';
|
|
if (!preg_match('#^/private/tmp/fa_pipeline_[a-f0-9]{12}/controller-child-[a-f0-9]+\.json$#D', $path)) {
|
|
throw new RuntimeException('Disposable child input required');
|
|
}
|
|
$input = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
|
|
if ($input['connection']['hostname'] !== '127.0.0.1' || (int) $input['connection']['hostport'] !== 23319
|
|
|| !preg_match('/^fa_pipeline_[a-f0-9]{12}$/D', $input['connection']['database'])) {
|
|
throw new RuntimeException('Disposable child connection required');
|
|
}
|
|
new think\App(); // Deliberately not initialized: no application .env/config discovery.
|
|
$manager = new think\DbManager();
|
|
$manager->setConfig(['default' => 'mysql', 'connections' => ['mysql' => $input['connection']]]);
|
|
Container::getInstance()->instance('think\DbManager', $manager);
|
|
$config = new think\Config(); $config->set($input['settings'], 'followup_audio');
|
|
Container::getInstance()->instance('config', $config);
|
|
file_put_contents($path . '.ready', 'ready');
|
|
$deadline = microtime(true) + 15;
|
|
while (!is_file($input['barrier'])) {
|
|
if (microtime(true) > $deadline) { throw new RuntimeException('Child barrier timeout'); }
|
|
usleep(10000);
|
|
}
|
|
$deadline = microtime(true) + 15;
|
|
while (!empty($input['wait_for']) && !is_file($input['wait_for'])) {
|
|
if (microtime(true) > $deadline) { throw new RuntimeException('Ordered child timeout'); } usleep(10000);
|
|
}
|
|
$response = reextractRequest($input['action'], $input['body']);
|
|
if (!empty($input['done'])) { file_put_contents($input['done'], 'done'); }
|
|
echo json_encode($response, JSON_THROW_ON_ERROR) . PHP_EOL;
|
|
exit(0);
|
|
}
|
|
|
|
$f = followupAudioTestDatabase(['asr_stereo_chunk_seconds' => 2, 'preview_only' => true, 'test_diagnosis_ids' => [1], 'test_admin_ids' => [1,2,3]]);
|
|
$providers = config('followup_audio.providers'); $providers['qwen']['preview_verified_fingerprint'] = Provider::resolve('qwen')['fingerprint']; $f['config']->set(['providers' => $providers], 'followup_audio');
|
|
$checks = 0; $active = 0; $calls = ['asr' => 0, 'extraction' => 0]; $mode = 'success';
|
|
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
|
|
$success = static function (array $r) use ($expect): array { $expect($r['code'] === 1, json_encode($r, JSON_UNESCAPED_UNICODE)); return $r['data']; };
|
|
$reject = static function (array $r, string $message = '') use ($expect): void { $expect($r['code'] === 0 && ($message === '' || str_contains($r['msg'], $message)), json_encode($r, JSON_UNESCAPED_UNICODE)); };
|
|
$transport = static function (array $spec) use (&$active, &$calls, &$mode, $expect): array {
|
|
$calls[$spec['stage']]++; $task = Store::task($active); $state = Store::open($active, 'pipeline', $task['pipeline_cipher']);
|
|
if ($spec['stage'] === 'asr') {
|
|
$intent = array_values(array_filter($state['chunks'], static fn ($c): bool => $c['state'] === 'intent'))[0];
|
|
$text = ($intent['channel'] ?? 0) === 1 ? '您好,我是浙养堂医院的医疗助理。今天早晨空腹血糖是多少?有没有头晕?' : '今天早晨空腹血糖六点一,没有头晕。';
|
|
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['text' => $text], JSON_UNESCAPED_UNICODE)];
|
|
}
|
|
$prompt = $spec['json']['messages'][0]['content'];
|
|
$context = json_decode(explode("\nSOURCE_CITATIONS=", explode("\nSERVER_CONTEXT=", $prompt, 2)[1], 2)[0], true, 512, JSON_THROW_ON_ERROR);
|
|
$expect($context['channel_roles'] === ($state['source_channels'] === 2 ? Reextract::roles($task) : 'unconfirmed'), 'model receives effective explicitly confirmed role');
|
|
$expect(str_contains($prompt, '每个 values 字段 key 最多出现一次') && str_contains($prompt, '相互矛盾的值不得自动合并') && str_contains($prompt, '不得合并血糖、血压、饮食、运动等不同日期或时段的独立事件'), 'unique diagnosis keys, compatible texts only, preserve separate daily events');
|
|
$expect(str_contains($prompt, '局部疑点不否定整段对话') && str_contains($prompt, '不再猜测交换声道'), 'retain strict roles without global uncertainty discard');
|
|
if ($mode === 'reject_once') { $mode = 'success'; return ['http_code' => 429, 'errno' => 0, 'body' => '{}']; }
|
|
if ($mode === 'unknown') { return ['http_code' => 0, 'errno' => 28, 'body' => '']; }
|
|
$citations = Prompt::citations(Checkpoint::segments($state));
|
|
$answer = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成回访', 'uncertainties' => [], 'items' => []];
|
|
if ($context['channel_roles'] === 'right_service') {
|
|
$answer['items'][] = ['kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.1], 'record_date' => '2026-09-29', 'record_time' => null,
|
|
'date_text' => '今天', 'time_text' => '早晨', 'time_period' => '早晨', 'time_estimated' => true, 'needs_review' => true,
|
|
'evidence_ids' => [$citations[0]['id'], $citations[1]['id']]];
|
|
} else { $answer['uncertainties'][] = '角色约定与医疗助理自我介绍冲突'; }
|
|
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['choices' => [['finish_reason' => 'stop', 'message' => ['content' => json_encode($answer, JSON_UNESCAPED_UNICODE)]]]])];
|
|
};
|
|
$run = static fn (): bool => (new Worker(new Dify($transport)))->runOnce();
|
|
$make = static function (string $roles = 'left_service', int $channels = 2) use ($f, $success, $run, &$active): array {
|
|
static $serial = 0; $serial++;
|
|
$frame = pack('v', 1000 + $serial) . ($channels === 2 ? pack('v', 2000 + $serial) : '');
|
|
$data = str_repeat($frame, 16000 * 2) . str_repeat("\0", 16000 * $channels * 2);
|
|
$path = $f['private'] . '/reextract-' . $serial . '.wav';
|
|
file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($data)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, $channels, 16000, 32000 * $channels, 2 * $channels, 16) . 'data' . pack('V', strlen($data)) . $data); chmod($path, 0600);
|
|
$upload = followupAudioTestUpload($f, $path, 'synthetic.wav');
|
|
$created = $success(reextractRequest('create', ['diagnosis_id' => 1, 'upload_id' => $upload['id'], 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen', 'channel_roles' => $roles]));
|
|
$active = $created['id']; $run();
|
|
return $success(reextractRequest('detail', ['id' => $active], 1, 'GET'));
|
|
};
|
|
$body = static fn (array $d, string $r = 'right_service'): array => ['id' => $d['id'], 'version' => $d['version'], 'channel_roles' => $r];
|
|
$clinical = static function (): string {
|
|
$rows = [];
|
|
foreach (['tcm_diagnosis','tcm_blood_record','patient_diet_record','patient_exercise_record','tracking_note','followup_audio_audit'] as $table) { $rows[$table] = Db::name($table)->order('id')->select()->toArray(); }
|
|
return hash('sha256', json_encode($rows));
|
|
};
|
|
$parallel = static function (array $requests, ?int $first = null) use ($f, $expect): array {
|
|
$jobs = []; $barrier = $f['private'] . '/barrier-' . bin2hex(random_bytes(6));
|
|
try {
|
|
foreach ($requests as $index => [$action, $payload]) {
|
|
$path = $f['private'] . '/controller-child-' . bin2hex(random_bytes(6)) . '.json';
|
|
file_put_contents($path, json_encode(['connection' => Db::connect()->getConfig(), 'settings' => config('followup_audio'),
|
|
'action' => $action, 'body' => $payload, 'barrier' => $barrier,
|
|
'wait_for' => $first !== null && $index !== $first ? $barrier . '.first_done' : '',
|
|
'done' => $index === $first ? $barrier . '.first_done' : ''], JSON_THROW_ON_ERROR)); chmod($path, 0600);
|
|
$pipes = []; $process = proc_open([PHP_BINARY, __FILE__, '--request', $path], [0 => ['pipe','r'],1 => ['pipe','w'],2 => ['pipe','w']], $pipes);
|
|
if (!is_resource($process)) { throw new RuntimeException('Child launch failed'); }
|
|
fclose($pipes[0]); $jobs[] = [$process,$pipes,$path];
|
|
}
|
|
$deadline = microtime(true) + 15;
|
|
while (count(array_filter($jobs, static fn ($j): bool => is_file($j[2] . '.ready'))) !== count($jobs)) {
|
|
if (microtime(true) > $deadline) { throw new RuntimeException('Child startup timeout'); } usleep(10000);
|
|
}
|
|
file_put_contents($barrier, 'go');
|
|
} finally {
|
|
if (!is_file($barrier)) { file_put_contents($barrier, 'go'); }
|
|
$results = [];
|
|
foreach ($jobs as [$process,$pipes]) {
|
|
$stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]);
|
|
fclose($pipes[1]); fclose($pipes[2]); $exit = proc_close($process);
|
|
$expect($exit === 0 && $stderr === '', 'child clean: ' . $stderr);
|
|
$results[] = json_decode(trim($stdout), true, 512, JSON_THROW_ON_ERROR);
|
|
}
|
|
}
|
|
return $results;
|
|
};
|
|
try {
|
|
$d = $make(); $snapshot = $clinical(); $id = $d['id']; $before = Store::task($id); $initialCalls = $calls;
|
|
$expect($d['status'] === 'review' && $d['items'] === [] && $d['can_reextract'], 'zero-item completed review may correct role');
|
|
$expect($d['role_check']['conflict'] && $d['role_check']['suggested_roles'] === 'right_service'
|
|
&& $d['role_check']['evidence'][0]['channel'] === 1, 'explicit intro is advisory evidence');
|
|
$expect($calls === ['asr' => 2, 'extraction' => 1], 'original run has two actual ASR calls and two local silence chunks');
|
|
foreach ([null, true, 1, [], ' right_service', 'right_service ', 'unconfirmed'] as $bad) {
|
|
$reject(reextractRequest('reextract', ['id' => $id, 'version' => $d['version'], 'channel_roles' => $bad]), 'CHANNEL_ROLES_INVALID');
|
|
}
|
|
$reject(reextractRequest('reextract', $body($d), 1, 'GET'), '请求方式错误');
|
|
$reject(reextractRequest('reextract', $body($d) + ['force' => true]), '不支持的字段');
|
|
$reject(reextractRequest('reextract', $body($d, 'left_service')), 'ROLE_UNCHANGED');
|
|
$reject(reextractRequest('reextract', $body($d), 2), 'ACTOR_DENIED');
|
|
$reject(reextractRequest('reextract', $body($d), 3), '无权操作');
|
|
$other = $success(reextractRequest('detail', ['id' => $id], 2, 'GET'));
|
|
$expect(!$other['can_reextract'] && $other['reextract_block_reason'] !== '', 'nonowner capability false despite editable patient');
|
|
Db::name('admin')->where('id', 1)->update(['disable' => 1]);
|
|
$reject(reextractRequest('reextract', $body($d)), '账号已停用');
|
|
Db::name('admin')->where('id', 1)->update(['disable' => 0]);
|
|
Db::name('tcm_diagnosis')->where('id', 1)->update(['patient_id' => 999]);
|
|
$reject(reextractRequest('reextract', $body($d)), '患者归属已变化');
|
|
Db::name('tcm_diagnosis')->where('id', 1)->update(['patient_id' => 101]);
|
|
$expect(Store::task($id) === $before && $calls === $initialCalls, 'rejected operations leave task and network untouched');
|
|
|
|
// Review annotations cannot fix extraction or silence the underlying role warning.
|
|
$d = $success(reextractRequest('saveDraft', ['id' => $id, 'version' => $d['version'], 'items' => [], 'channel_roles' => 'right_service']));
|
|
$expect($d['channel_roles'] === 'right_service' && $d['extraction_channel_roles'] === 'left_service' && $d['role_check']['conflict'], 'annotation distinct from extraction binding');
|
|
$before = Store::task($id); $oldState = Store::open($id, 'pipeline', $before['pipeline_cipher']); $requestBody = $body($d);
|
|
$queued = $success(reextractRequest('reextract', $requestBody));
|
|
$expect($queued['status'] === 'queued' && $queued['items'] === [] && $queued['extraction_revision'] === 1
|
|
&& $queued['extraction_channel_roles'] === 'right_service' && !$queued['can_reextract'], 'explicit confirmation queues only extraction revision');
|
|
$task = Store::task($id); $state = Store::open($id, 'pipeline', $task['pipeline_cipher']);
|
|
foreach (['sha256','upload_id','upstream_ids_json','expires_at','actor_id','recorded_at','upstream_started_at'] as $key) { $expect($task[$key] === $before[$key], 'preserve ' . $key); }
|
|
$expect($state['chunks'] === $oldState['chunks'] && $state['extraction'] === ['state' => 'pending'], 'every ASR response, silence hash and request id exact');
|
|
$expect($queued['transcript'] === $d['transcript'] && $queued['transcript_segments'] === $d['transcript_segments'] && $calls === $initialCalls, 'cached transcript visible, endpoint itself no model call');
|
|
$history = Db::name('followup_audio_extraction_history')->where('task_id', $id)->find();
|
|
$expect(app\common\service\followupaudio\FollowupAudioPolicy::canonical(Store::open($id, 'history:1', $history['snapshot_cipher'])) === app\common\service\followupaudio\FollowupAudioPolicy::canonical($before) && $history['expires_at'] === $before['expires_at'], 'old encrypted extraction review pipeline metadata preserved under original retention');
|
|
$expect(!str_contains($history['snapshot_cipher'], '医疗助理'), 'history encrypted, no transcript in metadata');
|
|
$again = $success(reextractRequest('reextract', $requestBody));
|
|
$expect($again['reextract_reused'] && $again['reextract_requested_revision'] === 1 && Store::task($id) === $task, 'lost-response replay returns same revision no writes');
|
|
$reject(reextractRequest('reextract', array_replace($requestBody, ['channel_roles' => 'left_service'])), 'VERSION_CONFLICT');
|
|
$reject(reextractRequest('delete', ['id' => $id, 'version' => $task['version']]));
|
|
$reject(reextractRequest('apply', ['id' => $id, 'version' => $task['version'], 'items' => []]), 'PREVIEW_ONLY');
|
|
$reject(reextractRequest('saveDraft', ['id' => $id, 'version' => $d['version'], 'items' => []]));
|
|
|
|
// Wrong original role, override, revision or confirm actor cannot reuse the completed ASR binding.
|
|
foreach (['channel_roles','extraction_role_override','extraction_revision','extraction_source_version','extraction_confirmed_by'] as $field) {
|
|
$bad = $task;
|
|
if ($field === 'channel_roles') { $ids = json_decode($bad['upstream_ids_json'], true); $ids['channel_roles'] = 'right_service'; $bad['upstream_ids_json'] = json_encode($ids); }
|
|
elseif ($field === 'extraction_role_override') { $bad[$field] = 'left_service'; } else { $bad[$field] = (int) $bad[$field] + 1; }
|
|
try { Checkpoint::validate($state, $bad); throw new RuntimeException('altered binding accepted'); }
|
|
catch (\app\common\service\followupaudio\FollowupAudioException $error) { $expect($error->errorCode === 'PIPELINE_CHECKPOINT_INVALID', 'checkpoint rejects ' . $field); }
|
|
}
|
|
$active = $id; $run(); $done = $success(reextractRequest('detail', ['id' => $id], 1, 'GET'));
|
|
$expect($calls['asr'] === $initialCalls['asr'] && $calls['extraction'] === $initialCalls['extraction'] + 1, 'ACTUAL NETWORK COUNTS: reextract ASR=0 extraction=1');
|
|
$expect(count($done['items']) === 1 && $done['items'][0]['needs_review'] && !$done['items'][0]['selected'], 'corrected role yields grounded review candidate never auto adopted');
|
|
$expect($done['channel_roles'] === 'right_service' && !$done['role_check']['conflict'] && $done['preview_only'], 'corrected review role, preview remains permanent');
|
|
$annotation = Store::open($id, 'review', Store::task($id)['review_cipher'])['channel_roles_annotation'];
|
|
$expect($annotation['source'] === 'reextract' && $annotation['extraction_revision'] === 1, 'human revision provenance retained');
|
|
$again = $success(reextractRequest('reextract', $requestBody));
|
|
$expect($again['reextract_requested_revision'] === 1 && $again['status'] === 'review' && (int) Db::name('followup_audio_extraction_history')->where('task_id', $id)->count() === 1, 'completed replay no second revision');
|
|
$expect($clinical() === $snapshot, 'all clinical tables and audit unchanged');
|
|
|
|
// Known rejection resumes only extraction; unknown transport outcome is never resent.
|
|
$retryD = $make(); $active = $retryD['id']; $counts = $calls; $q = $success(reextractRequest('reextract', $body($retryD))); $mode = 'reject_once'; $run();
|
|
$failed = Store::detail($active); $expect($failed['status'] === 'failed' && $failed['can_retry'], 'known rejection explicitly retryable');
|
|
$success(reextractRequest('retry', ['id' => $active])); $run();
|
|
$expect($calls['asr'] === $counts['asr'] && $calls['extraction'] === $counts['extraction'] + 2 && Store::detail($active)['status'] === 'review', 'retry reuses every ASR chunk');
|
|
$unknownD = $make(); $active = $unknownD['id']; $success(reextractRequest('reextract', $body($unknownD))); $mode = 'unknown'; $run(); $mode = 'success'; $counts = $calls;
|
|
$unknown = Store::detail($active); $expect($unknown['status'] === 'needs_reconciliation' && !$unknown['can_retry'], 'unknown upstream result requires reconciliation');
|
|
$reject(reextractRequest('retry', ['id' => $active]), 'RETRY_NOT_SAFE');
|
|
$reject(reextractRequest('reextract', $body($unknown)));
|
|
$expect(!$run() && $calls === $counts, 'no automatic resend or role reset after unknown');
|
|
|
|
$test = $make(); $active = $test['id']; $row = Store::task($active);
|
|
foreach ([['expires_at' => time()-1], ['purged_at' => time()], ['applied_at' => time()], ['applied_cipher' => 'occupied'], ['status' => 'running'], ['lease_until' => time()+60], ['pipeline_cipher' => null]] as $change) {
|
|
Db::name('followup_audio_task')->where('id', $active)->update($change);
|
|
$reject(reextractRequest('reextract', $body($test)));
|
|
Db::name('followup_audio_task')->where('id', $active)->update(array_intersect_key($row, $change));
|
|
}
|
|
$badState = Store::open($active, 'pipeline', $row['pipeline_cipher']); $badState['extraction'] = ['state' => 'intent', 'request_id' => 'fa-'.str_repeat('a',32)];
|
|
Db::name('followup_audio_task')->where('id', $active)->update(['pipeline_cipher' => Store::seal($active,'pipeline',$badState)]);
|
|
$reject(reextractRequest('reextract', $body($test))); Db::name('followup_audio_task')->where('id', $active)->update(['pipeline_cipher'=>$row['pipeline_cipher']]);
|
|
$providers = config('followup_audio.providers'); $changed = $providers; $changed['qwen']['extraction']['model'] = 'changed';
|
|
$f['config']->set(['providers'=>$changed], 'followup_audio'); $reject(reextractRequest('reextract', $body($test))); $f['config']->set(['providers'=>$providers], 'followup_audio');
|
|
$success(reextractRequest('delete',['id'=>$active,'version'=>$test['version']])); $reject(reextractRequest('reextract',$body($test)));
|
|
|
|
// Actual concurrent POSTs serialize on task row: duplicate revision, delete, and apply races.
|
|
$race = $make(); $responses = $parallel([['reextract',$body($race)], ['reextract',$body($race)]]);
|
|
$expect($responses[0]['code'] === 1 && $responses[1]['code'] === 1 && count(array_filter($responses,static fn($r)=>$r['data']['reextract_reused'])) === 1, 'concurrent replay exactly one new revision');
|
|
$active = $race['id']; $run();
|
|
$race = $make(); $responses = $parallel([['reextract',$body($race)], ['delete',['id'=>$race['id'],'version'=>$race['version']]]]);
|
|
$expect(count(array_filter($responses,static fn($r)=>$r['code'] === 1)) === 1, 'delete versus reextract one winner');
|
|
if (Db::name('followup_audio_task')->where('id',$race['id'])->value('status') === 'queued') { $active=$race['id']; $run(); }
|
|
$race = $make(); $responses = $parallel([['reextract',$body($race)], ['apply',['id'=>$race['id'],'version'=>$race['version'],'items'=>[]]]]);
|
|
$expect($responses[0]['code'] === 1 && $responses[1]['code'] === 0 && str_contains($responses[1]['msg'],'PREVIEW_ONLY'), 'preview apply versus reextract no clinical write');
|
|
$active=$race['id']; $run();
|
|
|
|
$mono=$make('left_service',1); $expect(!$mono['can_reextract'],'mono not eligible for channel correction');
|
|
$reject(reextractRequest('reextract',$body($mono)), 'REEXTRACT_BLOCKED');
|
|
|
|
// Legacy stereo checkpoints opt into revisions without rewriting the absent creation role.
|
|
$legacy = $make(); $active = $legacy['id']; $row = Store::task($active);
|
|
$ids = json_decode($row['upstream_ids_json'], true); unset($ids['channel_roles']);
|
|
$legacyState = Store::open($active, 'pipeline', $row['pipeline_cipher']); unset($legacyState['extraction_channel_roles']);
|
|
Db::name('followup_audio_task')->where('id', $active)->update(['upstream_ids_json'=>json_encode($ids), 'pipeline_cipher'=>Store::seal($active,'pipeline',$legacyState)]);
|
|
$legacy = $success(reextractRequest('detail',['id'=>$active],1,'GET'));
|
|
$expect($legacy['extraction_channel_roles']==='unconfirmed' && $legacy['can_reextract'], 'legacy stereo safely opt in');
|
|
$beforeCalls=$calls; $success(reextractRequest('reextract',$body($legacy))); $run();
|
|
$expect(Store::taskChannelRoles(Store::task($active))==='unconfirmed' && Store::detail($active)['channel_roles']==='right_service' && $calls['asr']===$beforeCalls['asr'], 'legacy source role retained without ASR resend');
|
|
|
|
// Fresh edit grant, uploader identity and masked field catalog are enforced on successful response.
|
|
$owned=$make(); $active=$owned['id']; Db::name('followup_audio_task')->where('id',$active)->update(['actor_id'=>2]);
|
|
Db::name('system_role_menu')->where('role_id',2)->where('menu_id',1)->delete();
|
|
$reject(reextractRequest('reextract',$body($owned),2),'无权操作');
|
|
Db::name('system_role_menu')->insert(['role_id'=>2,'menu_id'=>1]);
|
|
$masked=$success(reextractRequest('reextract',$body($owned),2));
|
|
$phone=array_values(array_filter($masked['fields']['diagnosis'],static fn($f)=>$f['key']==='phone'))[0];
|
|
$expect(!empty($phone['readonly']) && !array_key_exists('snapshot_cipher',$masked) && !array_key_exists('upstream_ids_json',$masked), 'non-plain uploader receives protected field catalog and no history payload');
|
|
$run(); $f['config']->set(['preview_only'=>false],'followup_audio');
|
|
$permanent=Store::detail($active); $reject(reextractRequest('apply',['id'=>$active,'version'=>$permanent['version'],'items'=>[]]),'PREVIEW_ONLY');
|
|
$expect($permanent['preview_only'],'revision retains original preview marker after global switch');
|
|
$f['config']->set(['preview_only'=>true],'followup_audio');
|
|
|
|
// A corrupt review status must not hide an adoption audit.
|
|
$audited=$make(); $active=$audited['id'];
|
|
$auditId=Db::name('followup_audio_audit')->insertGetId(['task_id'=>$active,'item_id'=>'synthetic-guard','diagnosis_id'=>1,'actor_id'=>1,'kind'=>'diagnosis','table_name'=>'tcm_diagnosis','record_id'=>1,'action'=>'guard','source_cipher'=>'synthetic','before_cipher'=>'synthetic','after_cipher'=>'synthetic','created_at'=>time()]);
|
|
$reject(reextractRequest('reextract',$body($audited)), 'REEXTRACT_BLOCKED');
|
|
Db::name('followup_audio_audit')->where('id',$auditId)->delete();
|
|
|
|
// Authorized NORMAL-mode Apply competes with re-extraction using an actually reviewed selected item.
|
|
// Both deterministic orderings and an unscheduled concurrent race run in separate PHP processes.
|
|
$normalRaceOutcomes = [];
|
|
$f['config']->set(['preview_only'=>false], 'followup_audio');
|
|
foreach (['apply_first'=>0, 'reextract_first'=>1, 'simultaneous'=>null] as $label=>$first) {
|
|
$normal = $make('right_service'); $normalId = $normal['id'];
|
|
$expect(!$normal['preview_only'] && count($normal['items'])===1, 'normal race has a real candidate, not a preview denial');
|
|
$reviewed = $normal['items']; $reviewed[0]['selected']=true; $reviewed[0]['needs_review']=false;
|
|
$normal = $success(reextractRequest('saveDraft',['id'=>$normalId,'version'=>$normal['version'],'items'=>$reviewed]));
|
|
$expect($normal['items'][0]['selected'] && !$normal['items'][0]['needs_review'] && $normal['channel_roles']==='right_service', 'normal race starts with durably reviewed item and roles');
|
|
$beforeClinical=$clinical(); $beforeNetwork=$calls;
|
|
$responses=$parallel([
|
|
['apply',['id'=>$normalId,'version'=>$normal['version'],'items'=>$normal['items']]],
|
|
['reextract',$body($normal,'left_service')],
|
|
],$first);
|
|
$expect(count(array_filter($responses,static fn($r)=>$r['code']===1))===1, 'normal '.$label.' exactly one winner');
|
|
$expect(!str_contains(json_encode($responses),'PREVIEW_ONLY'), 'normal race traverses actual apply transaction');
|
|
$row=Store::task($normalId);
|
|
$audits=Db::name('followup_audio_audit')->where('task_id',$normalId)->select()->toArray();
|
|
$histories=(int)Db::name('followup_audio_extraction_history')->where('task_id',$normalId)->count();
|
|
if ($responses[0]['code']===1) {
|
|
$expect($first!==1 && $row['status']==='applied' && count($audits)===1 && $histories===0, 'apply winner has exactly one audit and no extraction history');
|
|
$expect($audits[0]['table_name']==='tcm_blood_record' && (int)Db::name('tcm_blood_record')->where('id',$audits[0]['record_id'])->count()===1, 'apply winner persisted the reviewed clinical row');
|
|
$expect((int)$row['extraction_revision']===0 && $row['extraction_role_override']==='', 'apply winner retained original extraction binding');
|
|
$normalRaceOutcomes[$label]='apply:audit1,history0,clinical1';
|
|
// Remove only this positive-test synthetic row/audit, restoring the common fixture snapshot.
|
|
Db::name('tcm_blood_record')->where('id',$audits[0]['record_id'])->delete();
|
|
Db::name('followup_audio_audit')->where('task_id',$normalId)->delete();
|
|
} else {
|
|
$expect($first!==0 && $row['status']==='queued' && $audits===[] && $histories===1, 'reextract winner queues one revision without audit');
|
|
$expect($clinical()===$beforeClinical && (int)$row['extraction_revision']===1, 'reextract winner has no clinical write');
|
|
$normalRaceOutcomes[$label]='reextract:audit0,history1,clinical0';
|
|
$active=$normalId; $run();
|
|
}
|
|
$expect($clinical()===$beforeClinical, 'isolated normal race restores clinical fixture');
|
|
$expect($calls['asr']===$beforeNetwork['asr'], 'normal race never resends ASR');
|
|
}
|
|
$f['config']->set(['preview_only'=>true], 'followup_audio');
|
|
echo 'FOLLOWUP_AUDIO_NORMAL_APPLY_REEXTRACT '.json_encode($normalRaceOutcomes,JSON_UNESCAPED_SLASHES).PHP_EOL;
|
|
|
|
// Retention erases encrypted historical transcript copies as well as current review.
|
|
Db::name('followup_audio_task')->where('id',$id)->update(['expires_at'=>time()-1]);
|
|
Db::name('followup_audio_upload')->where('id',$before['upload_id'])->update(['expires_at'=>time()-1]);
|
|
Db::name('followup_audio_extraction_history')->where('task_id',$id)->update(['expires_at'=>time()-1]);
|
|
$counts=Store::cleanupExpired(); $purged=Db::name('followup_audio_extraction_history')->where('task_id',$id)->find();
|
|
$expect($counts['errors']===0 && $purged['snapshot_cipher']===null && (int)$purged['purged_at']>0, 'history payload purged at original task expiry');
|
|
$expired=Store::detail($id); $expect($expired['transcript']==='' && $expired['items']===[] && $expired['role_check']['evidence']===[], 'expired reads reveal no historical plaintext');
|
|
$reject(reextractRequest('reextract',$requestBody),'EXPIRED');
|
|
$expect($clinical()===$snapshot,'all test paths preserve clinical rows');
|
|
foreach (['他是医生','你是医生吗?','患者说我是医生','我不是医生','我是医生的家属','“我是医生”'] as $text) {
|
|
$check=Reextract::roleCheck([['channel'=>1,'start_ms'=>0,'end_ms'=>1000,'text'=>$text]],'left_service');
|
|
$expect(!$check['conflict'],'no automatic guess from mention: '.$text);
|
|
}
|
|
echo 'FOLLOWUP_AUDIO_REEXTRACT assertions='.$checks.' PASS actual_reextract_asr_calls=0 extraction_calls=1 encrypted_history=1 role_binding=1 replay=1 concurrent_posts=1 retention=1 reextract_no_clinical_writes=1 normal_apply_race=1'.PHP_EOL;
|
|
} finally { followupAudioTestDatabaseCleanup($f); }
|