155 lines
12 KiB
PHP
155 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/** Real multipart+cURL loopback integration, synthetic-only; no app initialize(), DB or .env. */
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
|
|
|
use app\common\service\followupaudio\FollowupAudioDify as Dify;
|
|
use app\common\service\followupaudio\FollowupAudioException as AudioError;
|
|
use app\command\FollowupAudioProbe;
|
|
|
|
$directory = sys_get_temp_dir() . '/followup-audio-http-' . bin2hex(random_bytes(6));
|
|
mkdir($directory, 0700, true);
|
|
// Disposable SQLite dictionary only. No initialization or production database access.
|
|
new think\App();
|
|
$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);
|
|
|
|
$wave = $directory . '/short.wav';
|
|
$samples = str_repeat(pack('v', 0), 16000);
|
|
$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' => 1,
|
|
'expected' => ['canaries' => ['DO_NOT_SEND_EXPECTATIONS_TO_MODEL']]];
|
|
$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);
|
|
$environment = getenv();
|
|
$environment['FOLLOWUP_AUDIO_MOCK_DIR'] = $directory;
|
|
$process = proc_open([PHP_BINARY, '-n', '-S', '127.0.0.1:' . $port,
|
|
__DIR__ . '/fixtures/followup_audio/dify_router.php'],
|
|
[0 => ['pipe', 'r'], 1 => ['file', $directory . '/server.stdout', 'a'], 2 => ['file', $directory . '/server.stderr', 'a']], $pipes, __DIR__, $environment);
|
|
if (!is_resource($process)) { throw new RuntimeException('MOCK_SERVER_START_FAILED'); }
|
|
fclose($pipes[0]);
|
|
$checks = 0;
|
|
$expect = static function (bool $ok, string $message) use (&$checks): void {
|
|
if (!$ok) { throw new RuntimeException($message); }
|
|
$checks++;
|
|
};
|
|
$expectError = static function (callable $call, string $code, bool $uncertain = false) use ($expect): void {
|
|
try { $call(); $expect(false, 'expected ' . $code); }
|
|
catch (AudioError $e) {
|
|
$expect($e->errorCode === $code, 'expected ' . $code . ', got ' . $e->errorCode);
|
|
$expect($e->uncertain === $uncertain, 'uncertain flag for ' . $code);
|
|
$expect(!str_contains($e->getMessage(), 'private-body') && !str_contains($e->getMessage(), 'synthetic-test-key'), 'redacted error');
|
|
}
|
|
};
|
|
$settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'ffprobe' => 'ffprobe', 'request_timeout' => 5, 'max_seconds' => 3600];
|
|
$adapter = static function (string $scenario, array $extra = []) use ($port, $settings): Dify {
|
|
return new Dify(null, $extra + $settings, ['base_url' => 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1',
|
|
'models' => ['qwen' => ['api_key' => 'synthetic-test-key'], 'openai' => ['api_key' => 'synthetic-test-key']]]);
|
|
};
|
|
try {
|
|
$ready = false;
|
|
for ($i = 0; $i < 100; $i++) {
|
|
$connection = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $error, 0.1);
|
|
if ($connection) { fclose($connection); $ready = true; break; }
|
|
usleep(50000);
|
|
}
|
|
$expect($ready, 'loopback HTTP server ready');
|
|
$events = [];
|
|
$heartbeat = static function (array $metadata = []) use (&$events): bool { $events[] = $metadata; return true; };
|
|
$result = $adapter('success')->probe($wave, $fixture, 'qwen', $heartbeat);
|
|
$expect($result['items'][0]['values']['systolic_pressure'] == 126, 'real JSON facts preserved');
|
|
$expect(!isset($result['items'][0]['evidence'][0]['start_ms']) && !isset($result['items'][0]['evidence'][0]['end_ms']), 'unverified model offsets omitted, full audio only');
|
|
$expect($result['items'][0]['record_date'] === '2026-09-28', 'yesterday normalized from recorded_at');
|
|
$expect(substr($result['items'][0]['record_time'], 0, 5) === '21:00', 'temporal clock retained');
|
|
$expect(isset($events[0]['upstream_started_at']), 'durable intent before upload');
|
|
$expect($events[1]['upstream_file_id'] === 'mock-upload-success', 'upload ID checkpointed');
|
|
$ids = json_decode($events[2]['upstream_ids_json'], true);
|
|
$expect($ids['task_id'] === 'mock-task-success' && $ids['message_id'] === 'mock-message-success'
|
|
&& $ids['conversation_id'] === 'mock-conversation-success' && $ids['upstream_request_id'] === 'mock-request-success', 'all upstream IDs retained');
|
|
$requests = array_map(static fn (string $row): array => json_decode($row, true), file($directory . '/requests.jsonl', FILE_IGNORE_NEW_LINES));
|
|
$expect(count($requests) === 2 && $requests[0]['sha256'] === $fixture['sha256'] && $requests[0]['valid'] && $requests[1]['valid'], 'actual bytes uploaded via multipart and local_file same user');
|
|
$expect(!str_contains(json_encode($requests[1]['payload']), 'DO_NOT_SEND_EXPECTATIONS_TO_MODEL'), 'ground truth not in query');
|
|
foreach (['患者本人和家属', '客服/医生的问题与患者回答', '否定、纠正', '当前与历史用药', '未询问或不确定', '不得自行诊断'] as $guard) {
|
|
$expect(str_contains($requests[1]['payload']['query'], $guard), 'Actual wire prompt includes conservative semantic constraint: ' . $guard);
|
|
}
|
|
$expectError(static fn () => Dify::assertAudioPayload(['user' => 'u'], 'f'), 'AUDIO_ATTACHMENT_REQUIRED');
|
|
$expectError(static fn () => Dify::assertAudioPayload(['user' => 'u', 'files' => [['type' => 'image', 'transfer_method' => 'local_file', 'upload_file_id' => 'f']]], 'f'), 'AUDIO_ATTACHMENT_REQUIRED');
|
|
foreach (['upload_reject' => 'UPSTREAM_AUDIO_REJECTED', 'chat_reject' => 'UPSTREAM_AUDIO_REJECTED', 'wrong_type' => 'UPSTREAM_AUDIO_REJECTED',
|
|
'text_only' => 'AUDIO_NOT_PROCESSED', 'omitted' => 'AUDIO_NOT_PROCESSED', 'schema' => 'UPSTREAM_SCHEMA_INVALID',
|
|
'evidence' => 'UPSTREAM_SCHEMA_INVALID', 'extra_field' => 'UPSTREAM_SCHEMA_INVALID', 'bad_values' => 'UPSTREAM_SCHEMA_INVALID',
|
|
'bad_time' => 'UPSTREAM_SCHEMA_INVALID', 'markdown' => 'UPSTREAM_SCHEMA_INVALID'] as $scenario => $code) {
|
|
$expectError(static fn () => $adapter($scenario)->probe($wave, $fixture, 'qwen', $heartbeat), $code);
|
|
if ($scenario === 'wrong_type') {
|
|
$lastEvent = end($events);
|
|
$expect(($lastEvent['upstream_file_id'] ?? '') === 'mock-upload-wrong_type', 'rejected media file ID remains durable');
|
|
}
|
|
}
|
|
foreach (['unknown', 'malformed_response'] as $scenario) {
|
|
$expectError(static fn () => $adapter($scenario)->probe($wave, $fixture, 'openai', $heartbeat), 'UPSTREAM_UNCERTAIN', true);
|
|
if ($scenario === 'unknown') {
|
|
$lastEvent = end($events);
|
|
$failureIds = json_decode($lastEvent['upstream_ids_json'], true);
|
|
$expect($failureIds['task_id'] === 'mock-uncertain-task' && $failureIds['conversation_id'] === 'mock-uncertain-conversation', 'uncertain HTTP response IDs retained');
|
|
}
|
|
}
|
|
$requests = array_map(static fn (string $row): array => json_decode($row, true), file($directory . '/requests.jsonl', FILE_IGNORE_NEW_LINES));
|
|
foreach (['upload_reject' => 1, 'chat_reject' => 2, 'wrong_type' => 1, 'omitted' => 2, 'unknown' => 2] as $scenario => $count) {
|
|
$expect(count(array_filter($requests, static fn (array $request): bool => $request['scenario'] === $scenario)) === $count, 'no resend or text-only fallback ' . $scenario);
|
|
}
|
|
$localCalls = 0;
|
|
$testTransport = static function () use (&$localCalls): array { $localCalls++; return []; };
|
|
$provider = ['base_url' => 'http://127.0.0.1:' . $port . '/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]];
|
|
$gated = new Dify($testTransport, ['enabled' => false], $provider);
|
|
$expectError(static fn () => $gated->analyze([], $heartbeat), 'FEATURE_DISABLED');
|
|
$unverified = new Dify($testTransport, ['enabled' => true, 'audio_verified' => false], $provider);
|
|
$expectError(static fn () => $unverified->analyze([], $heartbeat), 'AUDIO_NOT_VERIFIED');
|
|
$notVerifiedProfile = new Dify($testTransport, ['verified_profiles' => ['qwen']] + $settings, $provider);
|
|
$expectError(static fn () => $notVerifiedProfile->analyze(['model_key' => 'openai'], $heartbeat), 'AUDIO_NOT_VERIFIED');
|
|
$guarded = new Dify($testTransport, $settings, $provider);
|
|
$expectError(static fn () => $guarded->analyze(['upstream_started_at' => 1, 'model_key' => 'qwen'], $heartbeat), 'RECONCILIATION_REQUIRED', true);
|
|
$expectError(static fn () => $guarded->probe($wave, $fixture + ['irrelevant' => 'x'], 'unsupported', $heartbeat), 'INVALID_PROFILE');
|
|
$badFixture = $fixture; $badFixture['sha256'] = str_repeat('0', 64);
|
|
$expectError(static fn () => $guarded->probe($wave, $badFixture, 'qwen', $heartbeat), 'AUDIO_INVALID');
|
|
$notSynthetic = $fixture; $notSynthetic['synthetic'] = false;
|
|
$expectError(static fn () => $guarded->probe($wave, $notSynthetic, 'qwen', $heartbeat), 'SYNTHETIC_FIXTURE_REQUIRED');
|
|
file_put_contents($wave, 'not audio');
|
|
$badFixture['sha256'] = hash_file('sha256', $wave);
|
|
$expectError(static fn () => $guarded->probe($wave, $badFixture, 'qwen', $heartbeat), 'AUDIO_INVALID');
|
|
file_put_contents($wave, $bytes);
|
|
$expectError(static fn () => $guarded->probe($wave, $fixture, 'qwen', static fn (): bool => false), 'LEASE_LOST');
|
|
$expect($localCalls === 0, 'disabled/unverified/reconciliation/invalid file/lease failures do not send');
|
|
$missing = new Dify($testTransport, $settings, []);
|
|
$expect($missing->configurationStatus('qwen')['code'] === 'CONFIG_MISSING', 'missing existing credentials gate is concrete');
|
|
$expectError(static fn () => $missing->probe($wave, $fixture, 'qwen', $heartbeat), 'CONFIG_MISSING');
|
|
$expected = ['canaries' => ['头部密码', '结尾密码'], 'facts' => [
|
|
['kind' => 'blood', 'record_date' => '2026-09-28', 'record_time' => '21:00', 'values' => ['systolic_pressure' => 126]],
|
|
['kind' => 'blood', 'record_date' => '2026-09-27', 'record_time' => '14:00', 'values' => ['postprandial_blood_sugar' => 7.2]],
|
|
['kind' => 'blood', 'record_date' => '2026-09-29', 'record_time' => '07:00', 'values' => ['systolic_pressure' => 147]],
|
|
]];
|
|
$probeResult = ['summary' => 'synthetic', 'transcript' => '头部密码到结尾密码', 'items' => $expected['facts']];
|
|
$expect(!in_array(false, FollowupAudioProbe::verifyExtraction($probeResult, $expected), true), 'probe accepts all temporal facts and audio-only canaries');
|
|
array_pop($probeResult['items']);
|
|
$expect(!FollowupAudioProbe::verifyExtraction($probeResult, $expected)['unique_tail_fact'], 'probe rejects truncated tail');
|
|
$probeResult['transcript'] = '头部密码';
|
|
$expect(!FollowupAudioProbe::verifyExtraction($probeResult, $expected)['audio_only_canaries'], 'probe rejects missing audio-only canary');
|
|
$expectError(static fn () => $adapter('timeout', ['request_timeout' => 1])->probe($wave, $fixture, 'qwen', $heartbeat), 'UPSTREAM_UNCERTAIN', true);
|
|
echo 'FOLLOWUP_AUDIO_DIFY assertions=' . $checks . ' PASS real_multipart=1 local_file=1 no_fallback=1 no_resend=1' . PHP_EOL;
|
|
} catch (\Throwable $failure) {
|
|
fwrite(STDERR, 'MOCK_DIAGNOSTIC ' . (string) @file_get_contents($directory . '/server.stderr') . PHP_EOL);
|
|
throw $failure;
|
|
} finally {
|
|
proc_terminate($process); proc_close($process);
|
|
foreach (glob($directory . '/*') ?: [] as $path) { if (is_file($path)) { unlink($path); } }
|
|
rmdir($directory);
|
|
}
|