Files
zyt/server/tests/FollowupAudioExtractionSchemaTest.php
T

136 lines
14 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioPipeline as Pipeline;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
use app\common\service\followupaudio\FollowupAudioFields as Fields;
use app\common\service\followupaudio\FollowupAudioException as Error;
new think\App();
$directory = sys_get_temp_dir() . '/fa-schema-' . bin2hex(random_bytes(6)); mkdir($directory, 0700);
$pdo = new PDO('sqlite:' . $directory . '/dictionary.sqlite');
$pdo->exec('CREATE TABLE zyt_dict_data(id INTEGER PRIMARY KEY,type_value TEXT,status INTEGER,sort INTEGER,name TEXT,value TEXT)');
$manager = new think\DbManager(); $manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => $directory . '/dictionary.sqlite', 'prefix' => 'zyt_']]]);
think\Container::getInstance()->instance('think\DbManager', $manager);
$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 = 'UPSTREAM_SCHEMA_INVALID') use ($expect): void {
try { $call(); } catch (Error $e) { $expect($e->errorCode === $code, 'expected ' . $code . ', got ' . $e->errorCode); return; }
throw new RuntimeException('Expected ' . $code);
};
try {
$segments = [['id' => 'asr_a', 'start_ms' => 0, 'end_ms' => 120000, 'text' => '患者:昨天早晨空腹血糖是六点一。'],
['id' => 'asr_b', 'start_ms' => 120000, 'end_ms' => 130000, 'text' => '患者:今天早餐吃了鸡蛋。']];
$transcript = implode("\n", array_column($segments, 'text')); $date = '2026-09-29 10:00:00';
$catalog = Fields::catalog(); $citations = Prompt::citations($segments);
$format = Pipeline::buildResponseFormat($catalog, $citations);
$assertDialect = static function (array $node) use (&$assertDialect, $expect): void {
if (array_key_exists('uniqueItems', $node)) { throw new RuntimeException('vLLM dialect cannot contain uniqueItems at any depth'); }
foreach ($node as $value) { if (is_array($value)) { $assertDialect($value); } }
};
$assertDialect($format); $expect(true, 'vLLM schema has no unsupported uniqueItems at any depth');
$schema = $format['json_schema']['schema']; $branches = $schema['properties']['items']['items']['anyOf'];
$expect($format['type'] === 'json_schema' && $format['json_schema']['strict'] === false, 'explicit sparse schema does not enable strict required-all-fields');
$expect(count($branches) === 5 && array_map(static fn ($branch) => $branch['properties']['kind']['enum'][0], $branches) === ['diagnosis', 'blood', 'diet', 'exercise', 'tracking_note'], 'five actual business kinds, never field names as kinds');
foreach ($branches as $branch) {
$expect($branch['additionalProperties'] === false && $branch['properties']['values']['additionalProperties'] === false
&& $branch['properties']['values']['minProperties'] === 1 && !isset($branch['properties']['values']['required']), 'unknown keys forbidden while unspoken values remain absent');
$expect($branch['properties']['evidence_ids']['items']['enum'] === array_column($citations, 'id'), 'only canonical citation IDs in schema');
}
$expect(!isset($branches[0]['properties']['values']['properties']['diagnosis_type']), 'empty dictionary does not invent an enum');
$expect($branches[0]['properties']['values']['properties']['age']['type'] === 'integer', 'age cannot be approximate numeric string');
$expect($branches[0]['properties']['values']['properties']['gender']['enum'] === [0, 1], 'option original scalar types retained');
$settings = ['providers' => ['qwen' => ['driver' => 'asr_then_llm', 'label' => 'Synthetic',
'asr' => ['base_url' => 'https://asr.invalid/v1', 'api_key' => 'synthetic-asr', 'model' => 'synthetic-asr'],
'extraction' => ['base_url' => 'https://llm.invalid/v1', 'api_key' => 'synthetic-llm', 'model' => 'synthetic-llm']]]];
$provider = Provider::resolve('qwen', $settings, []); $adapter = new Pipeline($settings, $provider);
$previousFingerprint = hash('sha256', json_encode([$provider['driver'], $provider['asr'], $provider['extraction'],
$provider['allow_loopback_tunnel'], $provider['chunk_seconds'], 'pcm-s16le-mono-16000-v1',
$provider['output_schema'], $provider['citation_policy']], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
$expect($provider['schema_dialect'] === 'vllm-json-schema-no-unique-items-v1'
&& !hash_equals($previousFingerprint, $provider['fingerprint']), 'schema dialect invalidates previous provider binding');
$verified = $settings + ['audio_verified' => true, 'verified_profiles' => ['qwen']];
$verified['providers']['qwen']['verified_fingerprint'] = $previousFingerprint;
$expect(!Provider::verified('qwen', $verified, []), 'old schema dialect verification cannot enable new requests');
$verified['providers']['qwen']['verified_fingerprint'] = $provider['fingerprint'];
$expect(Provider::verified('qwen', $verified, []), 'explicit current dialect verification succeeds');
$request = $adapter->extractRequest($transcript, $segments, $date, $catalog);
$expect($request['response_format'] === $format && $request['max_tokens'] === 8192 && !isset($request['chat_template_kwargs']), 'default production request is structured and portable');
$expect(str_contains($request['messages'][0]['content'], 'followup-audio-transcript-v2') && !isset($request['input_audio']), 'actual production V2 text prompt');
$expect($provider['output_schema'] === 'followup-audio-transcript-v2' && $provider['citation_policy'] === Prompt::CITATION_POLICY, 'fingerprint-visible explicit schema/citation policy');
foreach (['json_object', 'prompt_json'] as $mode) {
$modified = $settings; $modified['providers']['qwen']['extraction']['response_format'] = $mode;
$p = Provider::resolve('qwen', $modified, []); $r = (new Pipeline($modified, $p))->extractRequest($transcript, $segments, $date, $catalog);
$expect($p['fingerprint'] !== $provider['fingerprint'] && ($mode === 'prompt_json' ? !isset($r['response_format']) : $r['response_format'] === ['type' => 'json_object']), 'explicit alternate mode bound, not fallback');
}
foreach ([true, false] as $thinking) {
$modified = $settings; $modified['providers']['qwen']['extraction']['enable_thinking'] = $thinking;
$p = Provider::resolve('qwen', $modified, []); $r = (new Pipeline($modified, $p))->extractRequest($transcript, $segments, $date, $catalog);
$expect($p['fingerprint'] !== $provider['fingerprint'] && $r['chat_template_kwargs'] === ['enable_thinking' => $thinking], 'explicit thinking boolean bound and sent');
}
$modified = $settings; $modified['providers']['qwen']['extraction']['max_tokens'] = 4096;
$expect(Provider::resolve('qwen', $modified, [])['fingerprint'] !== $provider['fingerprint'], 'token bound changes identity');
foreach (['response_format' => 'automatic', 'max_tokens' => 8193, 'enable_thinking' => 'true'] as $key => $value) {
$modified = $settings; $modified['providers']['qwen']['extraction'][$key] = $value;
$reject(static fn () => Provider::resolve('qwen', $modified, []), 'CONFIG_INVALID');
}
$raw = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成摘要', 'uncertainties' => [], 'items' => [[
'kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.1], 'record_date' => '2026-09-28', 'record_time' => null,
'date_text' => '昨天', 'time_text' => '早晨', 'time_period' => '早晨', 'time_estimated' => true, 'needs_review' => true,
'evidence_ids' => [$citations[0]['id']],
]]];
$validate = static fn (array $answer, ?array $source = null): array => Pipeline::validateAnswer(json_encode($answer, JSON_UNESCAPED_UNICODE),
$source === null ? $transcript : implode("\n", array_column($source, 'text')), $source ?? $segments, $date, $catalog);
$normalized = $validate($raw);
$expect(\app\common\service\followupaudio\FollowupAudioPolicy::canonical($normalized['items'][0]['evidence'][0]) === \app\common\service\followupaudio\FollowupAudioPolicy::canonical(['text' => $segments[0]['text'], 'start_ms' => 0, 'end_ms' => 120000, 'segment_id' => 'asr_a', 'position_type' => 'segment']), 'server resolves evidence text+segment offsets exactly');
$expect($normalized['transcript'] === $transcript && $normalized['items'][0]['selected'] === false && $normalized['items'][0]['needs_review'], 'canonical transcript and review preserved');
foreach ([['c_' . str_repeat('0', 24)], [], [$citations[0]['id'], $citations[0]['id']]] as $ids) {
$bad = $raw; $bad['items'][0]['evidence_ids'] = $ids; $reject(static fn () => $validate($bad));
}
$changed = $segments; $changed[0]['text'] .= '不同的后续语句。'; $reject(static fn () => $validate($raw, $changed));
$changed = $segments; $changed[0]['start_ms'] = 1; $reject(static fn () => $validate($raw, $changed));
$bad = $raw; $bad['items'][0]['evidence'] = [['text' => '省略...不允许']]; $reject(static fn () => $validate($bad));
$bad = $raw; $bad['items'][0]['kind'] = 'fasting_blood_sugar'; $reject(static fn () => $validate($bad));
$bad = $raw; $bad['items'][0]['values'] = ['fasting_blood_sugar' => '6.1']; $reject(static fn () => $validate($bad));
$bad = $raw; $bad['items'][0]['values'] = ['patient_id' => 101]; $reject(static fn () => $validate($bad));
foreach (['约七十', '70'] as $age) { $bad = $raw; $bad['items'][0]['kind'] = 'diagnosis'; $bad['items'][0]['values'] = ['age' => $age]; $reject(static fn () => $validate($bad)); }
foreach (['1', 9] as $gender) { $bad = $raw; $bad['items'][0]['kind'] = 'diagnosis'; $bad['items'][0]['values'] = ['gender' => $gender]; $reject(static fn () => $validate($bad)); }
$bad = $raw; $bad['items'][0]['kind'] = 'diagnosis'; $bad['items'][0]['values'] = ['local_hospital_diagnosis' => ['invented']]; $reject(static fn () => $validate($bad));
$duplicates = $raw; $duplicates['items'][0]['kind'] = 'diagnosis';
$duplicates['items'][0]['values'] = ['local_hospital_diagnosis' => ['糖尿病', '糖尿病']];
$reject(static fn () => $validate($duplicates));
$duplicateIds = $raw; $duplicateIds['items'][0]['evidence_ids'] = [$citations[0]['id'], $citations[0]['id']];
$reject(static fn () => $validate($duplicateIds));
$bad = $raw; unset($bad['items'][0]['record_date']); $reject(static fn () => $validate($bad));
$bad = $raw; $bad['audio_processed'] = true; $reject(static fn () => $validate($bad));
$reject(static fn () => Pipeline::validateAnswer(json_encode($raw), 'mismatching transcript', $segments, $date, $catalog));
$legacy = $raw; $legacy['schema_version'] = 'followup-audio-transcript-v1'; unset($legacy['items'][0]['evidence_ids']);
$legacy['items'][0]['evidence'] = [['segment_id' => 'asr_a', 'text' => $segments[0]['text']]];
$expect($validate($legacy)['items'][0]['values']['fasting_blood_sugar'] === 6.1, 'retained valid V1 answers stay readable');
$wrongShape = str_replace('"uncertainties":[]', '"uncertainties":{}', json_encode($raw));
$reject(static fn () => Pipeline::validateAnswer($wrongShape, $transcript, $segments, $date, $catalog));
$export = getenv('FOLLOWUP_AUDIO_SCHEMA_EXPORT');
if (is_string($export) && $export !== '') {
$cases = [['name' => 'valid_sparse', 'valid' => true, 'answer' => $raw]];
foreach (['diet' => ['breakfast' => '合成早餐'], 'exercise' => ['duration' => 20], 'diagnosis' => ['gender' => 0], 'tracking_note' => ['content' => '合成记录']] as $kind => $values) {
$example = $raw; $example['items'][0]['kind'] = $kind; $example['items'][0]['values'] = $values;
$cases[] = ['name' => 'valid_' . $kind, 'valid' => true, 'answer' => $example];
}
foreach (['unknown_kind' => ['kind' => 'fasting_blood_sugar'], 'foreign_field' => ['values' => ['patient_id' => 101]],
'string_number' => ['values' => ['fasting_blood_sugar' => '6.1']], 'empty_values' => ['values' => new stdClass()],
'unknown_citation' => ['evidence_ids' => ['c_' . str_repeat('0', 24)]], 'unasked_null' => ['values' => ['fasting_blood_sugar' => null]],
'model_quote' => ['evidence' => [['text' => '合成...']]]] as $name => $mutation) {
$example = $raw; $example['items'][0] = array_replace($example['items'][0], $mutation);
$cases[] = ['name' => $name, 'valid' => false, 'answer' => $example];
}
// Dialect grammar cannot enforce uniqueness; server rejection above is the acceptance boundary.
$cases[] = ['name' => 'duplicate_ids_schema_only', 'valid' => true, 'server_valid' => false, 'answer' => $duplicateIds];
$cases[] = ['name' => 'duplicate_values_schema_only', 'valid' => true, 'server_valid' => false, 'answer' => $duplicates];
file_put_contents($export, json_encode(['schema' => $schema, 'cases' => $cases], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
echo 'FOLLOWUP_AUDIO_EXTRACTION_SCHEMA assertions=' . $checks . ' PASS actual_v2_request=1 per_kind_sparse_schema=1 server_citations=1 no_value_coercion=1 fingerprint_modes=1 portable_thinking=1 legacy_v1=1 vllm_dialect=1 duplicate_server_rejection=1 network_calls=0' . PHP_EOL;
} finally { unlink($directory . '/dictionary.sqlite'); rmdir($directory); }