Files
zyt/server/tests/FollowupAudioOpenAiTest.php
T

115 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
/** Synthetic bytes, real ffmpeg/ffprobe, injected HTTP response; no network, .env or patient data. */
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioDify as Adapter;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioException as AudioError;
new think\App();
$directory = sys_get_temp_dir() . '/followup-audio-openai-' . bin2hex(random_bytes(6)); mkdir($directory, 0700, true);
$pdo = new PDO('sqlite:' . $directory . '/dictionary.sqlite');
$pdo->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);
}