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

197 lines
13 KiB
PHP
Raw Permalink 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);
require dirname(__DIR__) . '/vendor/autoload.php';
require __DIR__ . '/fixtures/CompleteTcmCase.php';
use app\adminapi\logic\tcm\PatientAiReportLogic;
use app\adminapi\service\AiRequiredSource;
use app\adminapi\service\TcmClinicalEvidenceContract as Contract;
use app\adminapi\service\TcmClinicalEvidenceIndex as Index;
use app\adminapi\service\TcmClinicalFactStateGuard as Guard;
// Pure source/protocol regressions. No stream, model, transport, DB or report rewrite.
$count = 0;
$failures = [];
$expect = static function (bool $ok, string $message) use (&$count, &$failures): void {
++$count;
if (!$ok) $failures[] = $message;
};
$snapshot = (new ReflectionMethod(PatientAiReportLogic::class, 'buildSourceSnapshotFromRows'))
->invoke(null, CompleteTcmCase::rows());
$submission = (new ReflectionMethod(PatientAiReportLogic::class, 'sanitizeSnapshotForUpstream'))
->invoke(null, AiRequiredSource::submission($snapshot));
$ledger = Guard::build(CompleteTcmCase::current(), $submission);
$index = Index::build($ledger);
$sourceHash = hash('sha256', json_encode($ledger, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$expect($sourceHash === '2ebbec0b558347bf4ee128dfb5a9e63fc4e355d58f7226486a2ae96098bd57f6',
'Immutable complete source projection, including all original IDs, qualifiers, scopes and dates.');
$expect(hash_file('sha256', __DIR__ . '/fixtures/CompleteTcmCase.php')
=== 'b54c47ec11f6a99dc87bdda78629e18c8a6f37a64017e42a72db399c727cb1c6', 'Immutable fixture bytes.');
$first = "一、四诊\n\n";
foreach (Index::ROLES as $label => $role) {
$sourceSpan = null;
foreach ($index['sources'] as $source) {
if ($source['scope'] !== 'current') continue;
foreach ($source['spans'] as $span) {
if ($span['role'] === $role && $span['recorded']) { $sourceSpan = [$source, trim($span['quote'])]; break 2; }
}
}
if ($role === 'palpation' && $sourceSpan === null) {
$first .= "切诊:其他触诊未记录,脉象见独立脉象项。\n\n";
continue;
}
$expect($sourceSpan !== null, 'A recorded role has its actual current source.');
[$source, $quote] = $sourceSpan;
$first .= $label . ':' . $source['date'] . '“' . $quote . '”(' . $source['id'] . ")。\n\n";
}
$head = $first . "二、八纲\n\n";
$check = static function (string $text, bool $valid, string $code = '', bool $contract = false) use ($ledger, $index, $head, $expect): array {
$original = $text;
$result = $contract ? Contract::validate($index, $head . $text, true) : Guard::validate($ledger, $text);
$expect($result['valid'] === $valid, ($contract ? 'Contract ' : 'Guard ')
. json_encode([$text, $result], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
if ($code !== '') $expect(in_array($code, array_column($result['issues'], 'code'), true), 'Expected ' . $code . ': ' . $text);
$expect($text === $original, 'Every original report byte survives pure validation.');
return $result;
};
$medication = '现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s6,不擅停换。';
$inference = '不能仅据相关阴性记录反推为纯虚无实。';
$question = '适用前须核实:是否存在近期新发发热、恶寒、咳痰、胸闷或腹胀纳差等可能提示外邪或邪实未清的表现?';
$future = '适用条件为复核后仍以活动后气短、自汗、乏力、脉无力和少津为核心,且不存在更需优先处理的急性代谢异常、感染或器质性心肺问题。';
$trigger = '如症状突然加重或出现意识异常,应及时按急症评估。';
$reproductions = ['medication' => $medication, 'inference' => $inference, 'question' => $question, 'future' => $future, 'trigger' => $trigger];
if (in_array('--reproduce', $argv, true)) {
foreach ($reproductions as $label => $text) {
echo json_encode(['case' => $label, 'text' => $text, 'guard' => Guard::validate($ledger, $text),
'contract' => Contract::validate($index, $head . $text, true)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
}
$clauses = new ReflectionMethod(Guard::class, 'reportClauses');
foreach (['tcm_complete_v43r21_cap8_openai_deadline_20261024_openai_next_full90_openai',
'tcm_complete_v43r21_cap8_single_openai_20261024_0_openai', 'tcm_complete_v43r21_cap8_mixed_c4_20261024_3_openai'] as $stem) {
$path = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/' . $stem . '.rejected.json';
if (!is_file($path)) continue;
$saved = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
foreach ($saved['rejections'] as $n => $rejection) {
echo json_encode(['retained' => $stem . '#' . $n, 'sha256' => hash('sha256', $rejection['unit']),
'guard' => Guard::validate($ledger, $rejection['unit']),
'contract' => Contract::validate($index, $head . $rejection['unit'], true)], JSON_UNESCAPED_UNICODE) . "\n";
foreach ($clauses->invoke(null, $ledger, $rejection['unit']) as $entry) {
$result = Guard::validate($ledger, $entry['text']);
if (!$result['valid'] || $entry['annotation_invalid']) echo json_encode(['held_view' => $entry,
'guard' => $result], JSON_UNESCAPED_UNICODE) . "\n";
}
}
}
exit($failures === [] ? 0 : 1);
}
foreach ($reproductions as $text) { $check($text, true); $check($text, true, '', true); }
// Exact original retained units: do not alter snapshots or infer a successful live result.
$retained = [
['tcm_complete_v43r21_cap8_mixed_c4_20261024_3_openai', 1, 'dba0a7500da83bfbcf4c6c6845f7e4ad7aa78d701f9e6054a5fac4c004691617'],
['tcm_complete_v43r21_cap8_openai_deadline_20261024_openai_next_full90_openai', 0, '492602512704b309ba07b06d0a47d509b3c9b4cdb147e3ef43741247c31dfb9e'],
['tcm_complete_v43r21_cap8_openai_deadline_20261024_openai_next_full90_openai', 1, '0033369090211c54c6af2d62e792dd0cb7b7d776d0f5e547b6070fca8e4a6728'],
['tcm_complete_v43r21_cap8_single_openai_20261024_0_openai', 0, 'bbc1eddd8df19c1b5546990975867b8056d0241e235783e56fdba6de685e2b11'],
];
foreach ($retained as [$stem, $number, $hash]) {
$path = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/' . $stem . '.rejected.json';
if (!is_file($path)) continue;
$saved = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
$unit = $saved['rejections'][$number]['unit'];
if ($hash !== '') $expect(hash('sha256', $unit) === $hash, 'Exact retained source bytes: ' . $stem);
$check($unit, true); $check($unit, true, '', true);
}
$assertions = [
['患者有心悸', 'NEGATIVE_TO_POSITIVE'], ['患者无固定刺痛', 'UNKNOWN_TO_NEGATIVE'],
['患者有固定刺痛', 'UNKNOWN_TO_POSITIVE'], ['心悸未记录', 'NEGATIVE_TO_UNKNOWN'],
['口干未记录', 'POSITIVE_TO_UNKNOWN'], ['舌质红绛', 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE'],
['脉滑', 'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE'], ['无五心烦热', 'NEGATION_SCOPE_WIDENED'],
];
foreach ([$medication, $inference, $question, $future, $trigger] as $role) {
$base = preg_replace('/[。?]$/u', '', $role) ?? $role;
foreach ([',', ',', '且', '但', '并', '并且', '同时', '还', '又', '另'] as $joiner) {
foreach ($assertions as [$assertion, $code]) {
$check($base . $joiner . $assertion . '。', false, $code);
}
}
foreach ($assertions as [$assertion, $code]) $check($assertion . ',' . $base . '。', false, $code);
}
foreach (['s5', 's30', 's999', 's0'] as $id) $check(str_replace('s6', $id, $medication), false, 'SOURCE_ANNOTATION_INVALID');
foreach ([
'相关记录见s6。', '既有医嘱,相关记录见s6。', '口干,相关记录见s6。',
'现用二甲双胍0.6g、每日2次,既有医嘱,相关记录见s6。',
'现用二甲双胍0.5g、每日3次,既有医嘱,相关记录见s6。',
'现用二甲双胍0.5g、每日2次,已停用,相关记录见s6。',
'历史现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s6。',
'2026-10-22现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s6。',
] as $text) $check($text, false, 'SOURCE_ANNOTATION_INVALID');
foreach ([$question, $future, $medication, $inference, $trigger] as $role) {
foreach (['(s999)', '(2026-10-22,s2)', '(2026-13-99,s2)'] as $annotation) {
$check((preg_replace('/[。?]$/u', '', $role) ?? $role) . $annotation . '。', false, 'SOURCE_ANNOTATION_INVALID');
}
}
foreach ([
['适用条件为仍以乏力为核心,且不存在感染。', 'NEGATION_SCOPE_WIDENED'],
['适用条件为已复核后仍以乏力为核心,且不存在感染。', 'NEGATION_SCOPE_WIDENED'],
['适用条件为复核后仍以患者乏力为核心,且不存在感染。', 'NEGATION_SCOPE_WIDENED'],
['适用条件为复核后仍以当前乏力为核心,且不存在感染。', 'NEGATION_SCOPE_WIDENED'],
['适用条件为复核后仍以口干未记录为核心,且不存在感染。', ''],
['适用条件为复核后仍以乏力为核心,口干未记录,且不存在感染。', 'POSITIVE_TO_UNKNOWN'],
['如症状突然加重或出现意识异常。', 'NEGATIVE_TO_POSITIVE'],
['如症状突然加重或出现意识异常,可考虑观察。', 'NEGATIVE_TO_POSITIVE'],
['如症状突然加重或出现患者有心悸,应及时按急症评估。', 'NEGATIVE_TO_POSITIVE'],
['如症状突然加重或出现意识异常且患者有心悸,应及时按急症评估。', 'NEGATIVE_TO_POSITIVE'],
['如症状突然加重或出现意识异常,口干未记录,应及时按急症评估。', 'POSITIVE_TO_UNKNOWN'],
] as [$text, $code]) $check($text, false, $code);
foreach (['表', '里', '寒', '热', '虚', '实', '阴', '阳'] as $category) {
$check('不能仅据相关阴性记录反推为纯虚无' . $category . '。', true);
$check('不能仅据相关阴性记录反推为纯虚无' . $category . '。', true, '', true);
}
foreach (['2026-10-23“无固定刺痛”(s2)。',
'2026-10-22“无咳嗽咯痰、胸痛心悸、浮肿或腹泻。”(s2)。',
'2026-10-23“无咳嗽咯痰、胸痛心悸、浮肿或腹泻。”(s999)。',
'2026-10-23“无五心烦热、潮热盗汗,无畏寒肢冷。”(s2)。'] as $denial) {
$check($inference . $denial, false, '', true);
}
foreach (['无明显急性感染诱因', '无明显五心烦热'] as $qualified) {
$check($qualified . '。', true);
$check(str_replace('明显', '', $qualified) . '。', false, 'NEGATION_SCOPE_WIDENED');
$check('2026-10-23“' . str_replace('明显', '', $qualified) . '”(s2)。', false, '', true);
}
foreach ([
'不能仅据相关阴性记录反推为患者无固定刺痛。',
'不能仅据相关阴性记录反推为纯虚无实且患者无固定刺痛。',
'并非不能仅据相关阴性记录反推为纯虚无实。',
'不能不据相关阴性记录反推为纯虚无实。',
'患者不能仅据相关阴性记录反推为纯虚无实。',
] as $text) $check($text, false, 'UNQUOTED_PATIENT_DENIAL', true);
foreach (['患者无固定刺痛。', '患者无腰酸耳鸣。'] as $text) $check($text, false, 'UNKNOWN_TO_NEGATIVE');
$unknown = Guard::build(['diagnosis_date' => '2026-10-23', 'tongue' => '未记录', 'pulse' => '未记录']);
foreach (['舌象正常。', '脉象正常。', '舌象无异常。', '脉象无异常。'] as $text) {
$result = Guard::validate($unknown, $text);
$expect(!$result['valid'], 'Unknown exam never becomes negative or normal: ' . $text);
}
foreach (['tcm_complete_v43r21_cap8_single_qwen_20261024_0_qwen',
'tcm_complete_v43r21_cap8_mixed_c4_20261024_0_qwen', 'tcm_complete_v43r21_cap8_mixed_c4_20261024_1_qwen',
'tcm_complete_v43r21_cap8_qwen_deadline_20261024_qwen_next_full90_qwen'] as $stem) {
$path = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/' . $stem . '.rejected.json';
if (!is_file($path)) continue;
$saved = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
foreach ($saved['rejections'] as $entry) $check($entry['unit'], false, 'NEGATION_SCOPE_WIDENED');
}
$expect(hash('sha256', json_encode($ledger, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) === $sourceHash,
'Validation preserves every ledger byte.');
$expect(!class_exists('app\\adminapi\\service\\TcmClinicalReportStream', false)
&& !class_exists('app\\adminapi\\logic\\tcm\\DiagnosisAiLogic', false), 'No peer stream/diagnosis logic is loaded.');
if ($failures !== []) {
fwrite(STDERR, 'TCM r21 rejected role: ' . count($failures) . ' failures / ' . $count . " assertions\n"
. implode("\n", $failures) . "\n");
exit(1);
}
echo "TCM r21 rejected role: {$count} assertions passed (pure production Guard/Contract; no model/network).\n";