128 lines
12 KiB
PHP
128 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/** Synthetic controller → immutable task → split-ASR → extraction → review on disposable MySQL only. */
|
|
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\FollowupAudioPipeline as Pipeline;
|
|
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
|
|
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
|
|
use think\facade\Db;
|
|
|
|
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23319) { throw new RuntimeException('Dedicated disposable MySQL 23319 required'); }
|
|
$f = followupAudioTestDatabase(['asr_stereo_chunk_seconds' => 2]);
|
|
$checks = 0; $active = 0; $calls = ['asr' => 0, 'extraction' => 0]; $mode = 'success'; $expectedRoles = 'left_service';
|
|
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
|
|
$request = static function (string $action, array $body): array {
|
|
$body = json_decode(json_encode($body, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
|
|
$request = (new app\Request())->withServer(['REQUEST_METHOD' => 'POST'])->withPost($body);
|
|
$controller = (new ReflectionClass(FollowupAudioController::class))->newInstanceWithoutConstructor();
|
|
foreach (['request' => $request, 'adminId' => 1, 'adminInfo' => []] as $key => $value) {
|
|
$p = new ReflectionProperty($controller, $key); $p->setAccessible(true); $p->setValue($controller, $value);
|
|
}
|
|
return $controller->$action()->getData();
|
|
};
|
|
$bodyFor = static function (int $channels = 2) use ($f): array {
|
|
static $serial = 0; $serial++;
|
|
$frame = pack('v', 1000 + $serial) . ($channels === 2 ? pack('v', 2000 + $serial) : '');
|
|
$data = str_repeat($frame, 16000 * 3); $path = $f['private'] . '/dialogue-' . $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, 'dialogue-synthetic.wav');
|
|
return ['diagnosis_id' => 1, 'upload_id' => $upload['id'], 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen'];
|
|
};
|
|
$transport = static function (array $spec) use (&$active, &$calls, &$mode, &$expectedRoles, $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) === 0 ? '今天早晨的空腹血糖是多少?有没有头晕?' : '六点一,没有头晕。';
|
|
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);
|
|
try { Store::checkpoint($active, $task['lease_token'], ['upstream_ids_json' => ['channel_roles' => Store::taskChannelRoles($task) === 'left_service' ? 'right_service' : 'left_service']]);
|
|
throw new RuntimeException('role checkpoint mutation accepted');
|
|
} catch (DomainException $error) { $expect($error->getMessage() === 'FOLLOWUP_AUDIO_CHECKPOINT_INVALID', 'checkpoint cannot replace immutable role'); }
|
|
$expect($context['channel_roles'] === $expectedRoles, 'extraction receives immutable role BEFORE model call');
|
|
$expect(str_contains($prompt, '今天早晨的空腹血糖是多少?有没有头晕?') && ($expectedRoles === 'unconfirmed' || str_contains($prompt, '六点一,没有头晕。')), 'full question and answer passed, not patient-only input');
|
|
$expect(str_contains($prompt, '必须同时包含问题和回答') && str_contains($prompt, '问题本身不是患者事实') && str_contains($prompt, '不是逐字时间戳'), 'dialogue evidence and coarse timing rules present');
|
|
if ($mode === 'reject_once') { $mode = 'success'; return ['http_code' => 429, 'errno' => 0, 'body' => '{}']; }
|
|
$citations = Prompt::citations(Checkpoint::segments($state));
|
|
$answer = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成测试摘要', 'uncertainties' => [], 'items' => []];
|
|
if ($expectedRoles !== 'unconfirmed') {
|
|
$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']]];
|
|
}
|
|
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();
|
|
try {
|
|
$body = $bodyFor();
|
|
foreach ([true, false, 0, 1, null, [], ['left_service'], ' left_service', 'left_service ', 'LEFT_SERVICE', 'patient'] as $invalid) {
|
|
$response = $request('create', $body + ['channel_roles' => $invalid]);
|
|
$expect($response['code'] === 0 && $response['msg'] === 'FOLLOWUP_AUDIO_CHANNEL_ROLES_INVALID', 'strict JSON role rejected: ' . json_encode($invalid));
|
|
}
|
|
$expect((int) Db::name('followup_audio_task')->count() === 0, 'invalid creates make no task');
|
|
$created = $request('create', $body + ['channel_roles' => 'left_service']);
|
|
$expect($created['code'] === 1, 'create accepts exact confirmed role'); $active = $created['data']['id'];
|
|
$expect(Store::taskChannelRoles(Store::task($active)) === 'left_service', 'role durably bound before worker');
|
|
$same = $request('create', $body + ['channel_roles' => 'left_service']);
|
|
$expect($same['code'] === 1 && $same['data']['reused'], 'same-role dedup retained');
|
|
foreach (['right_service', 'unconfirmed'] as $different) {
|
|
$bad = $request('create', $body + ['channel_roles' => $different]);
|
|
$expect($bad['code'] === 0 && str_contains($bad['msg'], 'FOLLOWUP_AUDIO_CHANNEL_ROLES_CONFLICT'), 'different-role reuse is explicit error');
|
|
}
|
|
$mode = 'reject_once'; $run(); $failed = Store::detail($active);
|
|
$expect($failed['status'] === 'failed' && $failed['can_retry'], 'known extraction rejection may retry');
|
|
$before = $calls; $snapshot = Store::task($active)['upstream_ids_json'];
|
|
$response = $request('retry', ['id' => $active]); $expect($response['code'] === 1, 'controller retry accepted');
|
|
$expect(Store::task($active)['upstream_ids_json'] === $snapshot, 'retry retains role snapshot');
|
|
$run(); $detail = Store::detail($active);
|
|
$expect($detail['status'] === 'review' && $detail['channel_roles'] === 'left_service', 'confirmed stereo initializes review role');
|
|
$expect($calls['asr'] === $before['asr'] && $calls['extraction'] === $before['extraction'] + 1, 'retry uses completed split-channel ASR without resend');
|
|
$expect(count($detail['items']) === 1 && array_column($detail['items'][0]['evidence'], 'channel') === [0, 1], 'question AND answer evidence retained');
|
|
$expect($detail['items'][0]['needs_review'] && !$detail['items'][0]['selected'], 'role confirmation never auto-selects clinical fact');
|
|
$boundTask = Store::task($active); $checkpoint = Store::open($active, 'pipeline', $boundTask['pipeline_cipher']);
|
|
$expect($checkpoint['extraction_channel_roles'] === 'left_service' && $checkpoint['extraction']['state'] === 'complete', 'completed extraction checkpoint binds original role');
|
|
$sourcePath = Upload::path(Upload::session($boundTask['upload_id'])); $before = $calls;
|
|
foreach (['right_service', null] as $replacement) {
|
|
$altered = $boundTask; $ids = json_decode($altered['upstream_ids_json'], true);
|
|
if ($replacement === null) { unset($ids['channel_roles']); } else { $ids['channel_roles'] = $replacement; }
|
|
$altered['upstream_ids_json'] = json_encode($ids);
|
|
try {
|
|
(new Pipeline(config('followup_audio'), Provider::resolve('qwen'), $transport))->run($sourcePath, $altered, static fn (): bool => true, $checkpoint);
|
|
throw new RuntimeException('changed role reused completed checkpoint');
|
|
} catch (\app\common\service\followupaudio\FollowupAudioException $error) {
|
|
$expect($error->errorCode === 'PIPELINE_CHECKPOINT_INVALID', 'changed/missing role blocks completed-result reuse');
|
|
}
|
|
}
|
|
$expect($calls === $before && Store::task($active) === $boundTask, 'role-mismatch recovery calls no model and mutates no task');
|
|
$review = Store::open($active, 'review', $boundTask['review_cipher']);
|
|
$expect($review['channel_roles_annotation']['source'] === 'task_create' && $review['channel_roles_annotation']['actor_id'] === 1, 'initial human role retains origin annotation');
|
|
$before = $calls; Store::saveDraft($active, $detail['version'], [], 1, 'right_service');
|
|
$expect(Store::taskChannelRoles(Store::task($active)) === 'left_service' && Store::detail($active)['channel_roles'] === 'right_service' && $calls === $before, 'review annotation does not rewrite extraction binding or rerun');
|
|
|
|
$legacyBody = $bodyFor(); $legacy = $request('create', $legacyBody); $expect($legacy['code'] === 1, 'omitted role backward compatible'); $active = $legacy['data']['id'];
|
|
$ids = json_decode(Store::task($active)['upstream_ids_json'], true); unset($ids['channel_roles']); $ids['preview_only'] = true;
|
|
Db::name('followup_audio_task')->where('id', $active)->update(['upstream_ids_json' => json_encode($ids)]);
|
|
$expectedRoles = 'unconfirmed'; $run(); $beforeTask = Store::task($active);
|
|
$expect(Store::detail($active)['channel_roles'] === 'unconfirmed', 'historical snapshot without role stays unconfirmed');
|
|
$bad = $request('create', $legacyBody + ['channel_roles' => 'left_service']);
|
|
$expect($bad['code'] === 0 && str_contains($bad['msg'], 'CHANNEL_ROLES_CONFLICT') && Store::task($active) === $beforeTask, 'old preview not silently reclassified or rerun');
|
|
|
|
$mono = $request('create', $bodyFor(1) + ['channel_roles' => 'left_service']); $expect($mono['code'] === 1, 'mono can retain requested role without applying it'); $active = $mono['data']['id'];
|
|
$run(); $detail = Store::detail($active);
|
|
$expect($detail['status'] === 'review' && $detail['channel_roles'] === 'unconfirmed' && Store::taskChannelRoles(Store::task($active)) === 'left_service', 'mono extraction/review never falsely assigns channels');
|
|
|
|
$right = $request('create', $bodyFor() + ['channel_roles' => 'right_service']); $expect($right['code'] === 1, 'reverse role accepted'); $active = $right['data']['id']; $expectedRoles = 'right_service'; $run();
|
|
$expect(Store::detail($active)['channel_roles'] === 'right_service', 'reverse role passed to extraction and review');
|
|
$expect((int) Db::name('followup_audio_audit')->count() === 0 && (int) Db::name('tcm_blood_record')->count() === 0, 'no clinical apply or records created');
|
|
echo 'FOLLOWUP_AUDIO_DIALOGUE_ROLES assertions=' . $checks . ' PASS real_controller_json=1 immutable_task_role=1 before_extraction=1 full_dialogue=1 qa_evidence=1 split_asr_resume=1 legacy_unchanged=1 mono_unconfirmed=1 no_clinical_writes=1' . PHP_EOL;
|
|
} finally { followupAudioTestDatabaseCleanup($f); }
|