109 lines
8.2 KiB
PHP
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);
|
|
}
|
|
}
|