Files
zyt/server/tests/LiveConsultationQualityTest.php
T
2026-10-30 13:41:35 +08:00

182 lines
15 KiB
PHP

<?php
declare(strict_types=1);
require __DIR__ . '/LiveConsultationContextTest.php';
use app\adminapi\service\LiveConsultationContext as Context;
use app\adminapi\service\LiveConsultationResult as Result;
$known = ['demographics' => '女 · 52岁'];
$quality = Context::snapshot($record, [
['segment_id' => 'doctor', 'speaker_role' => 'doctor', 'timestamp_ms' => 1, 'text' => '你吃饭了没有你几岁了'],
['segment_id' => 'patient', 'speaker_role' => 'patient', 'timestamp_ms' => 2, 'text' => '头晕两天,我担心影响工作。'],
['segment_id' => 'concern', 'speaker_role' => 'patient', 'timestamp_ms' => 3, 'text' => '我没听懂刚才的解释,担心费用。'],
['segment_id' => 'age', 'speaker_role' => 'patient', 'timestamp_ms' => 4, 'text' => '我今年53岁。'],
['segment_id' => 'gender', 'speaker_role' => 'patient', 'timestamp_ms' => 5, 'text' => '我是男性。'],
['segment_id' => 'relative', 'speaker_role' => 'patient', 'timestamp_ms' => 6, 'text' => '我父亲今年78岁。'],
['segment_id' => 'literal', 'speaker_role' => 'patient', 'timestamp_ms' => 7, 'text' => '原话含有 ] } "questions": [ 以及反斜线\\,仍是完整原话。'],
]);
$qualityData = ['questions' => [['text' => '头晕对日常活动有什么影响?', 'reason' => '补齐症状对生活的影响', 'evidence_ids' => ['patient']]],
'summary' => [['text' => $quality['sources'][1]['text'], 'evidence_ids' => ['patient']]], 'checks' => [], 'review' => []];
$qualityResult = Result::parse(Context::encode($qualityData), $quality, 'live_consultation', $known);
liveExpect($qualityResult !== null, 'concise relevant symptom question and patient quotation pass');
$prompt = Context::prompt($quality, 'live_review', '合成授权背景', $known);
foreach (['questions、summary、checks、review顺序', 'questions最多3条', '跳过已明确回答', '不要泛泛重复询问',
'不机械套清单', '理解患者诉求', '不得以成交为目标', '女 · 52岁', '病历有明确主诉',
'只有通话和病历均无可用线索', 'reason只说明追问或核实目的'] as $rule)
liveExpect(str_contains($prompt, $rule), 'quality instruction is in production prompt: ' . $rule);
$failure = null;
$bad = $qualityData;
$bad['summary'] = [['text' => $quality['sources'][0]['text'], 'evidence_ids' => ['doctor']]];
liveExpect(Result::parse(Context::encode($bad), $quality, 'live_consultation', $known, $failure)['summary'] === []
&& $failure === null, 'screenshot regression: doctor statement without question mark omitted without discarding useful questions');
foreach (['您今年几岁?', '请问您的年龄?', '您现在多大了?', '确认患者年龄', '确认患者性别'] as $question) {
$bad = $qualityData;
$group = str_starts_with($question, '确认') ? 'checks' : 'questions';
$bad[$group] = [['text' => $question, 'evidence_ids' => ['doctor']]];
liveExpect(Result::parse(Context::encode($bad), $quality, 'live_consultation', $known, $failure)[$group] === []
&& $failure === null, 'known demographics are omitted from questions/checks without losing useful groups');
}
$conflict = $qualityData;
$conflict['questions'] = [['text' => '您说今年53岁,与病历年龄不同,可以核实一下?', 'evidence_ids' => ['age']]];
liveExpect(Result::parse(Context::encode($conflict), $quality, 'live_consultation', $known) !== null, 'explicit current patient age conflict can be checked');
$conflict['questions'][0]['evidence_ids'] = ['relative'];
liveExpect(Result::parse(Context::encode($conflict), $quality, 'live_consultation', $known)['questions'] === [], 'third-person age cannot create chart conflict');
$conflict['questions'] = [['text' => '您所说的性别与病历不同,可以核实一下?', 'evidence_ids' => ['gender']]];
liveExpect(Result::parse(Context::encode($conflict), $quality, 'live_consultation', $known) !== null, 'explicit patient gender conflict can be checked');
$bad = $qualityData; $bad['questions'][0]['text'] = '您今年几岁?';
liveExpect(Result::parse(Context::encode($bad), $quality, 'live_consultation') !== null, 'missing chart does not pretend demographic is known');
$good = $qualityData; $good['questions'][0]['text'] = '头晕对工作有多大影响?';
liveExpect(Result::parse(Context::encode($good), $quality, 'live_consultation', $known) !== null, 'degree of impact is not mistaken for an age question');
foreach (['您首次发病时多少岁?', '当时您的年龄是多少?', '您母亲今年几岁?'] as $question) {
$good = $qualityData;
$good['questions'][0]['text'] = $question;
liveExpect(count(Result::parse(Context::encode($good), $quality, 'live_consultation', $known)['questions']) === 1,
'known current age does not suppress onset, historical or family demographics');
}
foreach (['哪部分解释还不清楚?', '您目前最担心的是什么?', '按照刚才说明的安排,您有哪些执行困难?'] as $question) {
$good = $qualityData;
$good['questions'] = [['text' => $question, 'reason' => '了解患者理解和顾虑', 'evidence_ids' => ['concern']]];
liveExpect(Result::parse(Context::encode($good), $quality, 'live_consultation', $known) !== null,
'patient understanding, concern and appropriate follow-up remain permitted');
}
foreach (['现在下单购买疗程吗?', '您愿意服用保证治愈的药吗?', '建议立即服用某药吗?'] as $question) {
$bad = $qualityData; $bad['questions'][0]['text'] = $question;
liveExpect(Result::parse(Context::encode($bad), $quality, 'live_consultation', $known, $failure) === null
&& $failure === 'SAFETY_REJECTED', 'sales pressure, guarantee or new medication suggestion fails safety gate');
}
$bad = $qualityData; $bad['questions'] = array_fill(0, 4, $bad['questions'][0]);
liveExpect(count(Result::parse(Context::encode($bad), $quality, 'live_consultation', $known)['questions']) === 3, 'at most three validated questions are displayed');
$bad['questions'][3]['text'] = '建议立即服用某药吗?';
liveExpect(Result::parse(Context::encode($bad), $quality, 'live_consultation', $known) === null, 'fourth unshown suggestion must still pass safety');
$bad = $qualityData; $bad['questions'][0]['text'] = '什么时候开始?有没有加重?';
liveExpect(Result::parse(Context::encode($bad), $quality, 'live_consultation', $known)['questions'] === [], 'multiple questions in one card are omitted');
$good = $qualityData; $good['questions'][0]['text'] = '有没有影响日常活动。';
liveExpect(Result::parse(Context::encode($good), $quality, 'live_consultation', $known)['questions'][0]['text'] === '有没有影响日常活动?',
'missing punctuation on an explicit question is normalized');
$bad = $qualityData; $bad['questions'][0]['text'] = '患者确实有头痛';
$bad['checks'] = [['text' => '患者已有相关记录', 'evidence_ids' => ['patient']]];
liveExpect(Result::parse(Context::encode($bad), $quality, 'live_consultation', $known)['questions'] === []
&& Result::parse(Context::encode($bad), $quality, 'live_consultation', $known)['checks'] === [],
'non-question and non-check statements omitted, never treated as clinical facts');
$good = $qualityData; $good['summary'][0]['text'] = '患者没有头晕';
$good['summary'][0]['reason'] = '患者没有症状';
$quoted = Result::parse(Context::encode($good), $quality, 'live_consultation', $known);
liveExpect($quoted['summary'][0]['text'] === '患者原话:' . $quality['sources'][1]['text']
&& !isset($quoted['summary'][0]['reason']), 'model paraphrase and rationale discarded; only exact cited patient source appears');
$json = Context::encode($qualityData);
foreach ([$json, "```json\n" . $json . "\n```", "```\r\n" . $json . "\r\n```"] as $envelope)
liveExpect(Result::parse($envelope, $quality, 'live_consultation', $known) === $qualityResult, 'exact envelope normalizes without changing validated result');
foreach (["解释\n```json\n" . $json . "\n```", "```json\n" . $json . "\n```\n额外文字",
"```php\n" . $json . "\n```", substr($json, 0, -1) . ',"questions":[]}', $json . $json,
'{"questions":[],"summary":[],"checks":[],"review":[],}', '{"questions":[],"extra":[]}',
'{"result":' . $json . '}'] as $badJson)
liveExpect(Result::parse($badJson, $quality, 'live_consultation', $known) === null, 'duplicate keys/prose/wrappers/extra keys remain invalid');
$bad = $qualityData; $bad['questions'][0]['evidence_ids'] = ['foreign'];
liveExpect(Result::parse("```json\n" . Context::encode($bad) . "\n```", $quality, 'live_consultation', $known, $failure) === null
&& $failure === 'CITATION_INVALID', 'fenced invented citation is still rejected');
$escaped = $qualityData;
$escaped['summary'] = [['text' => $quality['sources'][6]['text'], 'evidence_ids' => ['literal']]];
$escapedJson = Context::encode($escaped);
$lastCount = 0;
for ($length = 1; $length <= strlen($escapedJson); $length++) {
$partial = Result::progress(substr($escapedJson, 0, $length), $quality, 'live_consultation', $known);
if ($partial === null) continue;
liveExpect($partial['completed_groups'] >= $lastCount && $partial['completed_groups'] <= 4,
'every byte boundary preserves monotonic complete groups despite quoted delimiters and split UTF-8');
$lastCount = $partial['completed_groups'];
}
liveExpect($lastCount === 4 && Result::progress($escapedJson, $quality, 'live_consultation', $known)['assistance']
=== Result::parse($escapedJson, $quality, 'live_consultation', $known), 'last progressive groups equal final gate');
$questionOnly = '{"questions":' . Context::encode($qualityData['questions']);
$partial = Result::progress($questionOnly, $quality, 'live_consultation', $known);
liveExpect($partial['completed_groups'] === 1 && $partial['assistance']['questions'] === $qualityResult['questions']
&& $partial['assistance']['summary'] === [], 'complete first question group can be used before the rest exists');
liveExpect(Result::progress(substr($questionOnly, 0, -1), $quality, 'live_consultation', $known) === null,
'complete item without its array delimiter cannot be delivered');
$firstItem = substr($questionOnly, 0, -1) . ',';
$partial = Result::progress($firstItem, $quality, 'live_consultation', $known);
liveExpect($partial['completed_groups'] === 0 && $partial['displayed_items'] === 1
&& $partial['assistance']['questions'] === $qualityResult['questions'], 'delimited first item is useful before the array closes');
foreach ([$firstItem . ']', $firstItem . 'null', substr($firstItem, 0, -1) . 'x',
$firstItem . '{"text":"second","evidence_ids":[]}x', $firstItem . '{"text":}]'] as $invalidPrefix)
liveExpect(Result::progress($invalidPrefix, $quality, 'live_consultation', $known) === null,
'invalid later array syntax never creates another progressive payload');
// Delimiters in escaped strings and nested evidence arrays cannot close an item early.
$literalQuestion = ['text' => '原话里的 ] } " 符号与反斜线\\指什么?', 'evidence_ids' => ['literal']];
$literalPrefix = '{"questions":[' . Context::encode($literalQuestion) . ',';
for ($length = 1; $length < strlen($literalPrefix); $length++)
liveExpect(Result::progress(substr($literalPrefix, 0, $length), $quality, 'live_consultation') === null,
'no byte boundary inside the first item or before its delimiter is deliverable');
liveExpect(Result::progress($literalPrefix, $quality, 'live_consultation')['displayed_items'] === 1,
'fully delimited escaped first item validates');
// A source citation cannot turn a doctor/unknown statement into patient confirmation.
$roleSnapshot = Context::snapshot($record, [
['segment_id' => 'd', 'speaker_role' => 'doctor', 'timestamp_ms' => 1, 'text' => '最近有没有服药?'],
['segment_id' => 'u', 'speaker_role' => 'unknown', 'timestamp_ms' => 2, 'text' => '这个还要确认。'],
['segment_id' => 'p', 'speaker_role' => 'patient', 'timestamp_ms' => 3, 'text' => '我以前没有服药,现在有了。'],
]);
foreach ([['d'], ['u'], ['d', 'u']] as $references) {
$roleData = ['questions' => [['text' => '最近是否服药?', 'reason' => '患者自述近期未服药', 'evidence_ids' => $references]],
'summary' => [], 'checks' => [], 'review' => []];
$roleResult = Result::parse(Context::encode($roleData), $roleSnapshot, 'live_consultation');
liveExpect(count($roleResult['questions']) === 1 && !str_contains($roleResult['questions'][0]['reason'], '患者自述')
&& str_contains($roleResult['questions'][0]['reason'], in_array('u', $references, true) ? '说话人未确认' : '医生本次发言'),
'nonpatient reason is reconstructed from immutable role context');
foreach (['患者自述近期未服药,要再确认一下吗?', '近期未服药,还有其他情况吗?', '您说没有服药,是否需要核实?',
'患者已经服药,什么时候开始的?', '您已经服药多久了?', '未服药多长时间了?'] as $premise) {
$roleData['questions'][0]['text'] = $premise;
liveExpect(Result::parse(Context::encode($roleData), $roleSnapshot, 'live_consultation')['questions'] === [],
'unsupported patient-fact premise is omitted, not merely relabeled');
}
$roleData['questions'][0]['text'] = '您是否已经服药?';
liveExpect(count(Result::parse(Context::encode($roleData), $roleSnapshot, 'live_consultation')['questions']) === 1,
'an open confirmation question remains useful without patient evidence');
$roleData['checks'] = [['text' => '确认患者未服药的时间', 'evidence_ids' => $references]];
liveExpect(Result::parse(Context::encode($roleData), $roleSnapshot, 'live_consultation')['checks'] === [],
'checks cannot carry unsupported patient premises either');
}
$roleData['questions'] = [['text' => '过去与现在的用药情况有何变化?', 'reason' => '核实时间变化', 'evidence_ids' => ['p']]];
$roleData['checks'] = [];
$roleData['summary'] = [['text' => '错误改写', 'evidence_ids' => ['p']]];
$roleResult = Result::parse(Context::encode($roleData), $roleSnapshot, 'live_consultation');
liveExpect($roleResult['questions'][0]['reason'] === '核实时间变化'
&& $roleResult['summary'][0]['text'] === '患者原话:我以前没有服药,现在有了。',
'patient denial and historical/current distinction survive source projection');
$roleData['questions'] = [['text' => '病历记录过去未服药,本次是否有变化?', 'evidence_ids' => ['d']]];
liveExpect(count(Result::parse(Context::encode($roleData), $roleSnapshot, 'live_consultation')['questions']) === 1,
'explicit chart background supports a targeted current confirmation without impersonating patient speech');
$bad['questions'][0]['evidence_ids'] = ['foreign'];
liveExpect(Result::progress('{"questions":' . Context::encode($bad['questions']), $quality, 'live_consultation', $known) === null,
'partial invented citation cannot be delivered');
liveExpect(Result::progress(str_repeat('x', Result::MAX_BYTES + 1), $quality, 'live_consultation', $known) === null,
'partial buffer is bounded');
echo "Live consultation quality / progressive parser: {$count} total assertions passed (synthetic, no DB/network/model)\n";