feat: configure audio providers and add local sample acceptance
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
<?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);
|
||||
}
|
||||
Reference in New Issue
Block a user