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); }