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

284 lines
20 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;
use app\adminapi\service\TcmClinicalReportStream;
$checks = 0;
$failures = [];
$expect = static function (bool $ok, string $message) use (&$checks, &$failures): void {
++$checks;
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', 'Original ledger identity remains unchanged.');
$expect($ledger['sources']['s10']['quote'] === '本次医师切诊:脉细弱,按之无力;不浮、不滑、不紧。', 'Exact current pulse source.');
$expect(str_contains($ledger['sources']['s2']['quote'], '未记录腰酸耳鸣或固定刺痛。'), 'Lumbar/tinnitus reference remains UNKNOWN.');
$first = "一、四诊\n\n";
foreach (Index::ROLES as $label => $role) {
$found = null;
foreach ($index['sources'] as $source) {
if ($source['scope'] !== 'current') continue;
foreach ($source['spans'] as $span) {
if ($span['role'] === $role && $span['recorded']) { $found = [$source, trim($span['quote'])]; break 2; }
}
}
if ($role === 'palpation' && $found === null) {
$first .= "切诊:其他触诊未记录,脉象见独立脉象项。\n\n";
continue;
}
$expect($found !== null, 'Each recorded role has a source span.');
[$source, $quote] = $found;
$first .= $label . ':' . $source['date'] . '“' . $quote . '”(' . $source['id'] . ")。\n\n";
}
$head = $first . "二、八纲\n\n";
$expect(Contract::validate($index, $head, true)['valid'], 'Actual six-role prefix is valid.');
$check = static function (string $label, string $text, bool $guardValid, bool $contractValid, string $code = '',
?array $otherLedger = null, bool $stream = true) use ($ledger, $index, $head, $expect): void {
$inputLedger = $otherLedger ?? $ledger;
$original = $text;
$result = Guard::validate($inputLedger, $text);
$contract = Contract::validate($index, $head . $text, true);
$expect($result['valid'] === $guardValid && $contract['valid'] === $contractValid,
$label . ': ' . json_encode([$result, $contract], JSON_UNESCAPED_UNICODE));
if ($code !== '') $expect(in_array($code, array_column($result['issues'], 'code'), true)
|| in_array($code, array_column($contract['issues'], 'code'), true), $label . ': retained ' . $code);
$expect($text === $original, $label . ': original report bytes preserved.');
if (!$stream || $otherLedger !== null) return;
foreach ([1, 7, 31] as $size) {
$visible = '';
$reader = new TcmClinicalReportStream($ledger, static function (string $delta) use (&$visible): bool {
$visible .= $delta;
return true;
}, null, $head, null, $index);
foreach (mb_str_split($text . "\n\n", $size, 'UTF-8') as $chunk) if (!$reader->push($chunk)) break;
$valid = $guardValid && $contractValid;
$expect($reader->finish() === $valid && $reader->rejected() === !$valid, $label . ': composed stream at chunk ' . $size);
if ($valid) $expect($visible === $text . "\n\n", $label . ': source/output bytes forwarded unchanged.');
}
};
$abstract = [
'不能仅据相关阴性反推为绝对无实',
'该限制不等同于证明纯虚无夹杂',
'病位不在表,属里证',
];
foreach ($abstract as $text) {
$check('closed diagnostic proposition', $text . '。', true, true);
foreach ([',患者无固定刺痛。', '且患者无固定刺痛。', '并患者无固定刺痛。', '而患者无固定刺痛。',
'患者无固定刺痛。', ',患者有固定刺痛。', ',口干未记录。', ',脉滑。', '且脉滑。', ',舌质红绛。',
',本次无明显急性感染诱因。'] as $tail) {
$check('diagnostic proposition cannot hide adjacent assertion', $text . $tail, str_contains($tail, '无明显急性感染诱因'),
!preg_match('/无|没有|未见/u', $tail) || str_contains($tail, '无力'));
}
}
foreach (['不能仅据相关阴性反推为绝对无实性皮疹。', '并非不能仅据相关阴性反推为绝对无实。',
'该限制不等同于证明纯虚无夹杂为事实。', '并非该限制不等同于证明纯虚无夹杂。',
'该限制不等同于证明纯虚无夹杂(s999)。'] as $text) {
$check('clinical nouns or outer negation have no abstract exemption', $text, str_contains($text, 's999') ? false : true,
false, 'UNQUOTED_PATIENT_DENIAL');
}
$pulse = '主证可暂定为气虚、卫表不固证,依据为动则气短、自汗活动后加重及脉细弱无力(s1、s2、s10)。';
$check('positive evidence starts after syndrome proposition', $pulse, true, true);
foreach (['脉细弱有力', '脉细弱滑无力', '脉细弱无力且脉结'] as $unsupported) {
$check('unsupported positive pulse remains rejected', str_replace('脉细弱无力', $unsupported, $pulse), false, true,
'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE');
}
$missingPulse = CompleteTcmCase::current();
$missingPulse['pulse'] = null;
$check('missing current pulse cannot use interview/historical pulse', $pulse, false, true,
'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE', Guard::build($missingPulse, $submission));
$weakOnly = CompleteTcmCase::current();
$weakOnly['pulse'] = '本次医师切诊:脉细弱。';
$check('weak pulse does not imply no force', $pulse, false, true,
'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE', Guard::build($weakOnly));
$check('pulse date mismatch', str_replace('s1、s2、s10', '2020-05-02,s10', $pulse), false, true, 'SOURCE_ANNOTATION_INVALID');
// Each explicit exam reference belongs to its own adjacent evidence, even
// when another part of the same sentence cites the right exam source.
foreach (['脉细弱无力(s3)。', '脉细弱无力(s14)。',
'卫表不固证,依据为脉细弱无力(s3)。', '卫表不固证,依据为脉细弱无力(s14)。',
'脉细弱无力(s3),气短与自汗的资料见s10。',
'气短(s10),脉细弱无力(s3)。',
'脉细弱无力(s3),脉细弱无力(s10)。',
'脉细弱无力(s10),按之无力(s3)。',
'脉细弱(s3),按之无力(s10)。',
'脉细弱无力(s3、s14),口干(s10)。',
'舌质淡红(s3)。', '舌面少津、苔少(s10)。',
'舌质淡红(s9),舌面少津(s3)。',
'舌质淡红、脉细弱无力(s9)。', '舌质淡红、脉细弱无力(s10)。',
] as $badReference) {
$check('local citation cannot borrow actual exam support elsewhere', $badReference, false, true, 'SOURCE_ANNOTATION_INVALID');
}
foreach (['脉细弱无力(s10)。', '脉细弱无力(s1、s2、s10)。',
'脉细弱无力(s10),气短(s2)。', '气短(s2),脉细弱无力(s10)。',
'脉细弱(s10),按之无力(s10)。', '舌质淡红(s9),脉细弱无力(s10)。',
'舌质淡红、脉细弱无力(s9、s10)。',
] as $validReference) $check('own current exam or correct composite citation', $validReference, true, true);
$historicalPulse = $ledger;
$historicalPulse['sources']['s40'] = $ledger['sources']['s10'];
$historicalPulse['sources']['s40']['id'] = 's40';
$historicalPulse['sources']['s40']['scope'] = 'historical';
$historicalPulse['sources']['s40']['path'] = 'history.0.pulse';
$check('same-date historical pulse role is still not current', '脉细弱无力(s40)。', false, true,
'SOURCE_ANNOTATION_INVALID', $historicalPulse);
$splitPulse = CompleteTcmCase::current();
$splitPulse['pulse'] = '本次医师切诊:脉细弱。';
$splitPulse['pulse_condition'] = '本次医师切诊:按之无力。';
$splitLedger = Guard::build($splitPulse);
$pulseId = $conditionId = '';
foreach ($splitLedger['sources'] as $source) {
if ($source['field'] === 'pulse') $pulseId = $source['id'];
if ($source['field'] === 'pulse_condition') $conditionId = $source['id'];
}
$check('pulse-field alias must actually contain every cited feature', '脉细弱无力(' . $conditionId . ')。', false, true,
'SOURCE_ANNOTATION_INVALID', $splitLedger);
$check('actual split current pulse sources compose without a synonym', '脉细弱无力(' . $pulseId . '、' . $conditionId . ')。', true, true, '', $splitLedger);
foreach (['脉细弱,无力(s14)。', '脉细弱,无力(s3)。', '舌质淡红,少津(s3)。',
'舌质淡红(s9),少津(s3)。', '脉不浮,不滑、不紧(s14)。',
'脉细弱,无力(s14),口干(s10)。', '脉细弱(s10),无力(s3)。',
'既往脉细弱无力(s10)。', '既往脉细弱无力(s10),本次脉细弱无力(s10)。'] as $badAntecedent) {
$check('established exam list and explicit historical scope require own supporting source', $badAntecedent,
false, true, 'SOURCE_ANNOTATION_INVALID');
}
foreach (['脉细弱,无力(s10)。', '舌质淡红,少津(s9)。', '舌质淡红(s9),少津(s9)。',
'脉细弱(s10),无力(s10)。'] as $goodAntecedent) {
$check('bare features retain the established local or immediately cited exam kind', $goodAntecedent, true, true);
}
$oldCurrent = CompleteTcmCase::current();
$oldCurrent['diagnosis_date'] = '2020-05-02';
$oldLedger = Guard::build($oldCurrent);
$check('an internally consistent old visit is still current to its own construction', '本次脉细弱无力(s10)。', true, true, '', $oldLedger);
$oldHistorical = $ledger;
$oldHistorical['sources']['s40'] = array_replace($ledger['sources']['s10'], ['id' => 's40', 'scope' => 'historical',
'path' => 'history.0.pulse', 'date' => '2020-05-02', 'quote' => '2020-05-02医师切诊:脉细弱无力。']);
$check('actual historical own source and date remain valid', '既往脉细弱(2020-05-02,s40),无力(s40)。', true, true, '', $oldHistorical);
$oldRelativeRecord = $oldHistorical;
$oldRelativeRecord['sources']['s40']['quote'] = '2020-05-02本次医师切诊:脉细弱无力。';
$check('historical own-date record keeps relative original 本次 in its source context', '既往脉细弱无力(2020-05-02,s40)。',
true, true, '', $oldRelativeRecord);
$oldInnerDate = $ledger;
$oldInnerDate['sources']['s10']['quote'] = '2020-05-02医师切诊:脉细弱无力。';
$check('explicit inner old exam date cannot become current through outer metadata', '本次脉细弱无力(s10)。', false, true,
'SOURCE_ANNOTATION_INVALID', $oldInnerDate);
$check('historical bare continuation cannot switch to the current source', '既往脉细弱(2020-05-02,s40),无力(s10)。', false, true,
'SOURCE_ANNOTATION_INVALID', $oldHistorical);
$syntheticMismatch = $ledger;
$syntheticMismatch['sources']['s40'] = array_replace($ledger['sources']['s10'], ['id' => 's40', 'date' => '2020-05-02']);
$check('diagnostic inconsistent current date violates existing per-kind construction metadata', '本次脉细弱无力(s40)。', false, true,
'SOURCE_ANNOTATION_INVALID', $syntheticMismatch);
// These are parser-only diagnostics, not new clinical acceptances. A new
// ordinary subject must reset pulse/tongue inheritance; without a valid
// construction date the existing trusted-ledger assumption is explicit.
$referenceGrammar = new ReflectionMethod(Guard::class, 'explicitExamReferencesSupported');
foreach (['脉细弱(s10),口干(s2),无力(s3)。', '脉细弱,腹部按之无力(s3)。',
'脉细弱(s10),腹部按之无力(s3)。', '舌质淡红(s9),口干(s2),少津(s3)。'] as $resetOnly) {
$expect($referenceGrammar->invoke(null, $ledger, $resetOnly), 'Parser-only: explicit ordinary subject ends the examination antecedent.');
}
foreach ([null, '', 'not-a-date'] as $missingAuthority) {
$unboundDate = $syntheticMismatch;
$unboundDate['field_states']['pulse']['date'] = $missingAuthority;
$expect($referenceGrammar->invoke(null, $unboundDate, '本次脉细弱无力(s40)。'),
'Parser-only diagnostic: no guessed visit-date rule when construction metadata is unavailable.');
}
$expect($referenceGrammar->invoke(null, $oldHistorical,
'脉细弱(s10),口干(2020-05-02,s40),脉细弱无力(s10)。'),
'Parser-only: another subject citation date cannot govern the next explicit examination.');
$locator = '腰肾相关资料见s2,暂不定肾虚';
$check('verified anaphor names actual missing record', $locator . '。', true, true);
$check('locator whitespace retains source verification and output bytes', '腰肾相关资料见s2 ,暂不定肾虚。', true, true);
foreach (['s3', 's10', 's14', 's999', 's0', 's2x', 's-2', 's02', 'S2'] as $badId) {
$check('anaphor needs same current source and actual lumbar item', str_replace('s2', $badId, $locator) . '。', false, true,
'SOURCE_ANNOTATION_INVALID');
}
$check('wrong locator source cannot hide behind whitespace', '腰肾相关资料见s3 ,暂不定肾虚。', false, true, 'SOURCE_ANNOTATION_INVALID');
$check('malformed locator token cannot hide a suffix after whitespace', '腰肾相关资料见s2 x,暂不定肾虚。', false, true, 'SOURCE_ANNOTATION_INVALID');
$check('anaphor date mismatch', $locator . '(2020-05-02,s2)。', false, true, 'SOURCE_ANNOTATION_INVALID');
$noLumbar = CompleteTcmCase::current();
$noLumbar['present_illness'] = '神疲乏力,未记录固定刺痛。';
$check('locator cannot invent a missing lumbar subject', $locator . '。', false, true,
'SOURCE_ANNOTATION_INVALID', Guard::build($noLumbar));
foreach ([',患者无腰酸耳鸣。', '且患者无固定刺痛。', ',患者有固定刺痛。', ',脉滑。'] as $tail) {
$check('locator cannot hide actual findings', $locator . $tail, false, !preg_match('/无/u', $tail));
}
$future = '适用条件:复核症状仍以活动后乏力、气短、自汗为主,口干少量频饮及少津舌持续存在,且无需要优先处理的急性病因';
$check('future acute-cause applicability criterion', $future . '。', true, true);
$check('future recheck without optional persistence criterion',
'适用条件:复核症状仍以活动后乏力、气短、自汗为主,且无需要优先处理的急性病因。', true, true);
foreach ([',患者无固定刺痛。', '且患者无固定刺痛。', '并患者无固定刺痛。', '患者无固定刺痛。',
',患者有固定刺痛。', ',口干未记录。', ',脉滑。', '且舌质红绛。'] as $tail) {
$check('future frame owns no adjoining actual finding', $future . $tail, false, !preg_match('/无/u', $tail));
}
foreach ([str_replace('复核症状', '本次复核症状', $future), str_replace('且无需要', '且本次无需要', $future),
str_replace('且无需要', '且已确认无需要', $future), str_replace('复核症状仍以', '复核患者自述仍以', $future)] as $actual) {
$check('current/completed exclusion cannot borrow condition heading', $actual . '。', true, false, 'UNQUOTED_PATIENT_DENIAL');
}
$check('unsupported pulse inside future target', str_replace('口干少量频饮及少津舌', '脉滑', $future) . '。', false, true,
'PULSE_FEATURE_NOT_IN_CURRENT_SOURCE');
$q = '表里:本次症状逐渐出现,无明显急性感染诱因(s2),病位不在表,属里证。';
$check('actual qualified Q inference still needs quoted denial protocol', $q, true, false, 'UNQUOTED_PATIENT_DENIAL');
$check('quoted full fragment permits diagnostic inference', '表里:本次“此次症状逐渐出现,无明显急性感染诱因”(s2),病位不在表,属里证。', true, true);
$check('actual Q degree deletion remains rejected', str_replace('无明显急性感染', '无急性感染', $q), false, false,
'NEGATION_SCOPE_WIDENED');
$check('unknown is not absent', '患者无腰酸耳鸣或固定刺痛。', false, false, 'UNKNOWN_TO_NEGATIVE');
// Exact retained r24 units are read in full, never substituted or rewritten.
$artifactRoot = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/';
$actualFiles = [
'tcm_complete_v43r24_single_openai_20261024_0_openai.rejected.json' => [[true, true], [true, true]],
'tcm_complete_v43r24_double_openai_20261024_0_openai.rejected.json' => [[true, true]],
'tcm_complete_v43r24_double_openai_20261024_1_openai.rejected.json' => [[true, true]],
'tcm_complete_v43r24_openai_deadline_20261024_openai_next_full90_openai.rejected.json' => [[true, true]],
'tcm_complete_v43r24_single_qwen_20261024_0_qwen.rejected.json' => [[false, false], [false, false]],
'tcm_complete_v43r24_double_qwen_20261024_0_qwen.rejected.json' => [[false, false], [false, false]],
'tcm_complete_v43r24_double_qwen_20261024_1_qwen.rejected.json' => [[false, false], [true, false]],
];
$actualHashes = [
'tcm_complete_v43r24_single_openai_20261024_0_openai.rejected.json' => '39056e3750c9b385f543db7da351b47657d997c5ac705e48cbd93f088b4c178e',
'tcm_complete_v43r24_double_openai_20261024_0_openai.rejected.json' => '0c03a7a8a764179e4535ea40c0c608e811598a2259ae9aefec3c3bdc3959f543',
'tcm_complete_v43r24_double_openai_20261024_1_openai.rejected.json' => 'e1e5a7a26aae0f7443b00c6dc1f4db2ad0f97006fc84fd91bb0171423c21c252',
'tcm_complete_v43r24_openai_deadline_20261024_openai_next_full90_openai.rejected.json' => '084a59af11c14b06d6a9d5664db53a87617c5e0083bcd6640b52c2c8d1d7a21c',
'tcm_complete_v43r24_single_qwen_20261024_0_qwen.rejected.json' => '55ee0125d1cec5ccc344d9fef747e118d9e55b8147fd901e819d8a4a38408b8a',
'tcm_complete_v43r24_double_qwen_20261024_0_qwen.rejected.json' => '2e7cce04ca83526a17c3cda27764b2386e192df35b36ef37f3dd47f622f561ba',
'tcm_complete_v43r24_double_qwen_20261024_1_qwen.rejected.json' => '0d803a89c349f10f1317080c7232379c59b7e1f291e514b373e63960819d4b85',
];
foreach ($actualFiles as $name => $outcomes) {
if (!is_file($artifactRoot . $name)) continue;
$before = hash_file('sha256', $artifactRoot . $name);
$expect($before === $actualHashes[$name], 'Exact original r24 rejection file identity: ' . $name);
$saved = json_decode((string) file_get_contents($artifactRoot . $name), true, 512, JSON_THROW_ON_ERROR);
$expect(count($saved['rejections']) === count($outcomes), 'Expected complete retained units: ' . $name);
foreach ($saved['rejections'] as $i => $unit) {
[$gv, $cv] = $outcomes[$i];
$check('exact retained ' . $name . ' / ' . $i, $unit['unit'], $gv, $cv);
}
$expect(hash_file('sha256', $artifactRoot . $name) === $before, 'Original result artifact unchanged: ' . $name);
}
$expect(serialize($ledger) === $ledgerBytes, 'Every source ledger byte is preserved across all validations.');
$expect(!class_exists('app\\adminapi\\logic\\tcm\\DiagnosisAiLogic', false), 'No inference/runtime logic loaded.');
if ($failures !== []) {
fwrite(STDERR, 'TCM r25 role/pulse: ' . count($failures) . ' failures / ' . $checks . " assertions\n- " . implode("\n- ", $failures) . "\n");
exit(1);
}
echo "TCM r25 role/pulse: {$checks} assertions passed (pure Guard/Contract + composed bounded streaming; no model/network).\n";