true, 'profile' => $profile, 'code' => 'OK', 'application_fingerprint' => self::$fingerprint]; } public function probe(string $path, array $fixture, string $profile, callable $heartbeat): array { self::$calls++; $heartbeat(['stage' => 'uploading', 'upstream_started_at' => time(), 'upstream_ids_json' => '{"request_id":"synthetic-request"}']); if (self::$mode === 'uncertain') { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); } return ['summary' => 'synthetic', 'transcript' => 'canary-start canary-end PRIVATE_TRANSCRIPT_NEVER_PRINTED', 'items' => $fixture['expected']['facts']]; } } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php'; use app\common\service\followupaudio\FollowupAudioDify as Dify; use app\command\FollowupAudioProbe as Probe; use think\console\Input; use think\console\Output; new think\App(); // No initialization, real config or DB. $safeConfig = new think\Config(); think\Container::getInstance()->instance('config', $safeConfig); $directory = sys_get_temp_dir() . '/followup-audio-probe-state-' . bin2hex(random_bytes(6)); mkdir($directory, 0700, true); $directory = realpath($directory); $manifest = ['synthetic' => true, 'generator' => 'followup-audio-synthetic-v1', 'recorded_at' => '2026-09-29 10:00:00', 'fixtures' => []]; foreach (['short' => 60, 'medium' => 900, 'long' => 3598] as $case => $duration) { $path = $directory . '/' . $case . '.mp3'; file_put_contents($path, 'synthetic-command-state-test-double-' . $case); $manifest['fixtures'][] = ['case' => $case, 'file' => $case . '.mp3', 'sha256' => hash_file('sha256', $path), 'duration_seconds' => $duration, 'expected' => ['canaries' => ['canary-start', 'canary-end'], 'facts' => [ ['kind' => 'blood', 'record_date' => '2026-09-28', 'record_time' => '21:00', 'values' => ['systolic_pressure' => 126]], ['kind' => 'blood', 'record_date' => '2026-09-27', 'record_time' => '14:00', 'values' => ['postprandial_blood_sugar' => 7.2]], ['kind' => 'blood', 'record_date' => '2026-09-29', 'record_time' => '07:00', 'values' => ['systolic_pressure' => 147]], ]]]; } file_put_contents($directory . '/manifest.json', json_encode($manifest)); $checks = 0; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; }; $run = static function (string $profile, bool $synthetic = true) use ($directory): array { $input = new Input(array_merge($synthetic ? ['--synthetic'] : [], ['--manifest', $directory . '/manifest.json', '--profile', $profile])); $output = new Output('buffer'); $code = (new Probe())->run($input, $output); return [$code, $output->fetch()]; }; try { $expected = $manifest['fixtures'][0]['expected']; $expected['canaries'] = ['紫色海豚金色河流绿色蜻蜓', '银色树叶紫色海豚红色石榴']; $result = ['summary' => 'synthetic', 'transcript' => '紫色海豚、金色河流,绿色蜻蜓。银色树叶,紫色海豚,红色石榴。', 'items' => $expected['facts']]; $expect(!in_array(false, Probe::verifyExtraction($result, $expected), true), 'ASR punctuation does not change audio-only canary words'); $changed = $result; $changed['transcript'] = str_replace('石榴', '石流', $changed['transcript']); $expect(!Probe::verifyExtraction($changed, $expected)['audio_only_canaries'], 'wrong spoken-word recognition still fails'); $changed = $result; $changed['items'][0]['record_date'] = '2026-09-27'; $expect(!Probe::verifyExtraction($changed, $expected)['historical_temporal_facts'], 'wrong relative date still fails'); $changed = $result; array_pop($changed['items']); $expect(!Probe::verifyExtraction($changed, $expected)['unique_tail_fact'], 'missing last audio fact still fails'); [$code, $stdout] = $run('qwen', false); $expect($code === 1 && Dify::$calls === 0 && str_contains($stdout, 'SYNTHETIC_ACK_REQUIRED'), 'explicit synthetic acknowledgement required'); [$code, $stdout] = $run('qwen'); $expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'needs_reconciliation'), 'unknown result stops next cases'); $report = json_decode(file_get_contents($directory . '/probe-qwen-' . Dify::$fingerprint . '.json'), true); $expect($report['cases']['short']['upstream_started_at'] > 0, 'intent persisted before request'); [$code, $stdout] = $run('qwen'); $expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'resume never recharges unknown result'); $currentQwenPath = $directory . '/probe-qwen-' . Dify::$fingerprint . '.json'; $legacyQwenPath = $directory . '/probe-qwen.json'; rename($currentQwenPath, $legacyQwenPath); $legacyBytes = file_get_contents($legacyQwenPath); [$code, $stdout] = $run('qwen'); $expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'matching legacy identity remains authoritative for unknown requests'); $expect(file_get_contents($legacyQwenPath) === $legacyBytes, 'legacy unknown evidence retained unchanged'); $legacyReport = json_decode($legacyBytes, true); $legacyReport['configuration']['application_fingerprint'] = hash('sha256', "https://synthetic.invalid/v1\0synthetic-key"); file_put_contents($legacyQwenPath, json_encode($legacyReport)); $safeConfig->set(['providers' => ['qwen' => ['driver' => 'dify', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key']]], 'followup_audio'); [$code, $stdout] = $run('qwen'); $expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'legacy Dify hash upgrade cannot resubmit same-app unknown'); $safeConfig->set([], 'followup_audio'); Dify::$mode = 'success'; [$code, $stdout] = $run('openai'); $expect($code === 0 && Dify::$calls === 4, 'three lengths passed sequentially'); $bytes = file_get_contents($directory . '/probe-openai-' . Dify::$fingerprint . '.json'); $report = json_decode($bytes, true); $expect($report['audio_verified'] && count($report['cases']) === 3, 'all three required before verification report'); $expect(!str_contains($bytes . $stdout, 'PRIVATE_TRANSCRIPT_NEVER_PRINTED'), 'no transcript in output or report'); [$code, $stdout] = $run('openai'); $expect($code === 0 && Dify::$calls === 4 && substr_count($stdout, 'retained_passed') === 3, 'passed gate results reused without resending'); $oldPath = $directory . '/probe-openai-' . Dify::$fingerprint . '.json'; Dify::$fingerprint = '2222222222222222222222222222222222222222222222222222222222222222'; [$code, $stdout] = $run('openai'); $expect($code === 0 && Dify::$calls === 7 && !str_contains($stdout, 'retained_passed'), 'new application executes its own gate'); $expect(file_get_contents($oldPath) === $bytes, 'application mismatch leaves original evidence unchanged'); file_put_contents($directory . '/short.mp3', 'tampered'); [$code, $stdout] = $run('qwen'); $expect($code === 1 && Dify::$calls === 7 && str_contains($stdout, 'SYNTHETIC_FIXTURE_REQUIRED'), 'tampered fixture cannot run'); echo 'FOLLOWUP_AUDIO_PROBE_COMMAND assertions=' . $checks . ' PASS durable_no_resend=1 retained_results=1 app_identity=1' . PHP_EOL; } finally { foreach (glob($directory . '/*') ?: [] as $file) { if (is_file($file)) { unlink($file); } } rmdir($directory); } }