284 lines
20 KiB
PHP
284 lines
20 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;
|
||
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";
|