'left_0', 'start_ms' => 0, 'end_ms' => 15000, 'channel' => 0, 'text' => '今天七点四十五分收缩压一百二十。'], ['id' => 'right_0', 'start_ms' => 0, 'end_ms' => 15000, 'channel' => 1, 'text' => '昨天先联系过。'], ['id' => 'left_1', 'start_ms' => 15000, 'end_ms' => 30000, 'channel' => 0, 'text' => '', 'source' => 'local_silence'], ['id' => 'right_1', 'start_ms' => 15000, 'end_ms' => 30000, 'channel' => 1, 'text' => '合成接续原文。'], ]; $transcript = implode("\n", array_column($segments, 'text')); $citations = Prompt::citations($segments); $check('overlapping_two_channels_are_valid', count($citations) === 3); $check('channel_time_metadata_preserved', ($citations[0]['channel'] ?? null) === 0 && ($citations[1]['channel'] ?? null) === 1 && ($citations[0]['start_ms'] ?? null) === 0 && ($citations[0]['end_ms'] ?? null) === 15000); $check('no_fictional_speaker_prefix', $citations[0]['text'] === $segments[0]['text'] && $citations[1]['text'] === $segments[1]['text']); $check('local_silence_has_no_citation', !in_array('left_1', array_column($citations, 'segment_id'), true)); $changed = [$segments[0]]; $changed[0]['channel'] = 1; $check('citation_id_binds_channel', Prompt::citations($changed)[0]['id'] !== $citations[0]['id']); $mono = [$segments[0]]; unset($mono[0]['channel']); $monoCitation = Prompt::citations($mono)[0]; $identity = hash('sha256', json_encode([$mono[0]['id'], 0, 15000, $mono[0]['text']], JSON_THROW_ON_ERROR)); $legacyId = 'c_' . substr(hash('sha256', Prompt::CITATION_POLICY . ':' . $identity . ':0'), 0, 32); $check('mono_citation_identity_unchanged', $monoCitation['id'] === $legacyId && !array_key_exists('channel', $monoCitation)); $quote = ['segment_id' => 'left_0', 'text' => $segments[0]['text'], 'start_ms' => 0, 'end_ms' => 15000, 'position_type' => 'segment', 'channel' => 0]; $item = ['kind' => 'blood', 'values' => ['systolic_pressure' => 120], 'record_date' => '2026-03-01', 'record_time' => '07:45', 'date_text' => '今天', 'time_text' => '七点四十五分', 'time_period' => null, 'time_estimated' => false, 'needs_review' => false, 'evidence' => [$quote]]; $input = ['transcript' => $transcript, 'summary' => '', 'uncertainties' => [], 'items' => [$item], 'transcript_segments' => $segments]; $normalized = Policy::normalizeExtraction($input, '2026-03-01 10:00:00'); $check('channel_always_requires_review', $normalized['items'][0]['needs_review'] && !$normalized['items'][0]['selected']); $check('channel_evidence_preserved', ($normalized['items'][0]['evidence'][0]['channel'] ?? null) === 0); $check('silent_channel_segment_retained', $normalized['transcript_segments'][2] === $segments[2]); $check('renormalization_preserves_channel', Policy::normalizeExtraction($normalized, '2026-03-01 10:00:00') === $normalized); $monoInput = $input; $monoInput['transcript_segments'] = $mono; $monoInput['transcript'] = $mono[0]['text']; unset($monoInput['items'][0]['evidence'][0]['channel']); $monoResult = Policy::normalizeExtraction($monoInput, '2026-03-01 10:00:00'); $check('mono_does_not_gain_channel_or_forced_review', !$monoResult['items'][0]['needs_review'] && !array_key_exists('channel', $monoResult['items'][0]['evidence'][0])); $monoInput['items'][0]['evidence'][0]['channel'] = 0; $check('mono_rejects_fabricated_channel', Policy::normalizeExtraction($monoInput, '2026-03-01 10:00:00')['items'] === []); foreach (['wrong_channel' => 1, 'string_channel' => '0', 'invalid_channel' => 2, 'null_channel' => null] as $name => $bad) { $copy = $input; $copy['items'][0]['evidence'][0]['channel'] = $bad; $actual = Policy::normalizeExtraction($copy, '2026-03-01 10:00:00'); $check('reject_evidence_' . $name, $actual['items'] === []); } $copy = $input; unset($copy['items'][0]['evidence'][0]['channel']); $check('reject_evidence_missing_channel', Policy::normalizeExtraction($copy, '2026-03-01 10:00:00')['items'] === []); $invalid = []; $invalid['same_channel_overlap'] = [$segments[0], array_replace($segments[2], ['start_ms' => 14000])]; $invalid['global_order_reversed'] = [$segments[1], $segments[0]]; $invalid['same_channel_reverse'] = [$segments[2], $segments[0]]; $invalid['mixed_channel_presence'] = [$segments[0], array_replace($mono[0], ['id' => 'missing_channel'])]; $invalid['non_integer_channel'] = [array_replace($segments[0], ['channel' => '0'])]; $invalid['unsupported_channel'] = [array_replace($segments[0], ['channel' => 2])]; $invalid['null_channel'] = [array_replace($segments[0], ['channel' => null])]; foreach ($invalid as $name => $bad) { $rejected = false; try { Prompt::citations($bad); } catch (DomainException $error) { $rejected = true; } $check('prompt_reject_' . $name, $rejected); $rejected = false; try { Policy::normalizeExtraction(array_replace($input, ['transcript_segments' => $bad, 'items' => []]), '2026-03-01 10:00:00'); } catch (DomainException $error) { $rejected = true; } $check('policy_reject_' . $name, $rejected); } $catalog = ['blood' => [['key' => 'systolic_pressure', 'type' => 'number']]]; $prompt = Prompt::build($transcript, $segments, '2026-03-01 10:00:00', $catalog); foreach (['左右声道', '角色未知', '不能默认', '不是准确话轮', '可能交叠', 'needs_review=true', '静音不代表否认'] as $rule) { $check('prompt_rule_' . $rule, str_contains($prompt, $rule)); } // Production validation integration can be separately delayed until the backend peer installs channel resolution. if (!in_array('--policy-only', $argv, true)) { unset($item['evidence']); $item['evidence_ids'] = [$citations[0]['id']]; $raw = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成通道', 'uncertainties' => [], 'items' => [$item]]; $actual = Pipeline::validateAnswer(json_encode($raw, JSON_THROW_ON_ERROR), $transcript, $segments, '2026-03-01 10:00:00', $catalog); $check('production_resolves_canonical_channel', ($actual['items'][0]['evidence'][0]['channel'] ?? null) === 0); $check('production_forces_review', ($actual['items'][0]['needs_review'] ?? false) === true); $raw['items'][0]['channel'] = 1; $rejected = false; try { Pipeline::validateAnswer(json_encode($raw, JSON_THROW_ON_ERROR), $transcript, $segments, '2026-03-01 10:00:00', $catalog); } catch (\Throwable $error) { $rejected = true; } $check('production_rejects_model_channel', $rejected); } $failed = array_keys(array_filter($checks, static fn (bool $pass): bool => !$pass)); echo json_encode(['suite' => 'followup-audio-stereo-quality', 'synthetic_only' => true, 'transport_calls' => 0, 'checks' => $checks, 'passed' => count($checks) - count($failed), 'total' => count($checks), 'failures' => $failed], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL; exit($failed === [] ? 0 : 1);