104 lines
8.5 KiB
PHP
104 lines
8.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
/** Synthetic local-only admission and citation boundary checks. No actual ASR/LLM/DB calls. */
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
use app\common\service\followupaudio\FollowupAudioPipeline as Pipeline;
|
|
use app\common\service\followupaudio\FollowupAudioPipelineCheckpoint as Checkpoint;
|
|
use app\common\service\followupaudio\FollowupAudioPipelineMedia as Media;
|
|
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
|
|
use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
|
|
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 $error) { $expect($error->errorCode === $code, 'expected ' . $code . ', got ' . $error->errorCode); return; }
|
|
throw new RuntimeException('Expected ' . $code);
|
|
};
|
|
$settings = ['asr_chunk_seconds' => 1, 'providers' => ['qwen' => ['driver' => 'asr_then_llm', 'label' => 'Synthetic',
|
|
'asr' => ['base_url' => 'https://asr.invalid/v1', 'api_key' => 'synthetic', 'model' => 'synthetic-asr'],
|
|
'extraction' => ['base_url' => 'https://llm.invalid/v1', 'api_key' => 'synthetic', 'model' => 'synthetic-llm']]]];
|
|
$provider = Provider::resolve('qwen', $settings, []);
|
|
$directory = sys_get_temp_dir() . '/fa-limits-' . bin2hex(random_bytes(6)); mkdir($directory, 0700);
|
|
$makeWave = static function (int $seconds) use ($directory): string {
|
|
$path = $directory . '/' . $seconds . '.wav'; $dataBytes = $seconds * 32000;
|
|
$handle = fopen($path, 'xb'); chmod($path, 0600);
|
|
fwrite($handle, 'RIFF' . pack('V', 36 + $dataBytes) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16)
|
|
. 'data' . pack('V', $dataBytes));
|
|
fwrite($handle, pack('v', 1)); // One nonzero LSB must still reach ASR; not a silence/energy threshold.
|
|
fseek($handle, 44 + $dataBytes - 1); fwrite($handle, "\0"); fclose($handle);
|
|
return $path;
|
|
};
|
|
$taskFor = static fn (string $path, float $duration): array => ['id' => 1, 'sha256' => hash_file('sha256', $path),
|
|
'duration_seconds' => $duration, 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen', 'upstream_started_at' => 0,
|
|
'upstream_ids_json' => json_encode(['provider_fingerprint' => $provider['fingerprint']])];
|
|
try {
|
|
$expect(Prompt::MAX_SEGMENTS === 1000 && Prompt::MAX_CITATIONS === 20, 'cost limits not raised');
|
|
$expect(Media::assertChunkPlan(1000, 1000) === 1000 && Media::assertChunkPlan(3000, 3000) === 1000, 'exact segment boundary admitted');
|
|
$expect(Media::assertChunkPlan(3600, 120000) === 30 && Media::assertChunkPlan(3600, 4000) === 900, 'normal full-hour plans stay allowed');
|
|
foreach ([[1001, 1000], [1000.001, 1000], [3000.001, 3000], [3600, 3000]] as [$duration, $chunkMs]) {
|
|
$reject(static fn () => Media::assertChunkPlan($duration, $chunkMs), 'ASR_SEGMENT_LIMIT');
|
|
}
|
|
$path1000 = $makeWave(1000); $path1001 = $makeWave(1001);
|
|
$task1000 = $taskFor($path1000, 1000); $task1001 = $taskFor($path1001, 1001);
|
|
$plan = Checkpoint::initial($task1000, $provider['fingerprint'], 1000);
|
|
$expect(count($plan['chunks']) === Prompt::MAX_SEGMENTS, 'Checkpoint accepts exactly1000 chunks');
|
|
$plan['revision'] = 1; Checkpoint::validate($plan, $task1000);
|
|
$reject(static fn () => Checkpoint::initial($task1001, $provider['fingerprint'], 1000), 'ASR_SEGMENT_LIMIT');
|
|
$legacy = $plan; $legacy['source_sha256'] = $task1001['sha256']; $legacy['duration_ms'] = 1001000;
|
|
$legacy['chunks'][] = ['id' => 'asr-' . hash('sha256', $task1001['sha256'] . ':' . $provider['fingerprint'] . ':1000000:1001000'),
|
|
'start_ms' => 1000000, 'end_ms' => 1001000, 'state' => 'pending'];
|
|
$reject(static fn () => Checkpoint::validate($legacy, $task1001), 'ASR_SEGMENT_LIMIT');
|
|
$calls = 0; $intents = 0; $saved = [];
|
|
$heartbeat = static function (array $fields) use (&$intents, &$saved): bool {
|
|
if (isset($fields['upstream_started_at'])) { $intents++; }
|
|
if (isset($fields['pipeline_checkpoint'])) { $saved = $fields['pipeline_checkpoint']['state']; }
|
|
return true;
|
|
};
|
|
$transport = static function (array $spec) use (&$calls, $expect): array {
|
|
$calls++; $expect($spec['stage'] === 'asr', 'only the allowed first ASR admission reaches injected transport');
|
|
return ['http_code' => 429, 'errno' => 0, 'body' => '{}']; // Deliberately stop after one admitted synthetic chunk.
|
|
};
|
|
$pipeline = new Pipeline($settings, $provider, $transport);
|
|
$reject(static fn () => $pipeline->run($path1001, $task1001, $heartbeat), 'ASR_SEGMENT_LIMIT');
|
|
$expect($calls === 0 && $intents === 0 && $saved === [], '1001 rejects before every transport/intent/checkpoint');
|
|
$reject(static fn () => $pipeline->run($path1001, $task1001, $heartbeat, $legacy), 'ASR_SEGMENT_LIMIT');
|
|
$expect($calls === 0 && $intents === 0 && $saved === [], 'oversized retained state cannot resume network');
|
|
$media = new Media($settings);
|
|
$reject(static fn () => $media->inspect($path1001, $task1001['sha256']), 'ASR_SEGMENT_LIMIT');
|
|
$misdeclared = $task1001; $misdeclared['duration_seconds'] = 1000;
|
|
$reject(static fn () => $pipeline->run($path1001, $misdeclared, $heartbeat), 'ASR_SEGMENT_LIMIT');
|
|
$expect($calls === 0 && $intents === 0, 'actual media duration prevents declared-duration bypass');
|
|
$reject(static fn () => $pipeline->run($path1000, $task1000, $heartbeat), 'ASR_REJECTED');
|
|
$expect($calls === 1 && $intents === 1 && count($saved['chunks']) === 1000, '1000 admits first synthetic request; no thousand-call cost incurred by test');
|
|
$expect(hash_file('sha256', $path1000) === $task1000['sha256'] && hash_file('sha256', $path1001) === $task1001['sha256'], 'original synthetic source bytes immutable');
|
|
|
|
$segments = [];
|
|
for ($i = 0; $i <= Prompt::MAX_CITATIONS; $i++) {
|
|
$segments[] = ['id' => 'segment_' . $i, 'start_ms' => $i * 1000, 'end_ms' => ($i + 1) * 1000,
|
|
'text' => '合成片段' . $i . ',收缩压一百二十,日期未说明。'];
|
|
}
|
|
$transcript = implode("\n", array_column($segments, 'text')); $citations = Prompt::citations($segments);
|
|
$catalog = ['blood' => [['key' => 'systolic_pressure', 'type' => 'number']]];
|
|
$answer = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成边界', 'uncertainties' => [], 'items' => [[
|
|
'kind' => 'blood', 'values' => ['systolic_pressure' => 120], 'record_date' => null, 'record_time' => null,
|
|
'date_text' => '', 'time_text' => '', 'time_period' => null, 'time_estimated' => false, 'needs_review' => true,
|
|
'evidence_ids' => array_slice(array_column($citations, 'id'), 0, Prompt::MAX_CITATIONS),
|
|
]]];
|
|
foreach (['json_schema', 'json_object', 'prompt_json'] as $mode) {
|
|
$options = $settings; $options['providers']['qwen']['extraction']['response_format'] = $mode;
|
|
$resolved = Provider::resolve('qwen', $options, []);
|
|
$payload = (new Pipeline($options, $resolved))->extractRequest($transcript, $segments, '2026-09-29 10:00:00', $catalog);
|
|
$expect(str_contains($payload['messages'][0]['content'], '最多20个') && !str_contains($payload['messages'][0]['content'], '最多30个'), 'prompt boundary shared in ' . $mode);
|
|
if ($mode === 'json_schema') {
|
|
$expect($payload['response_format']['json_schema']['schema']['properties']['items']['items']['anyOf'][0]['properties']['evidence_ids']['maxItems'] === Prompt::MAX_CITATIONS, 'schema uses shared citation cap');
|
|
}
|
|
$result = Pipeline::validateAnswer(json_encode($answer, JSON_UNESCAPED_UNICODE), $transcript, $segments, '2026-09-29 10:00:00', $catalog);
|
|
$expect(count($result['items'][0]['evidence']) === Prompt::MAX_CITATIONS && $result['items'][0]['needs_review'], 'exact citation boundary accepted without reducing review');
|
|
$over = $answer; $over['items'][0]['evidence_ids'] = array_column($citations, 'id');
|
|
$reject(static fn () => Pipeline::validateAnswer(json_encode($over, JSON_UNESCAPED_UNICODE), $transcript, $segments, '2026-09-29 10:00:00', $catalog), 'UPSTREAM_SCHEMA_INVALID');
|
|
}
|
|
echo 'FOLLOWUP_AUDIO_PIPELINE_LIMITS assertions=' . $checks . ' PASS segments1000_admitted=1 segments1001_transport_calls=0 oversized_intents=0 actual_media_guard=1 citation20=1 citation21_rejected=1 formats=3 live_model_calls=0' . PHP_EOL;
|
|
} finally { foreach (glob($directory . '/*') as $file) { unlink($file); } rmdir($directory); }
|