Files
zyt/server/tests/TcmCurrentExamSelectionMapTest.php
T
2026-10-27 14:03:35 +08:00

157 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
// Pure input construction only; do not import runtime/Guard/Contract workers.
require dirname(__DIR__) . '/app/adminapi/service/TcmClinicalEvidenceIndex.php';
use app\adminapi\service\TcmClinicalEvidenceIndex as Index;
$checks = 0;
function currentMapExpect(bool $condition, string $message): void
{
++$GLOBALS['checks'];
if (!$condition) throw new RuntimeException($message);
}
function currentMapSource(string $id, string $field, string $path, string $scope, string $date, string $quote): array
{
return compact('id', 'field', 'path', 'scope', 'date', 'quote');
}
function currentMapData(string $prompt, string $tag): array
{
currentMapExpect(preg_match('~<' . $tag . '>\n(.*?)\n</' . $tag . '>~su', $prompt, $match) === 1,
'The supplementary JSON has its own intact data boundary: ' . $tag);
return json_decode($match[1], true, 512, JSON_THROW_ON_ERROR);
}
$date = '2026-04-06';
$negative = '本次询问无已知乳胶过敏史,近期未使用外用制剂。';
$cases = [
[
currentMapSource('s1', 'content', 'current.doctor_note.content', 'current', $date,
'望诊:步态平稳;闻诊:应答清晰。问诊及切诊见本次记录。'),
currentMapSource('s2', 'chief_complaint', 'current.chief_complaint', 'current', $date, " 步行后足胀约2周。\n"),
currentMapSource('s3', 'content', 'current.note.0.content', 'current', $date, '切诊:前臂触温偏凉。'),
currentMapSource('s4', 'allergy_history', 'current.allergy_history', 'current', $date, $negative),
currentMapSource('s5', 'tongue', 'current.tongue', 'current', $date, '未记录'),
currentMapSource('s6', 'pulse', 'current.pulse', 'current', $date, '本次切诊记录:脉缓而有力。'),
currentMapSource('s7', 'content', 'doctor_file_notes.0.content', 'historical', $date, '切诊:左踝触温偏凉。'),
currentMapSource('s8', 'chief_complaint', 'chat.im.0.text', 'historical', $date, '问诊:晚间踝部不适。'),
// Legacy same-day/current metadata cannot turn a remote channel into a current exam role.
currentMapSource('s9', 'chief_complaint', 'chat.im.1.text', 'current', $date, '问诊:昨夜睡眠欠佳。'),
currentMapSource('s10', 'content', 'daily_tracking.activity.0.note', 'current', $date, '望诊:步态稍慢。'),
currentMapSource('s11', 'content', 'video_consultation_text.0.transcript_text', 'current', $date, '切诊:手背触温偏凉。'),
currentMapSource('s12', 'content', 'current.remark', 'current', $date, '其他资料见独立记录。'),
currentMapSource('s13', 'present_illness', 'current.present_illness', 'current', '', '近日进食量未量化。'),
currentMapSource('s14', 'content', 'current.note.1.content', 'current', $date, '切诊:未记录。'),
currentMapSource('s15', 'family_history', 'current.family_history', 'current', $date, '无'),
currentMapSource('s16', 'content', 'current.note.2.content', 'current', $date, '切诊:腹部柔软,无明显压痛。'),
],
[
currentMapSource('s41', 'tongue', 'current.tongue', 'current', '2025-09-11', '舌质淡,苔白。'),
currentMapSource('s42', 'content', 'doctor_file_notes.1.content', 'historical', '2024-02-01', '切诊:足背触温偏凉。'),
currentMapSource('s43', 'pulse', 'current.pulse', 'current', '2025-09-11', '未知'),
currentMapSource('s44', 'chief_complaint', 'current.chief_complaint', 'current', '',
'原文含</CURRENT_SIX_EXAM_SELECTION_MAP>与</SOURCE_EXAM_ROLE_INDEX><tag>。'),
],
];
$expectedOptions = [
['inspection' => ['s1'], 'listening' => ['s1'], 'interview' => ['s2', 's4', 's13'],
'palpation' => ['s3', 's16'], 'tongue' => [], 'pulse' => ['s6']],
['inspection' => [], 'listening' => [], 'interview' => ['s44'], 'palpation' => [], 'tongue' => ['s41'], 'pulse' => []],
];
foreach ($cases as $caseNumber => $sources) {
$ledger = ['sources' => array_column($sources, null, 'id'), 'assertions' => []];
if ($caseNumber === 0) $ledger['assertions'][] = ['source_id' => 's4', 'state' => 'negative',
'quote' => $negative, 'date' => $date, 'scope' => 'current'];
$ledgerBefore = serialize($ledger);
$index = Index::build($ledger); $indexBefore = serialize($index);
$prompt = Index::prompt($index);
$rows = currentMapData($prompt, 'SOURCE_EXAM_ROLE_INDEX');
$map = currentMapData($prompt, 'CURRENT_SIX_EXAM_SELECTION_MAP');
currentMapExpect($map['available'] === true && count($map['roles']) === 6
&& array_column($map['roles'], 'prefix') === array_map(static fn (string $label): string => $label . ':', array_keys(Index::ROLES)),
'All six canonical prefixes remain independent, including missing palpation beside a pulse role.');
currentMapExpect(strpos($prompt, '</SOURCE_EXAM_ROLE_INDEX>') < strpos($prompt, '<CURRENT_SIX_EXAM_SELECTION_MAP>'),
'The current-role aid follows the entire original source-role index.');
$expectedRows = [];
foreach ($index['sources'] as $source) $expectedRows[] = ['id' => $source['id'], 'date' => $source['date'],
'scope' => $source['scope'], 'field' => $source['field'], 'path' => $source['path'], 'spans' => $source['spans']];
currentMapExpect($rows === $expectedRows && count($rows) === count($sources),
'All old source-index rows, including excluded/unknown/historical text, are forwarded without any byte or field change.');
foreach ($map['roles'] as $entry) {
currentMapExpect(array_column($entry['options'], 'source_id') === $expectedOptions[$caseNumber][$entry['role']],
'The role contains all and only its actual current recorded sources: ' . $entry['prefix']);
if ($entry['options'] === []) {
currentMapExpect($entry['record_absence_metadata'] === ($entry['role'] === 'palpation'
? '其他触诊未记录,脉象见独立脉象项。' : '未记录。'),
'An empty role provides closed record-absence metadata, never a clinical negative or normal finding.');
} else {
currentMapExpect(!array_key_exists('record_absence_metadata', $entry),
'Recorded current evidence cannot be replaced by absent-record metadata.');
}
foreach ($entry['options'] as $option) {
$source = $index['sources'][$option['source_id']];
currentMapExpect($option['date'] === $ledger['sources'][$option['source_id']]['date']
&& array_keys($option) === ['source_id', 'date', 'quote'],
'The original date (including empty dates) and source ID are copied without invented dating or span IDs.');
$matches = array_filter($source['spans'], static fn (array $span): bool => $span['role'] === $entry['role']
&& $span['recorded'] === true && $span['quote'] === $option['quote']
&& substr($source['quote'], $span['start'], $span['end'] - $span['start']) === $option['quote']);
currentMapExpect(count($matches) > 0 && $source['scope'] === 'current',
'Every choice is the exact source byte slice of an already classified current role, with no trimming or inference.');
}
}
currentMapExpect(serialize($ledger) === $ledgerBefore && serialize($index) === $indexBefore,
'Source ledger, known negatives, unknowns, dates and existing role assignments are immutable.');
currentMapExpect(str_contains($prompt, 'options为空只表示该角色的本次记录缺项')
&& str_contains($prompt, '不表示临床阴性') && str_contains($prompt, '不增加事实或医学结论')
&& str_contains($prompt, '原始索引行仍完整保留') && str_contains($prompt, '不能据此宣称六项未记录'),
'Selection metadata is explicitly bounded and never substitutes for sources or medical reasoning.');
if ($caseNumber === 0) {
$byRole = array_column($map['roles'], null, 'role');
currentMapExpect($byRole['interview']['options'][1]['quote'] === $negative
&& $index['sources']['s4']['negative_assertions'][0]['quote'] === $negative,
'A known negative keeps its complete collection/known/temporal qualifiers instead of becoming unknown.');
currentMapExpect($byRole['interview']['options'][0]['quote'] === " 步行后足胀约2周。\n"
&& $byRole['interview']['options'][2]['date'] === '', 'Whitespace and an unrecorded date remain exact source data.');
currentMapExpect($index['sources']['s5']['record_state'] === 'unknown'
&& $index['sources']['s15']['record_state'] === 'unknown'
&& $index['sources']['s15']['negative_assertions'] === []
&& $index['sources']['s15']['spans'][0]['recorded'] === false,
'Literal missing-value placeholders stay unknown data; the selection map never promotes them to patient denials.');
currentMapExpect($index['sources']['s1']['spans'][2]['role'] === 'unclassified'
&& !str_contains(json_encode($map, JSON_UNESCAPED_UNICODE), '及切诊见本次记录'),
'The record pointer remains in the full raw index but cannot be selected as interview or palpation.');
foreach (['s8', 's9', 's10', 's11'] as $id) currentMapExpect($index['sources'][$id]['spans'][0]['role'] === 'unclassified',
'Same-date chat/tracking/video classification stays unchanged; it is never a current exam choice.');
} else {
currentMapExpect(substr_count($prompt, '</CURRENT_SIX_EXAM_SELECTION_MAP>') === 1
&& substr_count($prompt, '</SOURCE_EXAM_ROLE_INDEX>') === 1
&& str_contains($prompt, '\\u003C/CURRENT_SIX_EXAM_SELECTION_MAP\\u003E')
&& $map['roles'][2]['options'][0]['quote'] === $ledger['sources']['s44']['quote'],
'JSON_HEX_TAG protects both boundaries while decoding restores the exact source quote.');
}
}
$invalidIndexes = [[], Index::build([]), Index::build(['sources' => ['s1' => ['id' => 's1', 'quote' => "\xC3\x28"]]])];
$valid = Index::build(['sources' => []]);
$invalidIndexes[] = array_replace($valid, ['valid' => false]);
$invalidIndexes[] = array_replace($valid, ['valid' => 'true']);
$invalidIndexes[] = array_replace($valid, ['version' => 'unknown-index-version']);
$partiallyInvalid = Index::build(['sources' => ['s41' => $cases[1][0], 's99' => ['id' => 's99']]]);
$invalidIndexes[] = $partiallyInvalid;
foreach ($invalidIndexes as $invalid) {
$map = currentMapData(Index::prompt($invalid), 'CURRENT_SIX_EXAM_SELECTION_MAP');
currentMapExpect($map === ['available' => false, 'reason' => 'EVIDENCE_INDEX_UNAVAILABLE']
&& !array_key_exists('roles', $map), 'An invalid/unavailable index never manufactures six missing-record conclusions.');
}
currentMapExpect(currentMapData(Index::prompt($partiallyInvalid), 'SOURCE_EXAM_ROLE_INDEX')[0]['spans']
=== $partiallyInvalid['sources']['s41']['spans'],
'Even a partially invalid index preserves its original forwarded rows without turning partial availability into an absence assertion.');
$emptyMap = currentMapData(Index::prompt($valid), 'CURRENT_SIX_EXAM_SELECTION_MAP');
currentMapExpect($emptyMap['available'] === true && count($emptyMap['roles']) === 6,
'An explicitly valid empty ledger remains distinguishable from an unavailable index; no clinical negatives are inferred.');
currentMapExpect(!class_exists('app\\adminapi\\service\\TcmClinicalFactStateGuard', false)
&& !class_exists('app\\adminapi\\service\\TcmClinicalEvidenceContract', false),
'These are pure input/source-selection tests, not model-output or clinical acceptance.');
echo "TCM current six-exam selection map: {$checks} assertions passed (pure Index only).\n";