269 lines
20 KiB
PHP
269 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\DiagnosisAiLogic as Logic;
|
||
use app\adminapi\logic\tcm\PatientAiReportLogic;
|
||
use app\adminapi\service\AiRequiredSource;
|
||
use app\adminapi\service\TcmClinicalEvidenceIndex as Index;
|
||
use app\adminapi\service\TcmClinicalFactStateGuard as Guard;
|
||
|
||
// Pure source/array fixtures and retained rejected bytes. No application bootstrap or transport.
|
||
$checks = 0;
|
||
$expect = static function (bool $ok, string $message) use (&$checks): void {
|
||
++$checks;
|
||
if (!$ok) throw new RuntimeException($message);
|
||
};
|
||
$contextMethod = new ReflectionMethod(Logic::class, 'qualifiedDenialContinuationContext');
|
||
$clauseMethod = new ReflectionMethod(Logic::class, 'canonicalDenialClause');
|
||
$context = static fn (array $index, string $rejected, array $issues, int $section = 2): ?array =>
|
||
$contextMethod->invoke(null, $index, $rejected, $issues, $section);
|
||
$clause = static fn (array $source, array $negative): ?array => $clauseMethod->invoke(null, $source, $negative);
|
||
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_THROW_ON_ERROR;
|
||
$encode = static fn (array $data): string => json_encode($data, $flags);
|
||
$issue = [['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's2']];
|
||
$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);
|
||
$original = serialize([$ledger, $index, $snapshot, $submission]);
|
||
$verify = static function (array $data, array $indexed, string $rejected) use ($expect, $encode): void {
|
||
$expect($data['kind'] === 'qualified_denial_repair_context_v1'
|
||
&& $data['selection_kind'] === 'literal_leading_degree_deletion_candidate'
|
||
&& $data['rejected_unit_sha256'] === hash('sha256', $rejected), 'Typed candidate and exact rejected-unit binding.');
|
||
$expect(strlen($encode($data)) <= 4096
|
||
&& count($data['matched_candidates']) + count($data['related_qualified_source_clauses']) <= 4,
|
||
'All serialized supplement and clause-count bounds hold.');
|
||
$keys = [];
|
||
foreach (array_merge($data['matched_candidates'], $data['related_qualified_source_clauses']) as $entry) {
|
||
$source = $indexed['sources'][$entry['source_id']];
|
||
$key = $entry['source_id'] . ':' . $entry['canonical_clause_start'] . ':' . $entry['canonical_clause_end'];
|
||
$expect(!isset($keys[$key]), 'Canonical source intervals are unique.');
|
||
$keys[$key] = true;
|
||
$expect($entry['source_scope'] === 'current' && $source['scope'] === 'current'
|
||
&& $entry['source_date'] === $source['date'] && $entry['source_path'] === $source['path']
|
||
&& $entry['source_field'] === $source['field'] && str_starts_with($entry['source_path'], 'current.')
|
||
&& $entry['reference_text'] === '相关原文见' . $entry['source_id'], 'Exact current source identity and neutral locator.');
|
||
foreach (['source_denial' => ['source_denial_start', 'source_denial_end'],
|
||
'canonical_clause' => ['canonical_clause_start', 'canonical_clause_end']] as $field => [$start, $end]) {
|
||
$expect(substr($source['quote'], $entry[$start], $entry[$end] - $entry[$start]) === $entry[$field],
|
||
'Exact original byte slice: ' . $field);
|
||
}
|
||
$expect(substr($source['quote'], $entry['source_degree_start'], $entry['source_degree_end'] - $entry['source_degree_start'])
|
||
=== implode('', $entry['source_degree_tokens']), 'Every removed degree token is verified against original source bytes.');
|
||
$expect(strlen($entry['canonical_clause']) <= 1024, 'A canonical fragment is complete and within its byte cap.');
|
||
}
|
||
foreach ($data['matched_candidates'] as $entry) {
|
||
$expect(strlen($entry['rejected_phrase']) <= 128
|
||
&& substr($rejected, $entry['rejected_phrase_start'], $entry['rejected_phrase_end'] - $entry['rejected_phrase_start'])
|
||
=== $entry['rejected_phrase'], 'Actual rejected lexical occurrence, with exact byte bounds.');
|
||
}
|
||
foreach ($data['related_qualified_source_clauses'] as $entry) {
|
||
$expect(!array_key_exists('rejected_phrase', $entry), 'Related source clauses make no allegation of an actual rejected match.');
|
||
}
|
||
};
|
||
|
||
$retained = [
|
||
'tcm_complete_v43r21_cap8_single_qwen_20261024_0_qwen' => [
|
||
'c797ceae0948f5f6125c15ea1ae06f31c43fe7cac7be37ebdcd692512c81f571',
|
||
'50792928f9af6b8308f710d6c866168c19974556f1ce22057b01c6087fe9cacb'],
|
||
'tcm_complete_v43r21_cap8_mixed_c4_20261024_0_qwen' => [
|
||
'c797ceae0948f5f6125c15ea1ae06f31c43fe7cac7be37ebdcd692512c81f571',
|
||
'10eed19c3fbc08e6ef249010103585923b4a115b37f9b1db16d33416f881fb14'],
|
||
'tcm_complete_v43r21_cap8_mixed_c4_20261024_1_qwen' => [
|
||
'0a3573b45bef4646e4cc35e2473ead136d901a419ce35682809d50ecf2364991',
|
||
'700107dd6ba9dcba66ee1f2cc6af4f3f2e3605dfbb79ecdff2edd8e9c1a55363'],
|
||
'tcm_complete_v43r21_cap8_qwen_deadline_20261024_qwen_next_full90_qwen' => [
|
||
'0a3573b45bef4646e4cc35e2473ead136d901a419ce35682809d50ecf2364991',
|
||
'1d4a89b846afbd9ce5a539558eb416d5829a7c70eae8a21ec46d75734efc92a3'],
|
||
];
|
||
$retainedCount = 0;
|
||
foreach ($retained as $stem => $hashes) {
|
||
$base = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/' . $stem;
|
||
$expect(is_file($base . '.rejected.json') && is_file($base . '.md'), 'Required retained fixture exists: ' . $stem);
|
||
$saved = json_decode((string) file_get_contents($base . '.rejected.json'), true, 512, JSON_THROW_ON_ERROR);
|
||
$body = (string) file_get_contents($base . '.md');
|
||
$start = strpos($body, '一、四诊');
|
||
$expect($start !== false && count($saved['rejections']) === 2, 'Exact two retained units and clinical-prefix boundary.');
|
||
$prefix = substr($body, $start);
|
||
$expect(mb_strlen($prefix, 'UTF-8') === 510 && mb_strlen(str_replace("\r\n", "\n", $prefix), 'UTF-8') === 494,
|
||
'Saved Markdown has 510 characters with CRLF; its LF representation matches the recorded 494-character clinical prefix.');
|
||
foreach ($saved['rejections'] as $number => $entry) {
|
||
++$retainedCount;
|
||
$expect(hash('sha256', $entry['unit']) === $hashes[$number], 'Unmodified retained rejected bytes: ' . $stem . '#' . $number);
|
||
$data = $context($index, $entry['unit'], $entry['issues']);
|
||
$expect($data !== null && $context($index, $entry['unit'], $entry['issues']) === $data, 'Real issue-driven pairing is deterministic.');
|
||
$verify($data, $index, $entry['unit']);
|
||
$rows = array_merge($data['matched_candidates'], $data['related_qualified_source_clauses']);
|
||
$expect(array_unique(array_column($rows, 'source_id')) === ['s2'], 'Historical s14 never becomes current repair evidence.');
|
||
$infection = array_values(array_filter($data['matched_candidates'], static fn (array $row): bool =>
|
||
$row['rejected_phrase'] === '无急性感染诱因'));
|
||
$expect(count($infection) === 1 && $infection[0]['source_denial_start'] === 407
|
||
&& $infection[0]['source_denial_end'] === 434 && $infection[0]['canonical_clause_start'] === 380
|
||
&& $infection[0]['canonical_clause_end'] === 434
|
||
&& $infection[0]['canonical_clause'] === '此次症状逐渐出现,无明显急性感染诱因', 'Canonical infection fragment and original byte intervals.');
|
||
$heat = array_values(array_filter($rows, static fn (array $row): bool => $row['canonical_clause_start'] === 189));
|
||
$expect(count($heat) === 1 && $heat[0]['canonical_clause_end'] === 243
|
||
&& $heat[0]['canonical_clause'] === '无明显五心烦热、潮热盗汗,无畏寒肢冷', 'Complete qualified list is retained without comma/enumeration cropping.');
|
||
$expect(count($data['matched_candidates']) === (str_contains($entry['unit'], '无五心烦热') ? 2 : 1),
|
||
'Only actual supported lexical candidates match, including a following 及 separator.');
|
||
if ($number === 0) $expect(count($data['related_qualified_source_clauses']) === 1,
|
||
'An infection match exposes the related source qualifier before another generation error.');
|
||
$guard = Guard::validate($ledger, $prefix . $entry['unit']);
|
||
$expect(!$guard['valid'] && in_array('NEGATION_SCOPE_WIDENED', array_column($guard['issues'], 'code'), true),
|
||
'The real guard still rejects every unchanged retained unit appended to its actual prefix.');
|
||
}
|
||
}
|
||
$expect($retainedCount === 8, 'All eight retained Qwen rejected units ran; none silently skipped.');
|
||
|
||
$probe = '无急性感染诱因';
|
||
foreach (['无明显急性感染诱因。', '未见急性感染诱因。', '并无急性感染诱因。', '无感染征象。',
|
||
'无急性感染诱因加重。', '无明显五心烦热。', '只有已记录阳性事实。'] as $text) {
|
||
$expect($context($index, $text, $issue) === null, 'No canonical, changed-operator, synonym, extended-topic or absent probe match: ' . $text);
|
||
}
|
||
foreach ([[], [['code' => 'NEGATIVE_NOT_IN_SOURCE', 'source_id' => 's2']],
|
||
[['code' => 'FUTURE_CODE', 'source_id' => 's2']], [['code' => '', 'source_id' => 's2']]] as $otherIssues) {
|
||
$expect($context($index, $probe, $otherIssues) === null, 'An unrelated issue cannot create a pairing.');
|
||
}
|
||
foreach ([0, 1, 7] as $section) $expect($context($index, $probe, $issue, $section) === null, 'Only later report sections receive this supplement.');
|
||
foreach ([2, 3, 4, 5, 6] as $section) $expect($context($index, $probe, $issue, $section) !== null, 'Existing sections 2–6 retain the same data mechanism.');
|
||
$expect($context($index, $probe, [['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's14']]) === null,
|
||
'Historical source identity wins over its current assertion and same-day duplicate text.');
|
||
$expect($context($index, $probe . ',' . $probe, $issue) === null, 'Repeated identical literal occurrence is ambiguous, so the complete supplement is omitted.');
|
||
|
||
$mutations = [
|
||
static function (array &$i): void { $i['valid'] = false; },
|
||
static function (array &$i): void { $i['valid'] = 1; },
|
||
static function (array &$i): void { $i['version'] = 'future-version'; },
|
||
static function (array &$i): void { unset($i['sources']['s2']); },
|
||
static function (array &$i): void { $i['sources']['s2']['id'] = 's3'; },
|
||
static function (array &$i): void { $i['sources']['s2']['record_state'] = 'unknown'; },
|
||
static function (array &$i): void { $i['sources']['s2']['path'] = 'history.0.present_illness'; },
|
||
static function (array &$i): void { $i['sources']['s2']['field'] = 'past_history'; },
|
||
static function (array &$i): void { $i['sources']['s2']['quote'] .= "\xFF"; },
|
||
static function (array &$i): void { $i['sources']['s2']['date'] = '2026-02-30'; },
|
||
static function (array &$i): void { $i['sources']['s2']['date'] = ''; },
|
||
static function (array &$i): void { $i['sources']['s2']['negative_assertions'][0]['date'] = '2026-10-22'; },
|
||
static function (array &$i): void { $i['sources']['s2']['negative_assertions'][0]['scope'] = 'historical'; },
|
||
static function (array &$i): void { ++$i['sources']['s2']['negative_assertions'][0]['start']; },
|
||
static function (array &$i): void { $i['sources']['s2']['negative_assertions'][0]['start'] = '123'; },
|
||
static function (array &$i): void { $i['sources']['s2']['negative_assertions'][0]['start'] = -1; },
|
||
static function (array &$i): void { $i['sources']['s2']['negative_assertions'][0]['end'] = PHP_INT_MAX; },
|
||
static function (array &$i): void { $i['sources']['s2']['negative_assertions'][0]['quote'] .= '改'; },
|
||
static function (array &$i): void { $i['sources']['s2']['negative_assertions'] = null; },
|
||
static function (array &$i): void { $i['sources']['s2']['negative_assertions'][] = 'bad-row'; },
|
||
];
|
||
foreach ($mutations as $number => $mutate) {
|
||
$bad = $index;
|
||
$mutate($bad);
|
||
$expect($context($bad, $probe, $issue) === null, 'Malformed/stale affected metadata omits the complete supplement: ' . $number);
|
||
}
|
||
foreach (['s0', 's02', 's999', 's2</QUALIFIED_DENIAL_REPAIR_CONTEXT>', ''] as $id) {
|
||
$expect($context($index, $probe, [['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => $id]]) === null, 'Invalid or unknown source ID is not inferred.');
|
||
}
|
||
$expect($context($index, $probe . "\xFF", $issue) === null, 'Invalid UTF-8 rejected input is omitted.');
|
||
$incomplete = $index;
|
||
$incomplete['sources']['s99'] = $index['sources']['s2'];
|
||
$incomplete['sources']['s99']['id'] = 's99';
|
||
$incomplete['sources']['s99']['negative_assertions'] = [];
|
||
$expect($context($incomplete, $probe, array_merge($issue, [['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's99']])) === null,
|
||
'An affected current source without indexed denials cannot yield a partially claimed supplement from another source.');
|
||
|
||
$makeIndex = static function (string $quote, array $denials, string $path = 'current.present_illness'): array {
|
||
$source = ['id' => 's2', 'path' => $path, 'field' => 'present_illness', 'scope' => 'current',
|
||
'date' => '2026-10-23', 'quote' => $quote];
|
||
return Index::build(['sources' => ['s2' => $source], 'assertions' => array_map(static fn (string $denial): array =>
|
||
['source_id' => 's2', 'state' => 'negative', 'scope' => 'current', 'date' => '2026-10-23', 'quote' => $denial], $denials)]);
|
||
};
|
||
$negative = '无明显甲、乙,无丙';
|
||
foreach (['问诊记录:' . $negative, " \t问诊记录:" . $negative . " \t",
|
||
'前句。问诊记录:' . $negative . ';后句', '前句;问诊记录:' . $negative . ';后句',
|
||
"前句\r\n问诊记录:" . $negative . "\r\n后句", '问诊记录:' . $negative . '。后句'] as $quote) {
|
||
$custom = $makeIndex($quote, ['无明显甲、乙,']);
|
||
$row = $clause($custom['sources']['s2'], $custom['sources']['s2']['negative_assertions'][0]);
|
||
$expect($row !== null && $row['canonical_clause'] === '问诊记录:' . $negative,
|
||
'Complete source/sentence/semicolon/CRLF fragment retains labels, comma and enumeration.');
|
||
$expect(substr($quote, $row['canonical_clause_start'], $row['canonical_clause_end'] - $row['canonical_clause_start'])
|
||
=== $row['canonical_clause'], 'Whitespace trimming adjusts byte boundaries, preserving an exact original slice.');
|
||
}
|
||
$cross = $makeIndex('无明显甲。无明显乙', ['无明显甲。无明显乙']);
|
||
$expect($context($cross, '无甲。', $issue) === null, 'A typed assertion crossing original canonical boundaries is unreliable.');
|
||
foreach (['无已知甲', '无最近甲', '无本次甲'] as $denial) {
|
||
$custom = $makeIndex($denial, [$denial]);
|
||
$expect($context($custom, '无甲。', $issue) === null, 'Known/time/field qualifier deletion is outside the finite lexical mechanism.');
|
||
}
|
||
foreach (['明显', '显著', '严重', '剧烈', '强烈', '轻微', '大量', '少量', '频繁', '持续', '淋漓', '明显持续'] as $degree) {
|
||
$custom = $makeIndex('问诊记录:无' . $degree . '甲、乙', ['无' . $degree . '甲、乙']);
|
||
$data = $context($custom, '无甲及乙。', $issue);
|
||
$expect($data !== null && implode('', $data['matched_candidates'][0]['source_degree_tokens']) === $degree,
|
||
'Finite leading degree-token vocabulary and sequences: ' . $degree);
|
||
$verify($data, $custom, '无甲及乙。');
|
||
}
|
||
$custom = $makeIndex('无明显甲', ['无明显甲']);
|
||
$duplicated = $custom;
|
||
$duplicated['sources']['s2']['negative_assertions'] = array_merge(
|
||
$duplicated['sources']['s2']['negative_assertions'], $duplicated['sources']['s2']['negative_assertions']);
|
||
$expect($context($custom, '无甲', $issue) === $context($duplicated, '无甲', array_merge($issue, $issue)),
|
||
'Duplicate issues and typed assertion rows deduplicate without changing the resulting context.');
|
||
$ambiguous = $custom;
|
||
$ambiguous['sources']['s3'] = $ambiguous['sources']['s2'];
|
||
$ambiguous['sources']['s3']['id'] = 's3';
|
||
$expect($context($ambiguous, '无甲', array_merge($issue, [['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's3']])) === null,
|
||
'An occurrence bound to distinct eligible source clauses is omitted as ambiguous.');
|
||
|
||
// Exactly-at and one-byte-over limits are input supplement caps, never original source/output truncation.
|
||
$atScan = '无甲' . str_repeat(' ', 8192 - strlen('无甲'));
|
||
$expect($context($custom, $atScan, $issue) !== null && $context($custom, $atScan . ' ', $issue) === null,
|
||
'Rejected-unit scan accepts exactly 8192 bytes and omits all data at 8193.');
|
||
foreach ([128 => true, 129 => false] as $bytes => $allowed) {
|
||
$topic = str_repeat('x', $bytes - strlen('无'));
|
||
$bounded = $makeIndex('无明显' . $topic, ['无明显' . $topic]);
|
||
$expect(($context($bounded, '无' . $topic, $issue) !== null) === $allowed, 'Selection phrase byte cap: ' . $bytes);
|
||
}
|
||
foreach ([1024 => true, 1025 => false] as $bytes => $allowed) {
|
||
$bounded = $makeIndex(str_repeat('x', $bytes - strlen('无明显甲')) . '无明显甲', ['无明显甲']);
|
||
$expect(($context($bounded, '无甲', $issue) !== null) === $allowed, 'Uncropped canonical clause byte cap: ' . $bytes);
|
||
}
|
||
foreach ([4 => true, 5 => false] as $total => $allowed) {
|
||
$denials = array_map(static fn (int $n): string => '无明显甲' . $n, range(0, $total - 1));
|
||
$bounded = $makeIndex(implode('。', $denials), $denials);
|
||
$data = $context($bounded, '无甲0。', $issue);
|
||
$expect(($data !== null) === $allowed, 'All-or-none canonical clause count: ' . $total);
|
||
if ($data !== null) $expect(count($data['related_qualified_source_clauses']) === 3, 'All three related clauses are present at the limit.');
|
||
}
|
||
$sizeBase = $makeIndex('无明显甲', ['无明显甲'], 'current.p.present_illness');
|
||
$sizeContext = $context($sizeBase, '无甲', $issue);
|
||
$padding = 4096 - strlen($encode($sizeContext));
|
||
$expect($padding > 0, 'Serialized-byte boundary fixture has positive padding.');
|
||
$atSize = $makeIndex('无明显甲', ['无明显甲'], 'current.' . str_repeat('p', $padding + 1) . '.present_illness');
|
||
$overSize = $makeIndex('无明显甲', ['无明显甲'], 'current.' . str_repeat('p', $padding + 2) . '.present_illness');
|
||
$expect(strlen($encode($context($atSize, '无甲', $issue))) === 4096 && $context($overSize, '无甲', $issue) === null,
|
||
'Exactly 4096 serialized escaped bytes survive; 4097 omits the entire supplement.');
|
||
|
||
$injection = "问诊记录:引号\"、反斜杠\\、</QUALIFIED_DENIAL_REPAIR_CONTEXT>&,无明显甲、乙\r\n后句";
|
||
$escapedIndex = $makeIndex($injection, ['无明显甲、乙']);
|
||
$escapedRejected = "</QUALIFIED_DENIAL_REPAIR_CONTEXT>&\"\\\r\n无甲及乙。";
|
||
$escaped = $context($escapedIndex, $escapedRejected, $issue);
|
||
$expect($escaped !== null && json_decode($encode($escaped), true, 512, JSON_THROW_ON_ERROR) === $escaped,
|
||
'Quotes, backslashes, CRLF and original strings survive exact JSON decoding.');
|
||
$expect(str_contains($encode($escaped), '\\u003C/QUALIFIED_DENIAL_REPAIR_CONTEXT\\u003E')
|
||
&& str_contains($encode($escaped), '\\u0026') && !str_contains($encode($escaped), '</QUALIFIED_DENIAL_REPAIR_CONTEXT>'),
|
||
'Escaped source data cannot close its boundary.');
|
||
$verify($escaped, $escapedIndex, $escapedRejected);
|
||
|
||
foreach (['患者有心悸。', '患者无固定刺痛。', '舌质红绛。', '脉滑。', '无五心烦热。'] as $text) {
|
||
$expect(!Guard::validate($ledger, $text)['valid'], 'Adjacent unsupported facts still fail the unchanged real guard: ' . $text);
|
||
}
|
||
$wrongSource = Guard::validate($ledger, '现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s999。');
|
||
$expect(!$wrongSource['valid'] && in_array('SOURCE_ANNOTATION_INVALID', array_column($wrongSource['issues'], 'code'), true),
|
||
'An unrelated invalid source annotation still fails the real guard.');
|
||
$expect(serialize([$ledger, $index, $snapshot, $submission]) === $original, 'All original source arrays remain unchanged.');
|
||
$expect(!class_exists('app\\common\\service\\DifyChatService', false)
|
||
&& !class_exists('app\\adminapi\\service\\TcmClinicalReportStream', false)
|
||
&& !class_exists('think\\facade\\Db', false), 'No model/provider, stream, configuration or DB execution was loaded.');
|
||
echo "TCM qualified-denial continuation context: {$checks} assertions passed; all 8 retained units still rejected (pure fixtures, no model/network).\n";
|