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

90 lines
7.3 KiB
PHP

<?php
declare(strict_types=1);
// Input-policy contract only: no concurrent Guard/Contract or runtime classes.
require dirname(__DIR__) . '/app/adminapi/service/TcmClinicalEvidenceIndex.php';
use app\adminapi\service\TcmClinicalEvidenceIndex as Index;
$checks = 0;
function priorityScopeExpect(bool $ok, string $why): void { ++$GLOBALS['checks']; if (!$ok) throw new RuntimeException($why); }
$cases = [
[
['s2', 'present_illness', 'current', '2026-02-12', '步行后倦怠较明显,休息可缓;饮水常分多次,每次量未测。'],
['s19', 'content', 'historical', '2026-02-12', '既有检验记录:2026-02-10 ALT 20U/L,肌酐75μmol/L。'],
],
[
['s47', 'present_illness', 'current', '2025-07-08', '晨起眩晕每周约2次;单次饮水约150mL,全天总量未记录。'],
['s90', 'past_history', 'current', '2025-07-08', '本次询问无已知制剂辅料过敏;随访变化未记录。'],
['s106', 'content', 'historical', '', '历史检查记录:2025-06-30血红蛋白130g/L;其他检验种类未记录。'],
],
];
$emptyPrompt = Index::prompt([]);
$sourceIndependentPolicy = substr($emptyPrompt, 0, strpos($emptyPrompt, '<SOURCE_EXAM_ROLE_INDEX>'));
foreach ($cases as $case) {
$ledger = ['sources' => [], 'assertions' => []];
foreach ($case as [$id, $field, $scope, $date, $quote]) $ledger['sources'][$id] = [
'id' => $id, 'field' => $field, 'path' => $scope . '.' . $field . '.' . $id,
'scope' => $scope, 'date' => $date, 'quote' => $quote];
$originalLedger = serialize($ledger);
$index = Index::build($ledger); $originalIndex = serialize($index);
$prompt = Index::prompt($index);
priorityScopeExpect(serialize($ledger) === $originalLedger && serialize($index) === $originalIndex,
'Qualitative/quantitative findings and baseline laboratory source payloads are never rewritten by policy generation.');
preg_match('~<SOURCE_EXAM_ROLE_INDEX>\s*(.*?)\s*</SOURCE_EXAM_ROLE_INDEX>~su', $prompt, $match);
$rows = json_decode($match[1], true, 64, JSON_THROW_ON_ERROR);
priorityScopeExpect(count($rows) === count($case), 'No source disappears when the instructions distinguish known and missing portions.');
foreach ($rows as $row) {
$source = $ledger['sources'][$row['id']];
priorityScopeExpect($row['field'] === $source['field'] && $row['path'] === $source['path']
&& $row['scope'] === $source['scope'] && $row['date'] === $source['date']
&& implode('', array_column($row['spans'], 'quote')) === $source['quote'],
'Exact activity qualifiers, per-occasion/total values, negative qualifiers and embedded lab dates survive unchanged.');
}
$policy = substr($prompt, 0, strpos($prompt, '<SOURCE_EXAM_ROLE_INDEX>'));
foreach (['每个主要治法限定语及既定优先顺序', '其实际功用怎样支持', '不能只声称‘一致’或‘覆盖’',
'不把作用相近的不同治法视为同义', '不能为迁就方名倒改第五节', '任一主要限定或优先目标无法解释时,不列方名药味,说明缺证点及所需补采',
'定性程度、活动或诱发关系、饮水方式及既有检验项目和原日期', '缺量化值须明确每次、全天或具体时段,缺检查须明确检验种类',
'不能因定量或特定检验缺失,把已有程度、摄入方式或实验室基线整体改成未知'] as $rule) {
priorityScopeExpect(str_contains($policy, $rule), 'Both differing source cases receive the same generic explanation/scope requirement: ' . $rule);
}
priorityScopeExpect(str_contains($policy, '完整句或分号分隔的完整片段')
&& str_contains($policy, '保留时间、询问或记录语境、已知及程度等限定词,不能只摘否认核心')
&& str_contains($policy, '原有全部资料仍须阅读') && str_contains($policy, '索引不包含新的诊断规则'),
'Strict canonical quotation, complete original-source reading and the no-new-diagnostic-rule boundary remain intact.');
priorityScopeExpect($policy === $sourceIndependentPolicy,
'The reasoning policy is identical for unrelated source payloads and supplies no case-dependent selection or answer prefill.');
}
$pulseSource = ['id' => 's7', 'field' => 'pulse', 'path' => 'current.pulse', 'scope' => 'current',
'date' => '2026-03-04', 'quote' => '脉缓而有力。'];
$palpationSource = ['id' => 's8', 'field' => 'content', 'path' => 'current.doctor_note.content', 'scope' => 'current',
'date' => '2026-03-04', 'quote' => '切诊前臂触温偏凉。'];
$historicalPalpation = array_replace($palpationSource, ['path' => 'historical.doctor_note.content',
'scope' => 'historical', 'date' => '2025-04-02']);
foreach ([[$pulseSource], [$pulseSource, $palpationSource], [$pulseSource, $historicalPalpation]] as $caseNumber => $sources) {
$ledger = ['sources' => array_column($sources, null, 'id')]; $before = serialize($ledger);
$index = Index::build($ledger); $prompt = Index::prompt($index);
$policy = substr($prompt, 0, strpos($prompt, '<SOURCE_EXAM_ROLE_INDEX>'));
foreach (['望诊', '闻诊', '问诊', '切诊', '舌象', '脉象'] as $label) priorityScopeExpect(str_contains($policy, '“' . $label . ':”'),
'Every exact mandatory prefix is specified even when its source role has no observation.');
priorityScopeExpect(str_contains($policy, '每项独立段落且不得省略;六项齐全后才能开始第二节八纲')
&& str_contains($policy, '独立脉象段不能代替切诊段'),
'The second-section boundary requires all six independent paragraphs; pulse never substitutes for palpation.');
priorityScopeExpect(str_contains($policy, '仅当本次确无其他已记录切诊内容时,仍须独立写“切诊:其他触诊未记录,脉象见独立脉象项。”')
&& str_contains($policy, '有其他本次切诊记录则按原日期、原文与sN引用,不得改成未记录'),
'The closed missing-palpation metadata is conditional and cannot erase an actual current palpation observation.');
$hasCurrentPalpation = false;
foreach ($index['sources'] as $source) {
priorityScopeExpect($source['quote'] === $ledger['sources'][$source['id']]['quote']
&& $source['scope'] === $ledger['sources'][$source['id']]['scope']
&& $source['date'] === $ledger['sources'][$source['id']]['date'], 'Mandatory roles do not rewrite source text or time/scope.');
foreach ($source['spans'] as $span) if ($source['scope'] === 'current' && $span['role'] === 'palpation' && $span['recorded']) $hasCurrentPalpation = true;
}
priorityScopeExpect(serialize($ledger) === $before && $hasCurrentPalpation === ($caseNumber === 1)
&& $index['sources']['s7']['spans'][0]['role'] === 'pulse',
'Pulse-only and historical-only cases remain distinct from a real current palpation source; no observation is fabricated.');
}
priorityScopeExpect(!class_exists('app\\adminapi\\service\\TcmClinicalFactStateGuard', false)
&& !class_exists('app\\adminapi\\service\\TcmClinicalEvidenceContract', false),
'Pure input tests do not load concurrent validation workers or claim model-output acceptance.');
echo "TCM priority/scope input prompt: {$checks} assertions passed (pure Index only; shared integration deferred).\n";