166 lines
10 KiB
PHP
166 lines
10 KiB
PHP
<?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";
|