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

166 lines
10 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);
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;
$count = 0;
$expect = static function (bool $value, string $message) use (&$count): void {
++$count;
if (!$value) throw new RuntimeException($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);
$expect(hash('sha256', json_encode($ledger, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))
=== '2ebbec0b558347bf4ee128dfb5a9e63fc4e355d58f7226486a2ae96098bd57f6',
'The complete immutable source projection retains all source IDs, quotes, dates, roles and states.');
// Establish the real six-role first section, using complete original role spans.
$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, 'Every recorded exam role has actual source provenance.');
[$source, $quote] = $sourceSpan;
$first .= $label . ':' . $source['date'] . '“' . $quote . '”(' . $source['id'] . ")。\n\n";
}
$contract = static function (string $text, bool $valid, string $code = '', bool $complete = false) use ($index, $expect): array {
$original = $text;
$result = Contract::validate($index, $text, $complete);
$expect($result['valid'] === $valid, json_encode([$text, $result], JSON_UNESCAPED_UNICODE));
if ($code !== '') $expect(in_array($code, array_column($result['issues'], 'code'), true),
'Expected ' . $code . ': ' . json_encode($result, JSON_UNESCAPED_UNICODE));
$expect($text === $original, 'Contract validation preserves every original model/output byte.');
return $result;
};
$guard = static function (string $text, bool $valid, string $code = '') use ($ledger, $expect): array {
$original = $text;
$result = Guard::validate($ledger, $text);
$expect($result['valid'] === $valid, json_encode([$text, $result], JSON_UNESCAPED_UNICODE));
if ($code !== '') $expect(in_array($code, array_column($result['issues'], 'code'), true),
'Expected unchanged Guard issue ' . $code . ': ' . json_encode($result, JSON_UNESCAPED_UNICODE));
$expect($text === $original, 'Guard validation preserves the original string.');
return $result;
};
$contract($first, true, '', true);
$head = $first . "二、八纲\n\n";
$unit = "虚实:神疲、面色少华、语声低微,活动后气短、自汗,脉细弱无力(s2、s3、s10),共同支持虚证。实证缺乏阳性依据,不能据此直接判定“无实”。\n\n";
$artifact = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/tcm_complete_v43r20_cap8_single_openai_20261024_0_openai.rejected.json';
if (is_file($artifact)) {
$saved = json_decode((string) file_get_contents($artifact), true, 512, JSON_THROW_ON_ERROR);
$expect($saved['rejections'][0]['unit'] === $unit, 'The portable exact unit equals the retained original failure.');
}
$expect(strlen($unit) === 201 && hash('sha256', $unit) === '6739ca28205232838eb426ceccf164c609d7fda9b2be5878255dfcad3c77421a',
'The original retained bytes are not rewritten or substituted.');
$contract($head . $unit, true, '', true);
$guard($unit, true);
foreach (['表', '里', '寒', '热', '虚', '实', '阴', '阳'] as $category) {
foreach (['不能据此直接判定', '不得由此直接判断', '不应仅凭虚证直接认定'] as $operator) {
foreach ([['“', '”'], ['「', '」'], ['"', '"']] as [$open, $close]) {
$contract($head . $operator . $open . '无' . $category . $close . '。', true);
}
}
}
$base = '不能据此直接判定“无实”';
foreach (['可据此直接判定“无实”。', '可以据此直接判定“无实”。',
'并非不能据此直接判定“无实”。', '不是不能据此直接判定“无实”。',
'不能不据此直接判定“无实”。', '不能说不能据此直接判定“无实”。',
'患者已判定“无实”。', '本次查见“无实”。',
$base . '是事实。', $base . '“无固定刺痛”。',
$base . '(s999)。', $base . '(s0)。', $base . '(非来源)。',
$base . '(s2)。', $base . '(s2、s3)。',
'不能据此直接判定“无固定刺痛”。', '不能据此直接判定“患者无口干”。',
'不能据此直接判定“无实性皮疹”。',
'不能据此直接判定“无' . str_repeat('实', 80) . '”。',
] as $text) $contract($head . $text, false, 'DENIAL_SOURCE_QUOTE_INVALID');
// The exception belongs to its immediately adjacent quote, not a previous clause/quote.
foreach ([$base . ';“无固定刺痛”。', $base . ';“无固定刺痛”(s999)。',
'不能据此直接判定“无实”;因此“无口干”。',
'并非“这一结论”不能据此直接判定“无实”。',
$base . ',不能据此直接判定“无固定刺痛”。'] as $text) {
$contract($head . $text, false, 'DENIAL_SOURCE_QUOTE_INVALID');
}
$contract($head . '不得写为“无胸痛”。', true); // Existing writing-role fallback is retained.
$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 ([',', ',', '且', '但', '并', '并且', '同时', '还', '又', '另'] as $joiner) {
foreach ($assertions as [$assertion, $code]) {
foreach ([$base . $joiner . $assertion . '。', $assertion . ',' . $base . '。'] as $text) {
$original = $text;
$guardResult = Guard::validate($ledger, $text);
$contractResult = Contract::validate($index, $head . $text);
$expect(!($guardResult['valid'] && $contractResult['valid']),
'The combined source/state and quote protocol cannot accept an adjoining actual assertion.');
if (str_starts_with($text, $base) && !in_array($joiner, [',', ','], true)) {
$expect(!$contractResult['valid']
&& in_array('DENIAL_SOURCE_QUOTE_INVALID', array_column($contractResult['issues'], 'code'), true),
'A direct conjunction tail never gains the new inference-quote exemption.');
} else {
$expect(!$guardResult['valid'] && in_array($code, array_column($guardResult['issues'], 'code'), true),
'The unchanged Guard retains every punctuation-separated actual assertion issue.');
}
$expect($text === $original, 'Combined pure validation preserves the original model bytes.');
}
}
}
$contract($head . $base . ',患者无固定刺痛。', false, 'UNQUOTED_PATIENT_DENIAL');
$contract($head . $base . '且药物无相互作用。', false, 'DENIAL_SOURCE_QUOTE_INVALID');
$contract($head . $base . '且心悸未记录。', false, 'DENIAL_SOURCE_QUOTE_INVALID');
$guard($base . ',心悸未记录。', false, 'NEGATIVE_TO_UNKNOWN');
$guard($base . ',口干未记录。', false, 'POSITIVE_TO_UNKNOWN');
$contract($head . '患者无固定刺痛,' . $base . '。', false, 'UNQUOTED_PATIENT_DENIAL');
$contract($head . $base . ';2026-10-23“无咳嗽咯痰、胸痛心悸、浮肿或腹泻。”(s2)。', true);
$contract($head . $base . ';2026-10-23“无五心烦热、潮热盗汗,无畏寒肢冷。”(s2)。', false,
'DENIAL_SOURCE_QUOTE_INVALID');
$contract($head . $base . ';2026-10-22“无咳嗽咯痰、胸痛心悸、浮肿或腹泻。”(s2)。', false,
'EVIDENCE_SOURCE_DATE_MISMATCH');
$contract($head . $base . ';2026-10-23“无固定刺痛”(s2)。', false, 'DENIAL_SOURCE_QUOTE_INVALID');
$contract($head . $base . ';本次2026-10-23“' . $index['sources']['s33']['quote'] . '”(s33)。', false,
'EXAM_SOURCE_NOT_CURRENT');
// The later-section illustration role cannot supply a first-section clinical fact.
foreach (array_keys(Index::ROLES) as $label) {
$contract("一、四诊\n\n" . $label . ':' . $base . '。', false, 'SOURCE_ANNOTATION_INVALID');
}
$contract("二、八纲\n\n" . $base . '。', false, 'EXAM_ROLE_MISSING');
$contract(str_replace('(s3)', '(s999)', $first), false, 'SOURCE_ANNOTATION_INVALID', true);
$contract(str_replace('2026-10-23', '2026-10-22', $first), false, 'EVIDENCE_SOURCE_DATE_MISMATCH', true);
$expect(hash('sha256', json_encode($ledger, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))
=== '2ebbec0b558347bf4ee128dfb5a9e63fc4e355d58f7226486a2ae96098bd57f6',
'Validation leaves the complete original source ledger unchanged.');
$expect(!class_exists('app\\adminapi\\service\\TcmClinicalReportStream', false)
&& !class_exists('app\\adminapi\\logic\\tcm\\DiagnosisAiLogic', false),
'This pure focused test never loads peer stream/logic classes.');
echo "TCM negated inference quote role: {$count} assertions passed (pure production Contract/Guard; no stream/model/network).\n";