199 lines
13 KiB
PHP
199 lines
13 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;
|
||
$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);
|
||
$ledgerBytes = serialize($ledger);
|
||
$expect(hash('sha256', json_encode($ledger, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))
|
||
=== '2ebbec0b558347bf4ee128dfb5a9e63fc4e355d58f7226486a2ae96098bd57f6', 'Every immutable source ledger byte is retained.');
|
||
$expect(hash_file('sha256', __DIR__ . '/fixtures/CompleteTcmCase.php')
|
||
=== 'b54c47ec11f6a99dc87bdda78629e18c8a6f37a64017e42a72db399c727cb1c6', 'Original fixture bytes remain unchanged.');
|
||
$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 role has an actual source span.');
|
||
[$source, $quote] = $sourceSpan;
|
||
$first .= $label . ':' . $source['date'] . '“' . $quote . '”(' . $source['id'] . ")。\n\n";
|
||
}
|
||
$head = $first . "二、八纲\n\n";
|
||
$check = static function (string $label, string $text, bool $expected, string $code = '') use ($ledger, $index, $head, $expect): array {
|
||
$original = $text;
|
||
$guard = Guard::validate($ledger, $text);
|
||
$contract = Contract::validate($index, $head . $text, true);
|
||
$expect(($guard['valid'] && $contract['valid']) === $expected,
|
||
json_encode([$label, $text, $guard, $contract], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||
if ($code !== '') $expect(!$guard['valid'] && in_array($code, array_column($guard['issues'], 'code'), true),
|
||
'Guard retains the concrete rejection ' . $code . ': ' . $text);
|
||
$expect($text === $original, 'Pure validation never rewrites original report bytes.');
|
||
return ['case' => $label, 'text' => $text, 'expected_pair_valid' => $expected, 'guard_valid' => $guard['valid'],
|
||
'contract_valid' => $contract['valid'], 'guard_codes' => array_column($guard['issues'], 'code'),
|
||
'as_expected' => (($guard['valid'] && $contract['valid']) === $expected)];
|
||
};
|
||
$roles = [
|
||
'现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s6,不擅停换。',
|
||
'不能仅据相关阴性记录反推为纯虚无实。',
|
||
'适用条件为复核后仍以活动后气短、自汗、乏力、脉无力和少津为核心,且不存在更需优先处理的急性代谢异常、感染或器质性心肺问题。',
|
||
'如症状突然加重或出现意识异常,应及时按急症评估。',
|
||
];
|
||
$actuals = [
|
||
['患者有心悸', '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'],
|
||
];
|
||
$trimEnd = static fn (string $text): string => preg_replace('/[。?]$/u', '', $text) ?? $text;
|
||
|
||
// Exact 75-observation independent review: 42 controls plus all 33 named gaps.
|
||
$observations = [];
|
||
foreach ($roles as $role) $observations[] = $check('valid_role', $role, true);
|
||
foreach ($roles as $n => $role) foreach ($actuals as [$actual, $code]) {
|
||
$observations[] = $check('ordinary_adjacent_' . $n, $trimEnd($role) . ',' . $actual . '。', false, $code);
|
||
}
|
||
foreach ([
|
||
'现用二甲双胍0.5g、每日2次,相关记录见s6。',
|
||
'现用二甲双胍0.6g、每日2次,既有医嘱,相关记录见s6。',
|
||
'现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s5。',
|
||
'现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s6,(2026-10-22,s6)。',
|
||
'适用条件为复核后仍以已确认脉滑为核心,且不存在感染。',
|
||
'如症状突然加重或出现意识异常且患者当前有心悸,应及时按急症评估。',
|
||
] as $text) $observations[] = $check('closed_negative_control', $text, false);
|
||
foreach ([
|
||
['如出现意识异常且患者当前有心悸,应及时按急症评估。', 'NEGATIVE_TO_POSITIVE'],
|
||
['如出现意识异常且患者已确认有心悸,应及时按急症评估。', 'NEGATIVE_TO_POSITIVE'],
|
||
['如出现意识异常且患者口干未记录,应及时按急症评估。', 'POSITIVE_TO_UNKNOWN'],
|
||
['如出现意识异常且当前舌质红绛,应及时按急症评估。', 'TONGUE_FEATURE_NOT_IN_CURRENT_SOURCE'],
|
||
['如出现意识异常且查见患者有心悸,应及时按急症评估。', 'NEGATIVE_TO_POSITIVE'],
|
||
] as [$text, $code]) $observations[] = $check('nested_trigger_gap', $text, false, $code);
|
||
foreach ($roles as $n => $role) {
|
||
foreach (['[患者有心悸]', '[舌质红绛]', '【患者有心悸】', '〔患者有心悸〕'] as $tail) {
|
||
$observations[] = $check('bracket_gap_' . $n, $trimEnd($role) . ',' . $tail . '。', false);
|
||
}
|
||
foreach (['(s0)', '(s06)', '(来源:s999)'] as $tail) {
|
||
$observations[] = $check('metadata_gap_' . $n, $trimEnd($role) . ',' . $tail . '。', false, 'SOURCE_ANNOTATION_INVALID');
|
||
}
|
||
}
|
||
$expect(count($observations) === 75, 'The independent review has exactly 75 unchanged observations.');
|
||
if (in_array('--reproduce', $argv, true)) {
|
||
$gaps = array_values(array_filter($observations, static fn (array $row): bool => !$row['as_expected']));
|
||
echo json_encode(['observations' => count($observations), 'as_expected' => count($observations) - count($gaps),
|
||
'gaps' => count($gaps), 'gap_classes' => array_count_values(array_map(static fn (array $row): string =>
|
||
preg_replace('/_[0-9]+$/', '', $row['case']) ?? $row['case'], $gaps))], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||
exit(0); // Diagnostic mode reports baseline gaps; it is never an acceptance pass.
|
||
}
|
||
|
||
// Prospective short triggers stay neutral, but nested actual facts cannot use generic fallback.
|
||
foreach (['如出现意识异常,应及时按急症评估。', '如出现心悸,应先行评估。',
|
||
'如出现固定刺痛,建议复核。', '若出现意识异常,应及时评估。',
|
||
'如出现意识异常,应及时按急症评估,[参考资料]。'] as $text) $check('neutral_trigger', $text, true);
|
||
foreach (['且', '并', '并且', '同时', '还', '又', '另'] as $joiner) {
|
||
foreach ($actuals as [$actual, $code]) {
|
||
$nestedActual = str_starts_with($actual, '患者') ? $actual : '患者' . $actual;
|
||
$check('nested_short_trigger', '如出现意识异常' . $joiner . $nestedActual . ',应及时按急症评估。', false, $code);
|
||
}
|
||
}
|
||
foreach ($roles as $role) {
|
||
foreach ([['[', ']'], ['【', '】'], ['〔', '〕']] as [$open, $close]) {
|
||
foreach ($actuals as [$actual, $code]) {
|
||
$check('bracket_preserves_actual', $trimEnd($role) . ',' . $open . $actual . $close . '。', false, $code);
|
||
$check('preceding_bracket_actual', $open . $actual . $close . ',' . $role, false, $code);
|
||
}
|
||
foreach (['s0', 's06', '来源:s999', '来源:s0', 's999'] as $bad) {
|
||
$check('source_shaped_brackets', $trimEnd($role) . ',' . $open . $bad . $close . '。', false, 'SOURCE_ANNOTATION_INVALID');
|
||
}
|
||
}
|
||
foreach (['(s0,参考资料)', '(来源:s06,参考资料)', '(s999,患者有心悸)'] as $bad) {
|
||
$check('mixed_invalid_source', $trimEnd($role) . ',' . $bad . '。', false, 'SOURCE_ANNOTATION_INVALID');
|
||
}
|
||
foreach (['[[患者有心悸]]', '【〔患者有心悸〕】', '〔[舌质红绛]〕'] as $nested) {
|
||
$check('nested_bracket_actual', $trimEnd($role) . ',' . $nested . '。', false);
|
||
}
|
||
foreach (['[[s0]]', '【〔来源:s999〕】', '〔[s06]〕'] as $nested) {
|
||
$check('nested_bracket_source', $trimEnd($role) . ',' . $nested . '。', false, 'SOURCE_ANNOTATION_INVALID');
|
||
}
|
||
}
|
||
foreach (['**口干**。', '__口干__。', '`口干`。', '[口干]。', '【口干】。', '〔口干〕。',
|
||
'[参考资料](https://example.test/reference)。', '“本次询问无已知药物过敏史”(s5)。',
|
||
'口干【来源:现病史】。',
|
||
'口干(来源:current.present_illness)。', '口干[s2]。', '口干【s2】。', '口干〔s2〕。',
|
||
'口干[s2](#s2)。', '口干[现病史](#s2)。', '口干[参考](https://example.test/reference)。',
|
||
'是否存在心悸?', '固定刺痛未记录。'] as $text) $check('valid_markup_or_reference', $text, true);
|
||
// Guard supports these existing annotation forms; Contract still requires its
|
||
// original canonical quote/ID protocol. No broader Contract grammar is added.
|
||
foreach (['“本次询问无已知药物过敏史”(来源:s5)。',
|
||
'“本次询问无已知药物过敏史”(2026-10-23,[s5])。'] as $text) {
|
||
$expect(Guard::validate($ledger, $text)['valid'], 'Existing Guard source annotation semantics are retained.');
|
||
}
|
||
foreach (['[患者有心悸](https://example.test/reference)。', '口干[s0](#s0)。',
|
||
'口干[资料](#s999)。', '口干[资料](#s06)。'] as $text) $check('link_preserves_label_or_source', $text, false);
|
||
foreach (['(s999)', '(s0)', '(s06)', '(来源:s999)', '(来源:s06)',
|
||
'(2026-10-22,s6)', '(2026-13-99,s6)', '(2026-10-23,2026-10-22,s6)'] as $bad) {
|
||
$check('source_date_scope', $roles[0] . $bad . '。', false, 'SOURCE_ANNOTATION_INVALID');
|
||
}
|
||
foreach (['无五心烦热。', '[无五心烦热]。', '【无急性感染诱因】。'] as $text) {
|
||
$check('qualifier_widening', $text, false, 'NEGATION_SCOPE_WIDENED');
|
||
}
|
||
foreach (['[患者无固定刺痛]。', '【患者无腰酸耳鸣】。'] as $text) $check('missing_stays_unknown', $text, false, 'UNKNOWN_TO_NEGATIVE');
|
||
$unknown = Guard::build(['tongue' => '未记录', 'pulse' => '未记录']);
|
||
foreach (['[舌象正常]。', '【脉象无异常】。', '〔舌象无异常〕。'] as $text) {
|
||
$expect(!Guard::validate($unknown, $text)['valid'], 'Bracketed missing exam cannot become normal/negative.');
|
||
}
|
||
|
||
// Compare the actual source builder and shared source cleanup against frozen r22a.
|
||
$baselinePath = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/v43r22a_before_boundary_TcmClinicalFactStateGuard.php.snapshot';
|
||
if (is_file($baselinePath)) {
|
||
$baselineSource = (string) file_get_contents($baselinePath);
|
||
$expect(hash('sha256', $baselineSource) === '66023aee7f0a7a724b45a8bf9a7042c95db0c408a1e216b62154cc7bad133065', 'Exact r22a baseline snapshot.');
|
||
$reference = str_replace('final class TcmClinicalFactStateGuard', 'final class R22RoleBoundaryBaseline', $baselineSource);
|
||
eval(substr($reference, strlen('<?php')));
|
||
$referenceClass = 'app\\adminapi\\service\\R22RoleBoundaryBaseline';
|
||
$expect(serialize($referenceClass::build(CompleteTcmCase::current(), $submission)) === $ledgerBytes,
|
||
'Full immutable ledger equals frozen source construction.');
|
||
foreach (['口干。[患者有心悸];【患者无固定刺痛】;〔舌质红绛〕。',
|
||
'口干[s0];【来源:s999】;〔s06〕;无明显胸痛。',
|
||
'[口干](https://example.test/reference);未记录固定刺痛。'] as $sourceText) {
|
||
$sourceRecord = ['diagnosis_date' => '2026-10-23', 'present_illness' => $sourceText,
|
||
'tongue' => '舌淡红,苔少。', 'pulse' => '脉细弱。'];
|
||
$expect(serialize(Guard::build($sourceRecord)) === serialize($referenceClass::build($sourceRecord)),
|
||
'Bracket/link/source-shaped clinical source ledger is byte-identical to r22a.');
|
||
$expect((new ReflectionMethod(Guard::class, 'cleanText'))->invoke(null, $sourceText)
|
||
=== (new ReflectionMethod($referenceClass, 'cleanText'))->invoke(null, $sourceText), 'Shared source cleanText behavior is unchanged.');
|
||
}
|
||
}
|
||
$expect(serialize($ledger) === $ledgerBytes, 'Every validation preserves the complete original ledger.');
|
||
$expect(!class_exists('app\\adminapi\\service\\TcmClinicalReportStream', false)
|
||
&& !class_exists('app\\adminapi\\logic\\tcm\\DiagnosisAiLogic', false), 'Pure test loads no stream/diagnosis logic.');
|
||
if ($failures !== []) {
|
||
fwrite(STDERR, 'TCM r22 boundary: ' . count($failures) . ' failures / ' . $count . " assertions\n" . implode("\n", $failures) . "\n");
|
||
exit(1);
|
||
}
|
||
echo "TCM r22 boundary: {$count} assertions passed; all 75 independent observations correct (pure Guard/Contract; immutable source ledger).\n";
|