Files
zyt/server/tests/FollowupAudioPipelineTest.php
T

102 lines
9.1 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioPipeline as Pipeline;
use app\common\service\followupaudio\FollowupAudioPipelineCheckpoint as Checkpoint;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioException as Error;
$checks = 0;
$expect = static function (bool $ok, string $message) use (&$checks): void { if (!$ok) { throw new RuntimeException($message); } $checks++; };
$reject = static function (callable $call, string $code) use ($expect): void {
try { $call(); } catch (Error $e) { $expect($e->errorCode === $code, 'expected ' . $code . ', got ' . $e->errorCode); return; }
throw new RuntimeException('Expected ' . $code);
};
$dir = sys_get_temp_dir() . '/fa-pipeline-http-' . bin2hex(random_bytes(6)); mkdir($dir, 0700);
new think\App();
$pdo = new PDO('sqlite:' . $dir . '/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' => $dir . '/dictionary.sqlite', 'prefix' => 'zyt_']]]);
think\Container::getInstance()->instance('think\DbManager', $manager);
$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);
$process = proc_open([PHP_BINARY, '-n', '-S', '127.0.0.1:' . $port, __DIR__ . '/fixtures/followup_audio/pipeline_router.php'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/stdout', 'a'], 2 => ['file', $dir . '/stderr', 'a']], $pipes, null,
array_merge(getenv(), ['FOLLOWUP_AUDIO_PIPELINE_MOCK_DIR' => $dir])); fclose($pipes[0]);
$pcm = str_repeat(pack('v', 1000), 24000);
$wav = 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', strlen($pcm)) . $pcm;
$path = $dir . '/short.wav'; file_put_contents($path, $wav); chmod($path, 0600);
$slot = ['driver' => 'asr_then_llm', 'label' => 'synthetic pipeline', 'allow_loopback_tunnel' => true,
'asr' => ['base_url' => 'http://127.0.0.1:' . $port . '/success/v1', 'api_key' => 'synthetic-key', 'model' => 'synthetic-asr'],
'extraction' => ['base_url' => 'http://127.0.0.1:' . $port . '/success/v1', 'api_key' => 'synthetic-key', 'model' => 'synthetic-llm']];
$settings = ['asr_chunk_seconds' => 1, 'request_timeout' => 5, 'providers' => ['qwen' => $slot]];
$provider = Provider::resolve('qwen', $settings, []);
$baseTask = ['id' => 1, 'sha256' => hash_file('sha256', $path), 'duration_seconds' => 1.5, 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen', 'upstream_started_at' => 0];
$bind = static fn (array $p): array => $baseTask + ['upstream_ids_json' => json_encode(['provider_fingerprint' => $p['fingerprint']])];
$state = []; $task = $bind($provider);
$callback = static function (array $fields) use (&$state, &$task): bool {
if (isset($fields['pipeline_checkpoint'])) {
$cp = $fields['pipeline_checkpoint']; Checkpoint::transition($state, $cp['state'], $task, $cp['expected_revision']); $state = $cp['state'];
}
return true;
};
try {
for ($i = 0; $i < 100; $i++) { $s = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $error, 0.1); if ($s) { fclose($s); break; } usleep(30000); }
$result = (new Pipeline($settings, $provider))->run($path, $task, $callback);
$requests = array_map(static fn (string $s): array => json_decode($s, true), file($dir . '/requests.jsonl', FILE_IGNORE_NEW_LINES));
$expect(count($requests) === 3 && !in_array(false, array_column($requests, 'valid'), true), 'actual multipart ASR and text-only cURL extraction');
$expect($requests[0]['bytes'] > $requests[1]['bytes'] && $state['chunks'][1]['start_ms'] === 1000 && $state['chunks'][1]['end_ms'] === 1500, 'nonoverlapping full coverage including fractional final chunk');
$expect($result['transcript'] === "今天早上血糖六点一。\n今天早上血糖六点一。" && $result['items'][0]['evidence'][0]['position_type'] === 'segment', 'canonical ASR transcript and real segment evidence');
$expect(hash_file('sha256', $path) === $baseTask['sha256'], 'original source unchanged');
(new Pipeline($settings, $provider))->run($path, $task, $callback, $state);
$expect(count(file($dir . '/requests.jsonl')) === 3, 'completed cached ASR and extraction never reissued');
foreach (['reject' => 'ASR_REJECTED', 'unknown' => 'UPSTREAM_UNCERTAIN', 'oversized' => 'UPSTREAM_UNCERTAIN'] as $scenario => $code) {
$options = $settings; $options['providers']['qwen']['asr']['base_url'] = 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1';
if ($scenario === 'oversized') { $options['max_response_bytes'] = 1024; }
$p = Provider::resolve('qwen', $options, []); $task = $bind($p); $state = [];
$reject(static fn () => (new Pipeline($options, $p))->run($path, $task, $callback), $code);
$expect($state['chunks'][0]['state'] === ($scenario === 'reject' ? 'rejected' : 'intent'), 'persist explicit rejection vs unknown intent');
if ($scenario !== 'reject') { $count = count(file($dir . '/requests.jsonl')); $reject(static fn () => (new Pipeline($options, $p))->run($path, $task, $callback, $state), 'RECONCILIATION_REQUIRED'); $expect(count(file($dir . '/requests.jsonl')) === $count, 'unknown never resent'); }
}
foreach (['http://localhost:8001/v1', 'http://192.0.2.2/v1', 'http://127.0.0.2/v1', 'http://2130706433/v1'] as $url) {
$bad = $settings; $bad['providers']['qwen']['asr']['base_url'] = $url;
$reject(static fn () => Provider::resolve('qwen', $bad, []), 'HTTPS_REQUIRED');
}
$bad = $settings; $bad['providers']['qwen']['allow_loopback_tunnel'] = false;
$reject(static fn () => Provider::resolve('qwen', $bad, []), 'HTTPS_REQUIRED');
$secure = $settings; $secure['providers']['qwen']['asr']['base_url'] = 'https://asr.invalid/v1'; $secure['providers']['qwen']['extraction']['base_url'] = 'https://llm.invalid/v1';
$expected = Provider::resolve('qwen', $secure, [])['fingerprint'];
foreach (['asr', 'extraction'] as $stage) { foreach (['api_key', 'model', 'base_url'] as $field) {
$changed = $secure; $changed['providers']['qwen'][$stage][$field] = $field === 'base_url' ? 'https://other.invalid/v1' : 'other';
$expect(Provider::resolve('qwen', $changed, [])['fingerprint'] !== $expected, 'fingerprint binds ' . $stage . ' ' . $field);
} }
$changed = $secure; $changed['asr_chunk_seconds'] = 120;
$expect(Provider::resolve('qwen', $changed, [])['fingerprint'] !== $expected, 'fingerprint binds chunk policy');
$changed['asr_chunk_seconds'] = 121; $reject(static fn () => Provider::resolve('qwen', $changed, []), 'CONFIG_INVALID');
$task = $bind($provider); $state = []; $count = count(file($dir . '/requests.jsonl'));
$reject(static fn () => (new Pipeline($settings, $provider))->run($path, $task, static fn (): bool => false), 'LEASE_LOST');
$expect(count(file($dir . '/requests.jsonl')) === $count, 'revoked authorization before media/network');
$changed = $settings; $changed['ffmpeg'] = '/not/a/real/ffmpeg';
$reject(static fn () => (new Pipeline($changed, $provider))->run($path, $task, $callback), 'AUDIO_NORMALIZATION_FAILED');
$expect(count(file($dir . '/requests.jsonl')) === $count && !Checkpoint::hasIntent($state), 'normalization failure before billable intent');
foreach (['' => 'ASR_TRANSCRIPT_EMPTY', str_repeat('重复内容', 25) => 'ASR_QUALITY_REVIEW_REQUIRED'] as $text => $code) {
$task = $bind($provider); $state = []; $network = 0;
$transport = static function (array $spec) use ($text, &$network): array {
$network++; if ($spec['stage'] !== 'asr') { throw new RuntimeException('Unreliable ASR must not reach extraction'); }
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['text' => $text])];
};
$reject(static fn () => (new Pipeline($settings, $provider, $transport))->run($path, $task, $callback), $code);
$expect($network === 2 && $state['extraction']['state'] === 'pending' && !Checkpoint::hasIntent($state), 'unreliable ASR retained, no LLM facts');
$reject(static fn () => (new Pipeline($settings, $provider, $transport))->run($path, $task, $callback, $state), $code);
$expect($network === 2, 'known unreliable ASR not resent on resume');
}
$hourTask = $baseTask; $hourTask['duration_seconds'] = 3600;
$plan = Checkpoint::initial($hourTask, $provider['fingerprint'], 120000);
$expect(count($plan['chunks']) === 30 && $plan['chunks'][29]['end_ms'] === 3600000, 'full-hour bounded plan');
echo 'FOLLOWUP_AUDIO_PIPELINE assertions=' . $checks . ' PASS real_loopback_http=1 full_coverage=1 bounded_response=1 HTTPS=1 fingerprint=1 immutable_source=1 no_blind_retry=1' . PHP_EOL;
} finally {
proc_terminate($process); proc_close($process);
foreach (glob($dir . '/*') as $file) { unlink($file); } rmdir($dir);
}