'asr_then_llm', 'label' => 'Synthetic preview', 'asr' => ['protocol' => 'dify', 'binding_revision' => 'test-asr-v1', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key', 'model' => 'expected-asr'], 'extraction' => ['protocol' => 'dify_chat', 'binding_revision' => 'test-chat-v1', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key', 'model' => 'expected-llm', 'response_format' => 'prompt_json']]; $f = followupAudioTestDatabase(['preview_only' => true, 'audio_verified' => false, 'verified_profiles' => [], 'test_diagnosis_ids' => '', 'providers' => ['qwen' => $provider]]); $checks = 0; $calls = ['asr' => 0, 'extraction' => 0]; $active = 0; $mode = 'success'; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; }; $reject = static function (callable $call, string $code) use ($expect): void { try { $call(); } catch (Throwable $e) { $expect(str_contains($e->getMessage(), $code) || ($e->errorCode ?? '') === $code, 'expected ' . $code . ', got ' . $e->getMessage()); return; } throw new RuntimeException('Expected ' . $code); }; $set = static function (array $fields) use ($f): void { $f['config']->set($fields, 'followup_audio'); }; $businessTables = ['zyt_tcm_diagnosis','zyt_tcm_prescription_order','zyt_tcm_blood_record','zyt_patient_diet_record','zyt_patient_exercise_record','zyt_tracking_note','zyt_followup_audio_audit']; $snapshot = static function () use ($f, $businessTables): string { $rows = []; foreach ($businessTables as $table) { $rows[$table] = $f['pdo']->query('SELECT * FROM `' . $table . '` ORDER BY id')->fetchAll(PDO::FETCH_ASSOC); } return hash('sha256', json_encode($rows)); }; $beforeBusiness = $snapshot(); $connection = Db::connect()->getConfig(); $connection['trigger_sql'] = true; Db::setConfig(['default' => 'mysql', 'connections' => ['mysql' => $connection]]); Db::connect('mysql', true); $businessWrites = 0; $featureWrites = 0; $sqlEvents = 0; Db::listen(static function (string $sql) use (&$businessWrites, &$featureWrites, &$sqlEvents, $businessTables): void { $sqlEvents++; if (preg_match('/\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+`?(zyt_[a-z0-9_]+)/i', $sql, $match)) { if (in_array(strtolower($match[1]), $businessTables, true)) { $businessWrites++; } elseif (str_starts_with(strtolower($match[1]), 'zyt_followup_audio_')) { $featureWrites++; } } }); $make = static function () use ($f, &$active): int { static $index = 0; $index++; $data = str_repeat(pack('vv', 1000 + $index, 2000 + $index), 16000); $path = $f['private'] . '/preview-' . $index . '.wav'; file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($data)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 2, 16000, 64000, 4, 16) . 'data' . pack('V', strlen($data)) . $data); chmod($path, 0600); $upload = followupAudioTestUpload($f, $path, 'synthetic.wav'); $task = Logic::create(['diagnosis_id' => 1, 'upload_id' => $upload['id'], 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen'], 1, $f['actor']); $active = $task['id']; return $active; }; $transport = static function (array $spec) use (&$active, &$calls, &$mode, $set, $expect): array { $calls[$spec['stage']]++; $row = Store::task($active); $state = Store::open($active, 'pipeline', $row['pipeline_cipher']); $expect(Checkpoint::hasIntent($state) && Gate::taskPreview($row), 'preview origin and intent durable before upstream'); if ($spec['stage'] === 'asr') { $expect(str_ends_with($spec['url'], '/audio-to-text') && !isset($spec['multipart']['model']) && isset($spec['multipart']['user']), 'actual preview Dify ASR contract'); if ($mode === 'revoke_scope') { $set(['test_diagnosis_ids' => '']); } if ($mode === 'revoke_admin') { $set(['test_admin_ids' => '2']); } return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['text' => '合成:今天早晨空腹血糖六点一。'], JSON_UNESCAPED_UNICODE)]; } $expect(str_ends_with($spec['url'], '/chat-messages') && !isset($spec['json']['max_tokens'], $spec['json']['model'], $spec['json']['conversation_id']), 'actual preview Dify extraction contract'); $citation = Prompt::citations(Checkpoint::segments($state))[0]; $answer = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成预览', 'uncertainties' => [], 'items' => [[ 'kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.1], 'record_date' => '2026-09-29', 'record_time' => '08:00', 'date_text' => '今天', 'time_text' => '早晨', 'time_period' => null, 'time_estimated' => false, 'needs_review' => true, 'evidence_ids' => [$citation['id']], ]]]; return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['message_id' => 'preview-dify-message', 'answer' => json_encode($answer, JSON_UNESCAPED_UNICODE)])]; }; $run = static fn (): bool => (new Worker(new Dify($transport)))->runOnce(); // Exercise the real app Request filter and public controller, not just direct Logic calls. $requestAction = static function (string $action, array $body) use ($f): array { $request = (new app\Request())->withServer(['REQUEST_METHOD' => 'POST']) ->withPost(json_decode(json_encode($body, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR)); $controller = (new ReflectionClass(FollowupAudioController::class))->newInstanceWithoutConstructor(); foreach (['request' => $request, 'adminId' => 1, 'adminInfo' => $f['actor']] as $key => $value) { $property = new ReflectionProperty($controller, $key); $property->setAccessible(true); $property->setValue($controller, $value); } return $controller->$action()->getData(); }; try { foreach (['', 'name-only', '1,,2', '0', '-1', ['1', null], ['id' => 1]] as $ids) { $set(['test_diagnosis_ids' => $ids]); $cap = Logic::capabilities(1, 1, $f['actor']); $expect(!$cap['enabled'] && !$cap['test_scope_allowed'] && $cap['models'] === [] && !$cap['can_apply'], 'empty/malformed scope fails closed without breaking capabilities'); $reject(fn () => Upload::createSession(1, 'synthetic.wav', 44, 1, $f['actor']), 'PREVIEW_SCOPE_DENIED'); } $set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']); $expect(!Store::ready() && !Store::verified(), 'neither preview nor full gate fabricated'); $set(['audio_verified' => true, 'verified_profiles' => ['qwen']]); $expect(Store::verified() && !Store::ready(), 'full gate cannot substitute missing preview fingerprint'); $set(['audio_verified' => false, 'verified_profiles' => []]); $slots = config('followup_audio.providers'); $slots['qwen']['preview_verified_fingerprint'] = Provider::resolve('qwen')['fingerprint']; $set(['providers' => $slots]); $cap = Logic::capabilities(1, 1, $f['actor']); $expect($cap['enabled'] && $cap['preview_only'] && $cap['preview_ready'] && $cap['test_scope_allowed'] && $cap['can_upload'] && $cap['can_review'] && !$cap['can_apply'] && !$cap['audio_verified'], 'preview upload/review separately ready, clinical apply always false'); $outside = Logic::capabilities(2, 1, $f['actor']); $expect(!$outside['enabled'] && !$outside['test_scope_allowed'] && $outside['models'] === [] && $outside['fields'] === [], 'other diagnosis normal editor receives disabled audio capabilities'); $reject(fn () => Upload::createSession(2, 'synthetic.wav', 44, 1, $f['actor']), 'PREVIEW_SCOPE_DENIED'); $reject(fn () => Upload::createSession(1, 'synthetic.wav', 44, 2, []), 'PREVIEW_SCOPE_DENIED'); $set(['test_admin_ids' => '']); $reject(fn () => Upload::createSession(1, 'synthetic.wav', 44, 3, ['root' => 1]), '诊单不存在或无权操作'); $set(['test_admin_ids' => '1']); $id = $make(); $claim = Store::claim(); $reject(fn () => Store::checkpoint($id, $claim['lease_token'], ['upstream_ids_json' => ['preview_only' => false]]), 'CHECKPOINT_INVALID'); $expect(Gate::taskPreview(Store::task($id)), 'preview-origin snapshot cannot be cleared by callback'); Store::fail($id, $claim['lease_token'], 'SYNTHETIC_LOCAL_CHECK', ''); Store::retry($id); $expect($run(), 'actual Worker processes ready preview while full verified false'); $detail = Logic::detail($id, 1, $f['actor']); $expect($detail['status'] === 'review' && $detail['preview_only'] && $detail['channel_roles'] === 'unconfirmed' && $calls === ['asr' => 2, 'extraction' => 1], 'allowed preview reaches transcript/review only'); $items = $detail['items']; $items[0]['selected'] = true; $items[0]['needs_review'] = false; $response = $requestAction('saveDraft', ['id' => $id, 'version' => $detail['version'], 'items' => $items, 'channel_roles' => 'left_service']); $expect($response['code'] === 1, 'HTTP draft save: ' . $response['msg']); $saved = $response['data']; $expect($saved['channel_roles'] === 'left_service' && !$saved['items'][0]['selected'], 'preview role and draft remain usable'); $items = $saved['items']; $items[0]['selected'] = true; $items[0]['needs_review'] = false; $response = $requestAction('saveDraft', ['id' => $id, 'version' => $saved['version'], 'items' => $items]); $expect($response['code'] === 1, 'HTTP repeated draft save: ' . $response['msg']); $saved = $response['data']; $expect($saved['items'][0]['selected'] === true && $saved['items'][0]['needs_review'] === false, 'JSON booleans remain booleans through the request/controller/database round trip'); $expect($saved['items'][0]['evidence'] === $items[0]['evidence'], 'immutable evidence and numeric channel/timestamps retained'); $body = ['id' => $id, 'version' => $saved['version'], 'items' => $saved['items']]; $invalid = $body; $invalid['items'][0]['evidence'][0]['text'] .= 'forged'; $response = $requestAction('saveDraft', $invalid); $expect($response['code'] === 0 && $response['msg'] === 'FOLLOWUP_AUDIO_IMMUTABLE_FIELD', 'evidence tampering remains rejected'); $invalid = $body; $invalid['items'][0]['selected'] = 'true'; $response = $requestAction('saveDraft', $invalid); $expect($response['code'] === 0 && $response['msg'] === 'FOLLOWUP_AUDIO_BOOLEAN_INVALID', 'string boolean remains invalid'); $invalid = $body; $invalid['items'][0]['target_id'] = '1'; $response = $requestAction('saveDraft', $invalid); $expect($response['code'] === 0 && $response['msg'] === 'FOLLOWUP_AUDIO_TARGET_INVALID', 'string target remains invalid'); $response = $requestAction('saveDraft', $body + ['unlisted' => true]); $expect($response['code'] === 0 && $response['msg'] === '请求包含不支持的字段', 'request key allowlist retained'); $response = $requestAction('apply', $body); $expect($response['code'] === 0 && $response['msg'] === 'FOLLOWUP_AUDIO_PREVIEW_ONLY', 'actual apply endpoint still hard-denies preview'); $expect(Store::detail($id)['version'] === $saved['version'], 'rejected requests never advance the draft version'); $legacy = (new app\Request())->withPost(['selected' => false, 'text' => ' legacy '])->post(); $expect($legacy['selected'] === '' && $legacy['text'] === 'legacy', 'unrelated endpoints retain original global trim'); $reject(fn () => Logic::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY'); $reject(fn () => Apply::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY'); $set(['audio_verified' => true, 'verified_profiles' => ['qwen']]); $reject(fn () => Apply::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY'); $set(['preview_only' => false]); $expect(Store::verified() && Store::detail($id)['preview_only'], 'origin survives later normal/full-verified configuration'); $reject(fn () => Apply::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY'); Db::name('followup_audio_task')->where('id', $id)->update(['status' => 'applied', 'stage' => 'applied', 'applied_cipher' => Store::seal($id, 'applied', ['items' => []])]); $reject(fn () => Apply::apply($id, $saved['version'], [], 1, $f['actor']), 'PREVIEW_ONLY'); $set(['preview_only' => true, 'audio_verified' => false, 'verified_profiles' => []]); $reject(fn () => Apply::apply($id, 999, [], 1, $f['actor']), 'PREVIEW_ONLY'); foreach (['revoke_scope', 'revoke_admin'] as $mode) { $set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']); $unknownId = $make(); $beforeCalls = $calls; $run(); $row = Store::task($unknownId); $expect($row['status'] === 'needs_reconciliation' && $calls['asr'] === $beforeCalls['asr'] + 1 && $calls['extraction'] === $beforeCalls['extraction'], 'scope/admin revoked after first request stops remaining upstream work'); $state = Store::open($unknownId, 'pipeline', $row['pipeline_cipher']); $expect(Checkpoint::hasIntent($state), 'unknown in-flight outcome retained'); $reject(fn () => Logic::detail($unknownId, 1, $f['actor']), 'PREVIEW_SCOPE_DENIED'); $set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']); $reject(fn () => Store::retry($unknownId), 'RETRY_NOT_SAFE'); $expect(Store::task($unknownId)['pipeline_cipher'] === $row['pipeline_cipher'], 'restoring allowlist does not clear unknown checkpoint'); } $mode = 'success'; $set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']); $queued = $make(); $set(['test_diagnosis_ids' => '']); $beforeCalls = $calls; $expect(!$run() && Store::task($queued)['status'] === 'queued' && $calls === $beforeCalls, 'revoked queued diagnosis not claimed or sent'); $expect($snapshot() === $beforeBusiness && $businessWrites === 0 && $featureWrites > 0 && $sqlEvents > 0, 'actual SQL listener: feature writes allowed, all clinical/audit tables zero writes and unchanged'); echo 'FOLLOWUP_AUDIO_PREVIEW assertions=' . $checks . ' PASS mysql23319=1 full_audio_verified_not_faked=1 allowlist_fail_closed=1 rbac=1 preview_review=1 clinical_writes=0 apply_root_direct_applied_denied=1 origin_permanent=1 revoke_stops_upstream=1 unknown_preserved=1' . PHP_EOL; } finally { followupAudioTestDatabaseCleanup($f); }