exec('CREATE TABLE zyt_dict_data (id INTEGER PRIMARY KEY, type_value TEXT, status INTEGER, sort INTEGER, name TEXT, value TEXT)'); $manager = new think\DbManager(); $manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => $directory . '/dictionary.sqlite', 'prefix' => 'zyt_']]]); think\Container::getInstance()->instance('think\DbManager', $manager); $wave = $directory . '/short.wav'; $samples = str_repeat(pack('v', 0), 16000); $bytes = 'RIFF' . pack('V', 36 + strlen($samples)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', strlen($samples)) . $samples; file_put_contents($wave, $bytes); $fixture = ['synthetic' => true, 'generator' => 'followup-audio-synthetic-v1', 'case' => 'short', 'sha256' => hash_file('sha256', $wave), 'duration_seconds' => 1, 'expected' => ['canaries' => ['DO_NOT_SEND_EXPECTATIONS_TO_MODEL']]]; $socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); $port = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); fclose($socket); $environment = getenv(); $environment['FOLLOWUP_AUDIO_MOCK_DIR'] = $directory; $process = proc_open([PHP_BINARY, '-n', '-S', '127.0.0.1:' . $port, __DIR__ . '/fixtures/followup_audio/dify_router.php'], [0 => ['pipe', 'r'], 1 => ['file', $directory . '/server.stdout', 'a'], 2 => ['file', $directory . '/server.stderr', 'a']], $pipes, __DIR__, $environment); if (!is_resource($process)) { throw new RuntimeException('MOCK_SERVER_START_FAILED'); } fclose($pipes[0]); $checks = 0; $expect = static function (bool $ok, string $message) use (&$checks): void { if (!$ok) { throw new RuntimeException($message); } $checks++; }; $expectError = static function (callable $call, string $code, bool $uncertain = false) use ($expect): void { try { $call(); $expect(false, 'expected ' . $code); } catch (AudioError $e) { $expect($e->errorCode === $code, 'expected ' . $code . ', got ' . $e->errorCode); $expect($e->uncertain === $uncertain, 'uncertain flag for ' . $code); $expect(!str_contains($e->getMessage(), 'private-body') && !str_contains($e->getMessage(), 'synthetic-test-key'), 'redacted error'); } }; $settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'ffprobe' => 'ffprobe', 'request_timeout' => 5, 'max_seconds' => 3600]; $adapter = static function (string $scenario, array $extra = []) use ($port, $settings): Dify { return new Dify(null, $extra + $settings, ['base_url' => 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key'], 'openai' => ['api_key' => 'synthetic-test-key']]]); }; try { $ready = false; for ($i = 0; $i < 100; $i++) { $connection = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $error, 0.1); if ($connection) { fclose($connection); $ready = true; break; } usleep(50000); } $expect($ready, 'loopback HTTP server ready'); $events = []; $heartbeat = static function (array $metadata = []) use (&$events): bool { $events[] = $metadata; return true; }; $result = $adapter('success')->probe($wave, $fixture, 'qwen', $heartbeat); $expect($result['items'][0]['values']['systolic_pressure'] == 126, 'real JSON facts preserved'); $expect(!isset($result['items'][0]['evidence'][0]['start_ms']) && !isset($result['items'][0]['evidence'][0]['end_ms']), 'unverified model offsets omitted, full audio only'); $expect($result['items'][0]['record_date'] === '2026-09-28', 'yesterday normalized from recorded_at'); $expect(substr($result['items'][0]['record_time'], 0, 5) === '21:00', 'temporal clock retained'); $expect(isset($events[0]['upstream_started_at']), 'durable intent before upload'); $expect($events[1]['upstream_file_id'] === 'mock-upload-success', 'upload ID checkpointed'); $ids = json_decode($events[2]['upstream_ids_json'], true); $expect($ids['task_id'] === 'mock-task-success' && $ids['message_id'] === 'mock-message-success' && $ids['conversation_id'] === 'mock-conversation-success' && $ids['upstream_request_id'] === 'mock-request-success', 'all upstream IDs retained'); $requests = array_map(static fn (string $row): array => json_decode($row, true), file($directory . '/requests.jsonl', FILE_IGNORE_NEW_LINES)); $expect(count($requests) === 2 && $requests[0]['sha256'] === $fixture['sha256'] && $requests[0]['valid'] && $requests[1]['valid'], 'actual bytes uploaded via multipart and local_file same user'); $expect(!str_contains(json_encode($requests[1]['payload']), 'DO_NOT_SEND_EXPECTATIONS_TO_MODEL'), 'ground truth not in query'); foreach (['患者本人和家属', '客服/医生的问题与患者回答', '否定、纠正', '当前与历史用药', '未询问或不确定', '不得自行诊断'] as $guard) { $expect(str_contains($requests[1]['payload']['query'], $guard), 'Actual wire prompt includes conservative semantic constraint: ' . $guard); } $expectError(static fn () => Dify::assertAudioPayload(['user' => 'u'], 'f'), 'AUDIO_ATTACHMENT_REQUIRED'); $expectError(static fn () => Dify::assertAudioPayload(['user' => 'u', 'files' => [['type' => 'image', 'transfer_method' => 'local_file', 'upload_file_id' => 'f']]], 'f'), 'AUDIO_ATTACHMENT_REQUIRED'); foreach (['upload_reject' => 'UPSTREAM_AUDIO_REJECTED', 'chat_reject' => 'UPSTREAM_AUDIO_REJECTED', 'wrong_type' => 'UPSTREAM_AUDIO_REJECTED', 'text_only' => 'AUDIO_NOT_PROCESSED', 'omitted' => 'AUDIO_NOT_PROCESSED', 'schema' => 'UPSTREAM_SCHEMA_INVALID', 'evidence' => 'UPSTREAM_SCHEMA_INVALID', 'extra_field' => 'UPSTREAM_SCHEMA_INVALID', 'bad_values' => 'UPSTREAM_SCHEMA_INVALID', 'bad_time' => 'UPSTREAM_SCHEMA_INVALID', 'markdown' => 'UPSTREAM_SCHEMA_INVALID'] as $scenario => $code) { $expectError(static fn () => $adapter($scenario)->probe($wave, $fixture, 'qwen', $heartbeat), $code); if ($scenario === 'wrong_type') { $lastEvent = end($events); $expect(($lastEvent['upstream_file_id'] ?? '') === 'mock-upload-wrong_type', 'rejected media file ID remains durable'); } } foreach (['unknown', 'malformed_response'] as $scenario) { $expectError(static fn () => $adapter($scenario)->probe($wave, $fixture, 'openai', $heartbeat), 'UPSTREAM_UNCERTAIN', true); if ($scenario === 'unknown') { $lastEvent = end($events); $failureIds = json_decode($lastEvent['upstream_ids_json'], true); $expect($failureIds['task_id'] === 'mock-uncertain-task' && $failureIds['conversation_id'] === 'mock-uncertain-conversation', 'uncertain HTTP response IDs retained'); } } $requests = array_map(static fn (string $row): array => json_decode($row, true), file($directory . '/requests.jsonl', FILE_IGNORE_NEW_LINES)); foreach (['upload_reject' => 1, 'chat_reject' => 2, 'wrong_type' => 1, 'omitted' => 2, 'unknown' => 2] as $scenario => $count) { $expect(count(array_filter($requests, static fn (array $request): bool => $request['scenario'] === $scenario)) === $count, 'no resend or text-only fallback ' . $scenario); } $localCalls = 0; $testTransport = static function () use (&$localCalls): array { $localCalls++; return []; }; $provider = ['base_url' => 'http://127.0.0.1:' . $port . '/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]]; $gated = new Dify($testTransport, ['enabled' => false], $provider); $expectError(static fn () => $gated->analyze([], $heartbeat), 'FEATURE_DISABLED'); $unverified = new Dify($testTransport, ['enabled' => true, 'audio_verified' => false], $provider); $expectError(static fn () => $unverified->analyze([], $heartbeat), 'AUDIO_NOT_VERIFIED'); $notVerifiedProfile = new Dify($testTransport, ['verified_profiles' => ['qwen']] + $settings, $provider); $expectError(static fn () => $notVerifiedProfile->analyze(['model_key' => 'openai'], $heartbeat), 'AUDIO_NOT_VERIFIED'); $guarded = new Dify($testTransport, $settings, $provider); $expectError(static fn () => $guarded->analyze(['upstream_started_at' => 1, 'model_key' => 'qwen'], $heartbeat), 'RECONCILIATION_REQUIRED', true); $expectError(static fn () => $guarded->probe($wave, $fixture + ['irrelevant' => 'x'], 'unsupported', $heartbeat), 'INVALID_PROFILE'); $badFixture = $fixture; $badFixture['sha256'] = str_repeat('0', 64); $expectError(static fn () => $guarded->probe($wave, $badFixture, 'qwen', $heartbeat), 'AUDIO_INVALID'); $notSynthetic = $fixture; $notSynthetic['synthetic'] = false; $expectError(static fn () => $guarded->probe($wave, $notSynthetic, 'qwen', $heartbeat), 'SYNTHETIC_FIXTURE_REQUIRED'); file_put_contents($wave, 'not audio'); $badFixture['sha256'] = hash_file('sha256', $wave); $expectError(static fn () => $guarded->probe($wave, $badFixture, 'qwen', $heartbeat), 'AUDIO_INVALID'); file_put_contents($wave, $bytes); $expectError(static fn () => $guarded->probe($wave, $fixture, 'qwen', static fn (): bool => false), 'LEASE_LOST'); $expect($localCalls === 0, 'disabled/unverified/reconciliation/invalid file/lease failures do not send'); $missing = new Dify($testTransport, $settings, []); $expect($missing->configurationStatus('qwen')['code'] === 'CONFIG_MISSING', 'missing existing credentials gate is concrete'); $expectError(static fn () => $missing->probe($wave, $fixture, 'qwen', $heartbeat), 'CONFIG_MISSING'); $expected = ['canaries' => ['头部密码', '结尾密码'], '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]], ]]; $probeResult = ['summary' => 'synthetic', 'transcript' => '头部密码到结尾密码', 'items' => $expected['facts']]; $expect(!in_array(false, FollowupAudioProbe::verifyExtraction($probeResult, $expected), true), 'probe accepts all temporal facts and audio-only canaries'); array_pop($probeResult['items']); $expect(!FollowupAudioProbe::verifyExtraction($probeResult, $expected)['unique_tail_fact'], 'probe rejects truncated tail'); $probeResult['transcript'] = '头部密码'; $expect(!FollowupAudioProbe::verifyExtraction($probeResult, $expected)['audio_only_canaries'], 'probe rejects missing audio-only canary'); $expectError(static fn () => $adapter('timeout', ['request_timeout' => 1])->probe($wave, $fixture, 'qwen', $heartbeat), 'UPSTREAM_UNCERTAIN', true); echo 'FOLLOWUP_AUDIO_DIFY assertions=' . $checks . ' PASS real_multipart=1 local_file=1 no_fallback=1 no_resend=1' . PHP_EOL; } catch (\Throwable $failure) { fwrite(STDERR, 'MOCK_DIAGNOSTIC ' . (string) @file_get_contents($directory . '/server.stderr') . PHP_EOL); throw $failure; } finally { proc_terminate($process); proc_close($process); foreach (glob($directory . '/*') ?: [] as $path) { if (is_file($path)) { unlink($path); } } rmdir($directory); }