157 lines
12 KiB
PHP
157 lines
12 KiB
PHP
<?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";
|