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); $checks = 0; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; }; $expectError = static function (callable $call, string $code, bool $uncertain = false) use ($expect): void { try { $call(); } catch (AudioError $error) { $expect($error->errorCode === $code && $error->uncertain === $uncertain, 'expected ' . $code . ', got ' . $error->errorCode); return; } throw new RuntimeException('Expected ' . $code); }; $slot = ['driver' => 'openai_audio', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key', 'model' => 'audio-model-from-config']; $settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen'], 'providers' => ['qwen' => $slot], 'max_seconds' => 3600, 'ffmpeg' => 'ffmpeg', 'ffprobe' => 'ffprobe', 'request_timeout' => 5]; $fingerprint = Provider::resolve('qwen', $settings, [])['fingerprint']; $settings['providers']['qwen']['verified_fingerprint'] = $fingerprint; $wave = $directory . '/short.wav'; $samples = str_repeat(pack('v', 0), 48000); $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' => 3]; $raw = ['schema_version' => 'followup-audio-v1', 'audio_processed' => true, 'summary' => '合成事实', 'transcript' => '昨天晚上九点,收缩压126,舒张压82。', 'uncertainties' => [], 'items' => [[ 'kind' => 'blood', 'values' => ['systolic_pressure' => 126, 'diastolic_pressure' => 82], 'record_date' => null, 'record_time' => '21:00:00', 'time_period' => null, 'time_estimated' => false, 'date_text' => '昨天', 'time_text' => '晚上九点', 'evidence' => [['text' => '昨天晚上九点,收缩压126,舒张压82。']], 'needs_review' => false, ]]]; $events = []; $requests = []; $mode = 'success'; $heartbeat = static function (array $fields = []) use (&$events): bool { $events[] = $fields; return true; }; $transport = static function (array $spec) use (&$requests, &$events, &$mode, $raw, $expect): array { $expect(!empty(end($events)['upstream_started_at']), 'intent is durable before a single chat request'); $requests[] = $spec; $answer = $raw; if ($mode === 'not_read') { $answer['audio_processed'] = false; } $content = $mode === 'text_only' ? '没有收到可读取音频附件' : json_encode($answer, JSON_UNESCAPED_UNICODE); $response = ['id' => 'synthetic-completion-id', 'choices' => [['finish_reason' => $mode === 'truncated' ? 'length' : 'stop', 'message' => ['role' => 'assistant', 'content' => $content]]]]; if ($mode === 'malformed_choices') { $response['choices'] = 'invalid'; } return ['http_code' => $mode === 'unknown' ? 504 : ($mode === 'rejected' ? 400 : 200), 'errno' => 0, 'request_id' => 'synthetic-request-id', 'body' => json_encode($response)]; }; $adapter = new Adapter($transport, $settings, []); try { $result = $adapter->probe($wave, $fixture, 'qwen', $heartbeat); $spec = $requests[0]; $content = $spec['json']['messages'][0]['content']; $expect(count($requests) === 1 && $spec['url'] === 'https://synthetic.invalid/v1/chat/completions', 'one explicit OpenAI endpoint, no upload or Dify fallback'); $expect($spec['json']['model'] === $slot['model'], 'selected model actually sent'); $expect($content[1]['type'] === 'input_audio' && $content[1]['input_audio']['format'] === 'wav' && base64_decode($content[1]['input_audio']['data'], true) === $bytes, 'exact original WAV bytes attached'); $expect($result['items'][0]['record_date'] === '2026-09-28', 'same policy and temporal normalization'); $ids = json_decode(end($events)['upstream_ids_json'], true); $expect($ids['provider_fingerprint'] === $fingerprint && $ids['message_id'] === 'synthetic-completion-id' && $ids['upstream_request_id'] === 'synthetic-request-id', 'response ID and bound identity checkpointed'); foreach (['not_read' => 'AUDIO_NOT_PROCESSED', 'text_only' => 'UPSTREAM_SCHEMA_INVALID', 'truncated' => 'UPSTREAM_SCHEMA_INVALID', 'malformed_choices' => 'UPSTREAM_SCHEMA_INVALID', 'rejected' => 'UPSTREAM_AUDIO_REJECTED', 'unknown' => 'UPSTREAM_UNCERTAIN'] as $scenario => $code) { $mode = $scenario; $before = count($requests); $expectError(static fn () => $adapter->probe($wave, $fixture, 'qwen', $heartbeat), $code, $scenario === 'unknown'); $expect(count($requests) === $before + 1, 'HTTP success/rejection/unknown never causes fallback or resend'); $ids = json_decode(end($events)['upstream_ids_json'], true); $expect($ids['provider_fingerprint'] === $fingerprint && $ids['message_id'] === 'synthetic-completion-id', 'failed response retains identity and billable ID'); } $mode = 'success'; $http = $settings; $http['providers']['qwen']['base_url'] = 'http://synthetic.invalid/v1'; $before = count($requests); $expectError(static fn () => (new Adapter($transport, $http, []))->probe($wave, $fixture, 'qwen', $heartbeat), 'HTTPS_REQUIRED'); $http['allow_insecure_synthetic'] = true; (new Adapter($transport, $http, []))->probe($wave, $fixture, 'qwen', $heartbeat); $expect(count($requests) === $before + 1, 'HTTP requires explicit synthetic-only switch'); $expectError(static fn () => (new Adapter($transport, $http, []))->analyze(['model_key' => 'qwen'], $heartbeat), 'AUDIO_NOT_VERIFIED'); $before = count($requests); $expectError(static fn () => $adapter->analyze(['model_key' => 'qwen', 'upstream_started_at' => 1], $heartbeat), 'RECONCILIATION_REQUIRED', true); $expect(count($requests) === $before, 'started tasks never resend'); $analyze = new ReflectionMethod(Adapter::class, 'analyzeFile'); $task = ['id' => 'synthetic-task', 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen', 'sha256' => $fixture['sha256'], 'duration_seconds' => 3, 'upstream_ids_json' => '{}']; $expectError(static fn () => $analyze->invoke($adapter, $wave, $task, $heartbeat), 'PROVIDER_CONFIGURATION_CHANGED'); $task['upstream_ids_json'] = json_encode(['provider_fingerprint' => str_repeat('0', 64)]); $expectError(static fn () => $analyze->invoke($adapter, $wave, $task, $heartbeat), 'PROVIDER_CONFIGURATION_CHANGED'); $expect(count($requests) === $before, 'missing or changed provider snapshot fails before sending'); $task['upstream_ids_json'] = json_encode(['provider_fingerprint' => $fingerprint]); // M4A and AMR must fully normalize to MP3, not be mislabeled input_audio formats. $p = proc_open(['ffmpeg', '-nostdin', '-v', 'error', '-i', $wave, '-c:a', 'aac', $directory . '/short.m4a'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); fclose($pipes[0]); stream_get_contents($pipes[1]); stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $expect(proc_close($p) === 0, 'synthetic M4A generated'); file_put_contents($directory . '/short.amr', "#!AMR\n" . str_repeat("\x3c" . str_repeat("\0", 31), 150)); foreach (['m4a', 'amr'] as $extension) { $path = $directory . '/short.' . $extension; $hash = hash_file('sha256', $path); $task['sha256'] = $hash; $analyze->invoke($adapter, $path, $task, $heartbeat); $audio = end($requests)['json']['messages'][0]['content'][1]['input_audio']; $expect($audio['format'] === 'mp3' && base64_decode($audio['data'], true) !== false, $extension . ' complete MP3 processing payload'); $expect(hash_file('sha256', $path) === $hash && glob($directory . '/processing.*') === [], $extension . ' original preserved and private copy cleaned'); } $source = file_get_contents(dirname(__DIR__) . '/app/common/service/followupaudio/FollowupAudioDify.php'); $expect(str_contains($source, 'CURLOPT_SSL_VERIFYPEER => true') && str_contains($source, 'CURLOPT_SSL_VERIFYHOST => 2'), 'TLS validation is never disabled'); echo 'FOLLOWUP_AUDIO_OPENAI assertions=' . $checks . ' PASS real_input_audio=1 model_configurable=1 no_text_fallback=1 no_resend=1 https_patient_gate=1 full_normalization=1' . PHP_EOL; } finally { $manager->connect()->close(); $pdo = null; foreach (glob($directory . '/*') ?: [] as $path) { if (is_file($path)) { unlink($path); } } rmdir($directory); }