Files
zyt/server/tests/FollowupAudioDifyPipelineTest.php
T

91 lines
10 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\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioStreamTransport as Stream;
use app\common\service\followupaudio\FollowupAudioException as Error;
$dir = sys_get_temp_dir() . '/fa-dify-stage-' . bin2hex(random_bytes(6)); mkdir($dir, 0700);
new think\App(); $db = new PDO('sqlite:' . $dir . '/dictionary.sqlite');
$db->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);
$checks = 0; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $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); };
$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);
$server = proc_open([PHP_BINARY, '-n', '-S', '127.0.0.1:' . $port, __DIR__ . '/fixtures/followup_audio/dify_pipeline_router.php'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/server.stdout', 'a'], 2 => ['file', $dir . '/server.stderr', 'a']], $pipes, null,
array_merge(getenv(), ['FOLLOWUP_AUDIO_DIFY_MOCK_DIR' => $dir])); fclose($pipes[0]);
$bytes = str_repeat(pack('v', 1000), 16000); $wave = $dir . '/source.wav';
file_put_contents($wave, 'RIFF' . pack('V', 36 + strlen($bytes)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', strlen($bytes)) . $bytes); chmod($wave, 0600);
$settings = ['request_timeout' => 5, 'providers' => ['qwen' => ['driver' => 'asr_then_llm', 'label' => 'Synthetic', 'allow_loopback_tunnel' => true,
'asr' => ['protocol' => 'dify', 'binding_revision' => 'synthetic-asr-v1', 'base_url' => 'http://127.0.0.1:' . $port . '/success/v1', 'api_key' => 'synthetic-dify-key', 'model' => 'expected-asr'],
'extraction' => ['protocol' => 'dify_chat', 'binding_revision' => 'synthetic-chat-v1', 'base_url' => 'http://127.0.0.1:' . $port . '/success/v1', 'api_key' => 'synthetic-dify-key', 'model' => 'expected-llm', 'response_format' => 'prompt_json']]]];
$state = []; $fields = [];
$heartbeat = static function (array $value) use (&$state, &$fields): bool { $fields[] = $value; if (isset($value['pipeline_checkpoint'])) { $state = $value['pipeline_checkpoint']['state']; } return true; };
$taskFor = static fn (array $p): array => ['id' => 1, 'model_key' => 'qwen', 'sha256' => hash_file('sha256', $wave), 'duration_seconds' => 1,
'recorded_at' => '2026-09-29 10:00:00', 'upstream_started_at' => 0, 'upstream_ids_json' => json_encode(['provider_fingerprint' => $p['fingerprint']])];
$tls = null;
try {
for ($i = 0; $i < 100; $i++) { $socket = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $error, 0.1); if ($socket) { fclose($socket); break; } usleep(20000); }
foreach (['curl', 'openssl_stream'] as $transport) {
$options = $settings; $options['http_transport'] = $transport; $provider = Provider::resolve('qwen', $options, []); $task = $taskFor($provider); $state = []; $fields = [];
$result = (new Pipeline($options, $provider))->run($wave, $task, $heartbeat);
$expect($result['transcript'] === '今天早晨空腹血糖六点一。' && count($result['items']) === 1, $transport . ' actual Dify stage pipeline');
$expect($state['extraction']['upstream_ids']['message_id'] === 'dify-message-success' && $state['extraction']['upstream_ids']['conversation_id'] === 'dify-conversation-never-reused', 'valid upstream IDs retained');
$rows = array_map(static fn ($line) => json_decode($line, true), file($dir . '/requests.jsonl', FILE_IGNORE_NEW_LINES));
$expect(!in_array(false, array_column($rows, 'valid'), true), 'actual HTTP has Dify-only shape, no local model/generation/conversation leakage');
$before = count($rows); (new Pipeline($options, $provider))->run($wave, $task, $heartbeat, $state);
$expect(count(file($dir . '/requests.jsonl')) === $before, 'known completed Dify results are not resent');
foreach (['reject' => 'ASR_REJECTED', 'unknown' => 'UPSTREAM_UNCERTAIN', 'redirect' => 'UPSTREAM_UNCERTAIN', 'oversize' => 'UPSTREAM_UNCERTAIN'] as $scenario => $code) {
$bad = $options; $bad['providers']['qwen']['asr']['base_url'] = 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1';
if ($scenario === 'oversize') { $bad['max_response_bytes'] = 1024; }
$p = Provider::resolve('qwen', $bad, []); $t = $taskFor($p); $state = []; $fields = [];
$before = count(file($dir . '/requests.jsonl'));
$reject(static fn () => (new Pipeline($bad, $p))->run($wave, $t, $heartbeat), $code);
$expect(count(file($dir . '/requests.jsonl')) === $before + 1, 'no HTTP redirect/protocol/transport fallback on ' . $scenario);
$expect($state['chunks'][0]['state'] === ($scenario === 'reject' ? 'rejected' : 'intent'), 'known rejection vs unresolved intent preserved');
if ($scenario === 'unknown') {
$expect((bool) array_filter($fields, static fn ($value) => isset($value['upstream_run_id']) && $value['upstream_run_id'] === 'unknown-observed-id'), 'unknown response ID retained before error');
$reject(static fn () => (new Pipeline($bad, $p))->run($wave, $t, $heartbeat, $state), 'RECONCILIATION_REQUIRED');
$expect(count(file($dir . '/requests.jsonl')) === $before + 1, 'unknown result is not retried');
}
}
$length = $options; $length['providers']['qwen']['extraction']['base_url'] = 'http://127.0.0.1:' . $port . '/length/v1';
$p = Provider::resolve('qwen', $length, []); $t = $taskFor($p); $state = [];
$reject(static fn () => (new Pipeline($length, $p))->run($wave, $t, $heartbeat), 'UPSTREAM_SCHEMA_INVALID');
$expect($state['extraction']['state'] === 'complete' && $state['extraction']['answer'] === '', 'Dify explicit length finish stays a known completed failure');
}
foreach (['asr', 'extraction'] as $stage) { $bad = $settings; $bad['providers']['qwen'][$stage]['binding_revision'] = ''; $reject(static fn () => Provider::resolve('qwen', $bad, []), 'CONFIG_INVALID'); }
$bad = $settings; $bad['providers']['qwen']['extraction']['response_format'] = 'json_schema'; $reject(static fn () => Provider::resolve('qwen', $bad, []), 'CONFIG_INVALID');
$bad = $settings; $bad['providers']['qwen']['extraction']['enable_thinking'] = true; $reject(static fn () => Provider::resolve('qwen', $bad, []), 'CONFIG_INVALID');
$a = Provider::resolve('qwen', $settings, []); $bad = $settings; $bad['providers']['qwen']['asr']['binding_revision'] = 'synthetic-asr-v2';
$expect(Provider::resolve('qwen', $bad, [])['fingerprint'] !== $a['fingerprint'], 'Dify revision changes identity');
$bad = $settings; $bad['http_transport'] = 'openssl_stream'; $expect(Provider::resolve('qwen', $bad, [])['fingerprint'] !== $a['fingerprint'], 'explicit TLS transport changes identity');
$expect(!isset($a['extraction']['max_tokens'], $a['extraction']['enable_thinking']), 'Dify generation owned by App, not claimed as local effective knobs');
// Verified TLS rejects an untrusted local certificate; no trust store change or verify=false workaround.
$cert = proc_open(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', $dir . '/key.pem', '-out', $dir . '/cert.pem', '-days', '1', '-subj', '/CN=localhost'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/cert.stdout', 'a'], 2 => ['file', $dir . '/cert.stderr', 'a']], $p); fclose($p[0]); $expect(proc_close($cert) === 0, 'local test certificate generated');
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); $tlsPort = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); fclose($socket);
$tls = proc_open(['openssl', 's_server', '-accept', '127.0.0.1:' . $tlsPort, '-cert', $dir . '/cert.pem', '-key', $dir . '/key.pem', '-quiet', '-www'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/tls.stdout', 'a'], 2 => ['file', $dir . '/tls.stderr', 'a']], $p); fclose($p[0]); usleep(200000);
$spec = ['url' => 'https://127.0.0.1:' . $tlsPort . '/v1/chat-messages', 'api_key' => 'synthetic-dify-key', 'timeout' => 3, 'stage' => 'extraction', 'json' => ['inputs' => new stdClass(), 'query' => 'synthetic']];
$reply = Stream::request($spec, static fn (): bool => true, 1024, false);
$expect($reply['errno'] !== 0 && $reply['http_code'] === 0, 'untrusted TLS fails closed');
$spec['url'] = 'http://127.0.0.1:' . $port . '/slow/v1/chat-messages'; $spec['json'] = (new Pipeline($settings, $a))->extractRequest('synthetic', [['id' => 's', 'start_ms' => 0, 'end_ms' => 1000, 'text' => 'synthetic']], '2026-09-29 10:00:00', ['blood' => [['key' => 'systolic_pressure', 'type' => 'number']]]);
$spec['timeout'] = 1; $start = microtime(true); $reply = Stream::request($spec, static fn (): bool => true, 1024, true);
$expect($reply['errno'] === 28 && microtime(true) - $start < 3, 'blocked response headers remain wall-clock bounded');
$spec['timeout'] = 10; $beats = 0; $start = microtime(true);
$reply = Stream::request($spec, static function () use (&$beats): bool { $beats++; return $beats < 2; }, 1024, true);
$expect($reply['errno'] === 42 && $beats === 2 && microtime(true) - $start < 7, 'parent rechecks authorization after5s while child waits for headers');
echo 'FOLLOWUP_AUDIO_DIFY_PIPELINE assertions=' . $checks . ' PASS native_audio_to_text=1 blocking_chat=1 no_fake_generation_args=1 revision_bound=1 curl_and_openssl=1 verified_tls=1 parent_heartbeat=1 body_timeout_limits=1 no_fallback=1 unknown_fenced=1' . PHP_EOL;
} finally {
if (is_resource($tls)) { proc_terminate($tls, 9); proc_close($tls); }
proc_terminate($server, 9); proc_close($server);
foreach (glob($dir . '/*') as $file) { unlink($file); } rmdir($dir);
}