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)); 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); }