feat: preserve stereo channels and require reviewed speaker roles
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Synthetic channel metadata only: no audio, ASR, model transport or application database. */
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
|
||||
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
|
||||
use app\common\service\followupaudio\FollowupAudioPipeline as Pipeline;
|
||||
if (($override = getenv('FOLLOWUP_AUDIO_PROMPT_OVERRIDE')) !== false && $override !== '') { require $override; }
|
||||
if (($override = getenv('FOLLOWUP_AUDIO_POLICY_OVERRIDE')) !== false && $override !== '') { require $override; }
|
||||
$checks = [];
|
||||
$check = static function (string $name, bool $pass) use (&$checks): void { $checks[$name] = $pass; };
|
||||
$segments = [
|
||||
['id' => '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);
|
||||
Reference in New Issue
Block a user