Files
zyt/server/tests/FollowupAudioProbeCommandTest.php
T

109 lines
8.2 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service\followupaudio {
/** Command-state test double. Actual HTTP/audio behavior is covered by FollowupAudioDifyTest. */
final class FollowupAudioDify {
public static string $mode = 'uncertain';
public static string $fingerprint = '1111111111111111111111111111111111111111111111111111111111111111';
public static int $calls = 0;
public function configurationStatus(string $profile): array { return ['configured' => true, 'profile' => $profile, 'code' => 'OK', 'application_fingerprint' => self::$fingerprint]; }
public function probe(string $path, array $fixture, string $profile, callable $heartbeat): array {
self::$calls++;
$heartbeat(['stage' => 'uploading', 'upstream_started_at' => time(), 'upstream_ids_json' => '{"request_id":"synthetic-request"}']);
if (self::$mode === 'uncertain') { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
return ['summary' => 'synthetic', 'transcript' => 'canary-start canary-end PRIVATE_TRANSCRIPT_NEVER_PRINTED', 'items' => $fixture['expected']['facts']];
}
}
}
namespace {
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\command\FollowupAudioProbe as Probe;
use think\console\Input;
use think\console\Output;
new think\App(); // No initialization, real config or DB.
$safeConfig = new think\Config(); think\Container::getInstance()->instance('config', $safeConfig);
$directory = sys_get_temp_dir() . '/followup-audio-probe-state-' . bin2hex(random_bytes(6));
mkdir($directory, 0700, true);
$directory = realpath($directory);
$manifest = ['synthetic' => true, 'generator' => 'followup-audio-synthetic-v1', 'recorded_at' => '2026-09-29 10:00:00', 'fixtures' => []];
foreach (['short' => 60, 'medium' => 900, 'long' => 3598] as $case => $duration) {
$path = $directory . '/' . $case . '.mp3';
file_put_contents($path, 'synthetic-command-state-test-double-' . $case);
$manifest['fixtures'][] = ['case' => $case, 'file' => $case . '.mp3', 'sha256' => hash_file('sha256', $path),
'duration_seconds' => $duration, 'expected' => ['canaries' => ['canary-start', 'canary-end'], '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]],
]]];
}
file_put_contents($directory . '/manifest.json', json_encode($manifest));
$checks = 0;
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
$run = static function (string $profile, bool $synthetic = true) use ($directory): array {
$input = new Input(array_merge($synthetic ? ['--synthetic'] : [], ['--manifest', $directory . '/manifest.json', '--profile', $profile]));
$output = new Output('buffer');
$code = (new Probe())->run($input, $output);
return [$code, $output->fetch()];
};
try {
$expected = $manifest['fixtures'][0]['expected'];
$expected['canaries'] = ['紫色海豚金色河流绿色蜻蜓', '银色树叶紫色海豚红色石榴'];
$result = ['summary' => 'synthetic', 'transcript' => '紫色海豚、金色河流,绿色蜻蜓。银色树叶,紫色海豚,红色石榴。', 'items' => $expected['facts']];
$expect(!in_array(false, Probe::verifyExtraction($result, $expected), true), 'ASR punctuation does not change audio-only canary words');
$changed = $result; $changed['transcript'] = str_replace('石榴', '石流', $changed['transcript']);
$expect(!Probe::verifyExtraction($changed, $expected)['audio_only_canaries'], 'wrong spoken-word recognition still fails');
$changed = $result; $changed['items'][0]['record_date'] = '2026-09-27';
$expect(!Probe::verifyExtraction($changed, $expected)['historical_temporal_facts'], 'wrong relative date still fails');
$changed = $result; array_pop($changed['items']);
$expect(!Probe::verifyExtraction($changed, $expected)['unique_tail_fact'], 'missing last audio fact still fails');
[$code, $stdout] = $run('qwen', false);
$expect($code === 1 && Dify::$calls === 0 && str_contains($stdout, 'SYNTHETIC_ACK_REQUIRED'), 'explicit synthetic acknowledgement required');
[$code, $stdout] = $run('qwen');
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'needs_reconciliation'), 'unknown result stops next cases');
$report = json_decode(file_get_contents($directory . '/probe-qwen-' . Dify::$fingerprint . '.json'), true);
$expect($report['cases']['short']['upstream_started_at'] > 0, 'intent persisted before request');
[$code, $stdout] = $run('qwen');
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'resume never recharges unknown result');
$currentQwenPath = $directory . '/probe-qwen-' . Dify::$fingerprint . '.json';
$legacyQwenPath = $directory . '/probe-qwen.json';
rename($currentQwenPath, $legacyQwenPath);
$legacyBytes = file_get_contents($legacyQwenPath);
[$code, $stdout] = $run('qwen');
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'matching legacy identity remains authoritative for unknown requests');
$expect(file_get_contents($legacyQwenPath) === $legacyBytes, 'legacy unknown evidence retained unchanged');
$legacyReport = json_decode($legacyBytes, true);
$legacyReport['configuration']['application_fingerprint'] = hash('sha256', "https://synthetic.invalid/v1\0synthetic-key");
file_put_contents($legacyQwenPath, json_encode($legacyReport));
$safeConfig->set(['providers' => ['qwen' => ['driver' => 'dify', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key']]], 'followup_audio');
[$code, $stdout] = $run('qwen');
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'legacy Dify hash upgrade cannot resubmit same-app unknown');
$safeConfig->set([], 'followup_audio');
Dify::$mode = 'success';
[$code, $stdout] = $run('openai');
$expect($code === 0 && Dify::$calls === 4, 'three lengths passed sequentially');
$bytes = file_get_contents($directory . '/probe-openai-' . Dify::$fingerprint . '.json');
$report = json_decode($bytes, true);
$expect($report['audio_verified'] && count($report['cases']) === 3, 'all three required before verification report');
$expect(!str_contains($bytes . $stdout, 'PRIVATE_TRANSCRIPT_NEVER_PRINTED'), 'no transcript in output or report');
[$code, $stdout] = $run('openai');
$expect($code === 0 && Dify::$calls === 4 && substr_count($stdout, 'retained_passed') === 3, 'passed gate results reused without resending');
$oldPath = $directory . '/probe-openai-' . Dify::$fingerprint . '.json';
Dify::$fingerprint = '2222222222222222222222222222222222222222222222222222222222222222';
[$code, $stdout] = $run('openai');
$expect($code === 0 && Dify::$calls === 7 && !str_contains($stdout, 'retained_passed'), 'new application executes its own gate');
$expect(file_get_contents($oldPath) === $bytes, 'application mismatch leaves original evidence unchanged');
file_put_contents($directory . '/short.mp3', 'tampered');
[$code, $stdout] = $run('qwen');
$expect($code === 1 && Dify::$calls === 7 && str_contains($stdout, 'SYNTHETIC_FIXTURE_REQUIRED'), 'tampered fixture cannot run');
echo 'FOLLOWUP_AUDIO_PROBE_COMMAND assertions=' . $checks . ' PASS durable_no_resend=1 retained_results=1 app_identity=1' . PHP_EOL;
} finally {
foreach (glob($directory . '/*') ?: [] as $file) { if (is_file($file)) { unlink($file); } }
rmdir($directory);
}
}