2]); $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); }; $make = static function (int $channels = 2, bool $allSilent = false) use ($f, &$active): int { static $serial = 0; $serial++; $frame = $allSilent ? str_repeat("\0", $channels * 2) : pack('v', 1000 + $serial) . ($channels === 2 ? pack('v', 2000 + $serial) : ''); $data = str_repeat($frame, 16000 * ($allSilent ? 1 : 5)); $path = $f['private'] . '/synthetic-' . $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'); $task = Store::create($upload, '2026-09-29 10:00:00', 'qwen', 1, $f['actor']); $active = $task['id']; return $active; }; $transport = static function (array $spec) use (&$calls, &$active, &$mode, $expect): array { $calls[$spec['stage']]++; $task = Store::task($active); $state = Store::open($active, 'pipeline', $task['pipeline_cipher']); $expect($state['schema_version'] === 2 && Checkpoint::hasIntent($state), 'new v2 encrypted intent precedes transport'); if ($spec['stage'] === 'asr') { $intent = array_values(array_filter($state['chunks'], static fn ($chunk): bool => $chunk['state'] === 'intent'))[0]; if ($mode === 'unknown_right' && ($intent['channel'] ?? -1) === 1) { return ['http_code' => 0, 'errno' => 28, 'body' => '']; } return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['text' => ($intent['channel'] ?? 0) === 0 ? '合成:今天早晨空腹血糖六点一。' : '合成:今天早餐吃了鸡蛋。'], JSON_UNESCAPED_UNICODE)]; } $citations = Prompt::citations(Checkpoint::segments($state)); $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' => [$citations[0]['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(); $resolve = static function (array $detail): array { $items = $detail['items']; foreach ($items as &$item) { $item['selected'] = true; $item['needs_review'] = false; } unset($item); return $items; }; try { $id = $make(); $expect($run(), 'actual Worker consumes stereo task'); $detail = Store::detail($id); $expect($detail['status'] === 'review' && $calls === ['asr' => 6, 'extraction' => 1], 'both channels every window then one extraction'); $expect($detail['channel_roles'] === 'unconfirmed' && count($detail['transcript_segments']) === 6 && array_column($detail['transcript_segments'], 'channel') === [0, 1, 0, 1, 0, 1], 'detail exposes canonical ordered channels without inferred roles'); $expect($detail['items'][0]['evidence'][0]['channel'] === 0 && $detail['items'][0]['needs_review'] && !$detail['items'][0]['selected'], 'stereo evidence channel immutable and review forced'); $reject(fn () => Apply::apply($id, $detail['version'], $resolve($detail), 1, $f['actor']), 'CHANNEL_ROLES_REQUIRED'); $before = Store::task($id)['review_cipher']; $reject(fn () => Store::saveDraft($id, $detail['version'], [], 3, 'left_service'), '诊单不存在或无权操作'); $expect(Store::task($id)['review_cipher'] === $before, 'role scope failure does not mutate ciphertext'); Db::name('admin')->where('id', 1)->update(['disable' => 1]); $reject(fn () => Store::saveDraft($id, $detail['version'], [], 1, 'left_service'), '账号已停用'); Db::name('admin')->where('id', 1)->update(['disable' => 0]); $reject(fn () => Store::saveDraft($id, $detail['version'], [], 1, 'patient_is_left'), 'CHANNEL_ROLES_INVALID'); $saved = Store::saveDraft($id, $detail['version'], $resolve($detail), 1, 'left_service'); $expect($saved['channel_roles'] === 'left_service' && $saved['version'] === $detail['version'] + 1 && !$saved['items'][0]['selected'] && $saved['items'][0]['needs_review'], 'role confirmation is version-bound and clears simultaneous selection'); $reject(fn () => Store::saveDraft($id, $detail['version'], [], 1, 'right_service'), 'VERSION_CONFLICT'); $expect(Store::detail($id)['channel_roles'] === 'left_service', 'stale role changes cannot win'); $forged = $saved['items']; $forged[0]['evidence'][0]['channel'] = 1; $reject(fn () => Store::saveDraft($id, $saved['version'], $forged, 1), 'IMMUTABLE_FIELD'); $forged = $saved['items']; $forged[0]['channel_roles'] = 'right_service'; $reject(fn () => Store::saveDraft($id, $saved['version'], $forged, 1), 'IMMUTABLE_FIELD'); $saved = Store::saveDraft($id, $saved['version'], $resolve($saved), 1); $expect($saved['channel_roles'] === 'left_service' && $saved['items'][0]['selected'], 'ordinary value review retains saved role'); $beforeCalls = $calls; $saved = Store::saveDraft($id, $saved['version'], $resolve($saved), 1, 'right_service'); $expect($saved['channel_roles'] === 'right_service' && !$saved['items'][0]['selected'] && $saved['items'][0]['needs_review'] && $calls === $beforeCalls, 'role swap forces new review, no re-extraction'); $sourceEvidence = $saved['items'][0]['evidence']; Db::name('tcm_blood_record')->insert(['diagnosis_id' => 1, 'patient_id' => 101, 'record_date' => Policy::dayTimestamp('2026-09-29'), 'record_time' => '08:00', 'fasting_blood_sugar' => 5.5]); $reject(fn () => Apply::apply($id, $saved['version'], $resolve($saved), 1, $f['actor']), 'STALE_REVIEW'); $fresh = Store::detail($id); $expect($fresh['channel_roles'] === 'right_service' && !$fresh['items'][0]['selected'] && $fresh['items'][0]['needs_review'], 'actual conflict refresh preserves role metadata'); $expect($fresh['items'][0]['evidence'] === $sourceEvidence, 'source evidence not relabeled by role change'); $applied = Apply::apply($id, $fresh['version'], $resolve($fresh), 1, $f['actor']); $expect($applied['status'] === 'applied' && (int) Db::name('followup_audio_audit')->where('task_id', $id)->count() === 1, 'explicit re-review applies one new local record'); $audit = Db::name('followup_audio_audit')->where('task_id', $id)->find(); $auditSource = Store::open($id, 'audit-source:' . $audit['item_id'], $audit['source_cipher']); $expect($auditSource['channel_roles'] === 'right_service' && $auditSource['channel_roles_annotation']['actor_id'] === 1 && $auditSource['evidence'][0]['channel'] === 0, 'audit stores human role annotation separately from original channel evidence'); $appliedCipher = Store::task($id)['applied_cipher']; $expect(Store::detail($id)['channel_roles'] === 'right_service', 'applied role comes from stored server metadata'); $reject(fn () => Store::saveDraft($id, Store::detail($id)['version'], [], 1, 'left_service'), 'NOT_REVIEWABLE'); $expect(Store::task($id)['applied_cipher'] === $appliedCipher, 'adopted results immutable'); $monoId = $make(1); $run(); $mono = Store::detail($monoId); $expect($mono['status'] === 'review' && $mono['channel_roles'] === 'unconfirmed' && !array_key_exists('channel', $mono['transcript_segments'][0]), 'mono remains compatible'); $reject(fn () => Store::saveDraft($monoId, $mono['version'], [], 1, 'left_service'), 'CHANNEL_ROLES_INVALID'); $expect(Apply::apply($monoId, $mono['version'], $resolve($mono), 1, $f['actor'])['status'] === 'applied', 'mono has no new role-confirmation gate'); $mode = 'unknown_right'; $unknownId = $make(); $beforeCalls = $calls; $run(); $unknown = Store::detail($unknownId); $expect($unknown['status'] === 'needs_reconciliation' && !$unknown['can_retry'] && $unknown['pipeline_progress']['transcribed'] === 1 && $calls['asr'] === $beforeCalls['asr'] + 2, 'unknown right channel retains completed left and forbids blind retry'); $unknownCipher = Store::task($unknownId)['pipeline_cipher']; $reject(fn () => Store::retry($unknownId), 'RETRY_NOT_SAFE'); $expect(Store::task($unknownId)['pipeline_cipher'] === $unknownCipher, 'unknown checkpoint never cleared'); $mode = 'success'; $silentId = $make(2, true); $beforeCalls = $calls; $run(); $silent = Store::detail($silentId); $expect($calls === $beforeCalls && $silent['error_code'] === 'ASR_TRANSCRIPT_EMPTY' && $silent['pipeline_progress'] === ['transcribed' => 2, 'total' => 2], 'both exactly-zero channels retained without ASR or LLM'); $silentState = Store::open($silentId, 'pipeline', Store::task($silentId)['pipeline_cipher']); $expect(array_column($silentState['chunks'], 'state') === ['local_silence', 'local_silence'] && (int) Store::task($silentId)['upstream_started_at'] === 0, 'no fabricated upstream result for local silence'); $legacyId = $make(1); $legacyTask = Store::task($legacyId); $oldFingerprint = str_repeat('c', 64); $legacyState = ['schema_version' => 1, 'revision' => 3, 'source_sha256' => $legacyTask['sha256'], 'fingerprint' => $oldFingerprint, 'duration_ms' => 5000, 'chunk_ms' => 120000, 'chunks' => [['id' => 'asr-' . hash('sha256', $legacyTask['sha256'] . ':' . $oldFingerprint . ':0:5000'), 'start_ms' => 0, 'end_ms' => 5000, 'state' => 'complete', 'request_id' => 'fa-' . str_repeat('c', 32), 'text' => 'legacy synthetic text']], 'extraction' => ['state' => 'pending']]; $legacyCipher = Store::seal($legacyId, 'pipeline', $legacyState); Db::name('followup_audio_task')->where('id', $legacyId)->update(['status' => 'failed', 'stage' => 'failed', 'pipeline_cipher' => $legacyCipher, 'upstream_ids_json' => json_encode(['provider_fingerprint' => $oldFingerprint]), 'upstream_started_at' => time()]); $oldDetail = Store::detail($legacyId); $expect($oldDetail['transcript'] === 'legacy synthetic text' && $oldDetail['channel_roles'] === 'unconfirmed' && !isset($oldDetail['transcript_segments'][0]['channel']) && !$oldDetail['can_retry'], 'old task text readable without rebinding provider'); $expect(Store::task($legacyId)['pipeline_cipher'] === $legacyCipher, 'legacy reads do not rewrite checkpoints'); $reject(fn () => Store::retry($legacyId), 'PROVIDER_CONFIGURATION_CHANGED'); $legacyAppliedId = $make(1); $oldAppliedCipher = Store::seal($legacyAppliedId, 'applied', ['items' => [['kind' => 'blood', 'record_id' => 999]]]); Db::name('followup_audio_task')->where('id', $legacyAppliedId)->update(['status' => 'applied', 'stage' => 'applied', 'applied_cipher' => $oldAppliedCipher]); $expect(Store::detail($legacyAppliedId)['applied_items'] === [['kind' => 'blood', 'record_id' => 999]] && Store::task($legacyAppliedId)['applied_cipher'] === $oldAppliedCipher, 'old applied results unchanged'); echo 'FOLLOWUP_AUDIO_STEREO_DATABASE assertions=' . $checks . ' PASS mysql' . (int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') . '=1 actual_worker=1 both_channels=1 role_scope_version=1 role_change_clears=1 apply_role_required=1 immutable_channels=1 audit_annotation=1 unknown_fenced=1 local_silence=1 legacy_unchanged=1' . PHP_EOL; } finally { followupAudioTestDatabaseCleanup($f); }