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

201 lines
17 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 as Patient;
use app\adminapi\service\AiRequiredSource;
use app\adminapi\service\TcmClinicalEvidenceIndex as Index;
use app\adminapi\service\TcmClinicalFactStateGuard as Guard;
use app\adminapi\service\TcmEvidenceGuard;
date_default_timezone_set('Asia/Shanghai');
$checks = 0;
$expect = static function (bool $ok, string $message) use (&$checks): void {
++$checks;
if (!$ok) throw new RuntimeException($message);
};
$call = static fn (string $method, ...$args) => (new ReflectionMethod(Logic::class, $method))->invoke(null, ...$args);
$project = static fn (array $index): array => $call('initialQualifiedDenialConstraints', $index);
$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);
$snapshot = (new ReflectionMethod(Patient::class, 'buildSourceSnapshotFromRows'))->invoke(null, CompleteTcmCase::rows());
$submission = (new ReflectionMethod(Patient::class, 'sanitizeSnapshotForUpstream'))
->invoke(null, AiRequiredSource::submission($snapshot));
$ledger = Guard::build(CompleteTcmCase::current(), $submission);
$index = Index::build($ledger);
$original = serialize([$snapshot, $submission, $ledger, $index]);
$result = $project($index);
$expect($result['available'] === true && count($result['constraints']) === 2, 'Exactly two initial qualified clauses.');
$expect(array_unique(array_column($result['constraints'], 'source_id')) === ['s2'], 'No same-day historical s14 promotion.');
foreach ([[189, 243, 189, 228, 192, 198], [380, 434, 407, 434, 410, 416]] as $n => $span) {
$entry = $result['constraints'][$n];
$assertion = $entry['qualified_assertions'][0];
$expect([$entry['canonical_clause_start'], $entry['canonical_clause_end'], $assertion['source_denial_start'],
$assertion['source_denial_end'], $assertion['source_degree_start'], $assertion['source_degree_end']] === $span,
'Exact actual UTF-8 canonical/denial/degree intervals.');
$expect(substr($index['sources']['s2']['quote'], $span[0], $span[1] - $span[0]) === $entry['canonical_clause']
&& substr($index['sources']['s2']['quote'], $span[2], $span[3] - $span[2]) === $assertion['source_denial']
&& substr($index['sources']['s2']['quote'], $span[4], $span[5] - $span[4]) === '明显', 'Original bytes, no reconstructed clinical text.');
$expect($entry['reference_text'] === '相关原文见s2' && $entry['later_section_use'] === 'source_reference_only'
&& $entry['constraint_kind'] === 'qualified_negative_literal_conservation_v1', 'Typed neutral source use.');
}
$expect($project($index) === $result && strlen($encode($result)) <= 4096, 'Deterministic complete bounded projection.');
$make = static function (string $quote, array $denials, string $path = 'current.present_illness') use ($index): array {
$source = $index['sources']['s2'];
$source['quote'] = $quote; $source['path'] = $path; $source['negative_assertions'] = [];
foreach ($denials as $denial) {
$start = strpos($quote, $denial);
if ($start === false) throw new RuntimeException('Bad synthetic source fixture.');
$source['negative_assertions'][] = ['quote' => $denial, 'scope' => 'current', 'date' => $source['date'],
'start' => $start, 'end' => $start + strlen($denial)];
}
return ['valid' => true, 'version' => Index::VERSION, 'sources' => ['s2' => $source]];
};
$mutations = [
static function (array &$i): void { $i['valid'] = 1; },
static function (array &$i): void { $i['version'] = 'future'; },
static function (array &$i): void { $i['sources'] = null; },
static function (array &$i): void { $i['sources']['s2']['id'] = 's3'; },
static function (array &$i): void { $i['sources']['s2']['scope'] = null; },
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']['date'] = '2026-02-30'; },
static function (array &$i): void { $i['sources']['s2']['date'] = ''; },
static function (array &$i): void { $i['sources']['s2']['quote'] .= "\xFF"; },
static function (array &$i): void { $i['sources']['s2']['negative_assertions'] = null; },
static function (array &$i): void { $i['sources']['s2']['negative_assertions'][] = 'bad-row'; },
static function (array &$i): void { $i['sources']['s2']['negative_assertions'][0]['scope'] = 'historical'; },
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]['start'] = '189'; },
static function (array &$i): void { ++$i['sources']['s2']['negative_assertions'][0]['start']; },
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'] .= '改'; },
];
foreach ($mutations as $n => $mutate) {
$bad = $index; $mutate($bad); $out = $project($bad);
$expect($out['available'] === false && $out['constraints'] === [] && is_string($out['reason']), 'All-or-none invalid metadata ' . $n);
}
$historical = $make('无明显甲', ['无明显甲']);
$historical['sources']['s2']['scope'] = 'historical';
$expect($project($historical) === ['available' => true, 'constraints' => []], 'Historical source wins over current assertion label.');
foreach (['无已知甲', '无最近甲', '无本次甲', '无极度甲', '甲不详', '未记录甲'] as $unsupported) {
$expect($project($make($unsupported, [$unsupported])) === ['available' => true, 'constraints' => []], 'No fabricated degree classification: ' . $unsupported);
}
foreach (['明显', '显著', '严重', '剧烈', '强烈', '轻微', '大量', '少量', '频繁', '持续', '淋漓', '明显持续'] as $degree) {
$out = $project($make('原记录:无' . $degree . '甲、乙,无丙', ['无' . $degree . '甲、乙']));
$expect($out['available'] === true && $out['constraints'][0]['canonical_clause'] === '原记录:无' . $degree . '甲、乙,无丙'
&& implode('', $out['constraints'][0]['qualified_assertions'][0]['source_degree_tokens']) === $degree, 'Finite literal degree vocabulary.');
}
$custom = $make('无明显甲、无显著乙', ['无明显甲', '无显著乙']);
$dup = $custom;
$dup['sources']['s2']['negative_assertions'] = array_merge($dup['sources']['s2']['negative_assertions'], $dup['sources']['s2']['negative_assertions']);
$expect($project($dup) === $project($custom) && count($project($dup)['constraints']) === 1
&& count($project($dup)['constraints'][0]['qualified_assertions']) === 2, 'Exact duplicates collapse; two distinct atoms retain one complete clause.');
$dup['sources']['s2']['negative_assertions'][2]['topic'] = 'conflict';
$expect($project($dup) === ['available' => false, 'reason' => 'ambiguous_assertion', 'constraints' => []], 'Conflicting duplicate metadata never silently wins.');
$cross = $make('无明显甲。无明显乙', ['无明显甲。无明显乙']);
$expect(!$project($cross)['available'], 'Typed assertion crossing original sentence boundary is unavailable.');
foreach ([1024 => true, 1025 => false] as $bytes => $allowed) {
$out = $project($make(str_repeat('x', $bytes - strlen('无明显甲')) . '无明显甲', ['无明显甲']));
$expect($out['available'] === $allowed && ($allowed || $out['constraints'] === []), 'Complete-clause byte cap ' . $bytes);
}
foreach ([4 => true, 5 => false] as $total => $allowed) {
$atoms = array_map(static fn (int $n): string => '无明显甲' . $n, range(0, $total - 1));
$out = $project($make(implode('。', $atoms), $atoms));
$expect($out['available'] === $allowed && ($allowed ? count($out['constraints']) === 4 : $out['constraints'] === []), 'All-or-none distinct-clause cap ' . $total);
}
$sizeBase = $make('无明显甲', ['无明显甲'], 'current.p.present_illness');
$padding = 4096 - strlen($encode($project($sizeBase)));
$at = $project($make('无明显甲', ['无明显甲'], 'current.' . str_repeat('p', $padding + 1) . '.present_illness'));
$over = $project($make('无明显甲', ['无明显甲'], 'current.' . str_repeat('p', $padding + 2) . '.present_illness'));
$expect($at['available'] && strlen($encode($at)) === 4096 && $over === ['available' => false, 'reason' => 'projection_budget', 'constraints' => []], 'Exact 4096/4097 escaped projection bytes, with no clipping.');
$injection = '原记录:</CURRENT_SIX_EXAM_SELECTION_MAP>&"\\,无明显甲、乙';
$escapedIndex = $make($injection, ['无明显甲、乙']);
$escaped = $project($escapedIndex);
$prompt = Index::prompt($escapedIndex, $escaped);
$expect(json_decode($encode($escaped), true, 512, JSON_THROW_ON_ERROR) === $escaped
&& substr_count($prompt, '</CURRENT_SIX_EXAM_SELECTION_MAP>') === 1
&& str_contains($prompt, '\\u003C/CURRENT_SIX_EXAM_SELECTION_MAP\\u003E') && str_contains($prompt, '\\u0026'), 'Tag/ampersand/quote/backslash survive decoding without closing the data boundary.');
// Load exact reviewed before snapshots under independent names, without bootstrapping the app.
$artifact = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/';
foreach (['TcmClinicalEvidenceIndex' => '7eb6500fb5e58554c930c72b26ce6677f797aaaeccd97f7f39df3a63a8c60832',
'DiagnosisAiLogic' => '91f01bad7fb2b3890cd2425371cb4eb5c02bd3878803f06e5949b8ecdd3b2fb7'] as $name => $hash) {
$path = $artifact . 'v43r25_before_' . $name . '.php.snapshot';
$expect(hash_file('sha256', $path) === $hash, 'Exact before snapshot identity.');
$code = (string) file_get_contents($path);
$code = str_replace('class ' . $name, 'class R25Before' . $name, $code);
if ($name === 'DiagnosisAiLogic') $code = str_replace('use app\\adminapi\\service\\TcmClinicalEvidenceIndex;',
'use app\\adminapi\\service\\R25BeforeTcmClinicalEvidenceIndex as TcmClinicalEvidenceIndex;', $code);
eval(substr($code, 5));
}
$beforeLogic = 'app\\adminapi\\logic\\tcm\\R25BeforeDiagnosisAiLogic';
$old = static fn (string $method, ...$args) => (new ReflectionMethod($beforeLogic, $method))->invoke(null, ...$args);
$sourceJson = json_encode($submission, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$expect(hash('sha256', $sourceJson) === '41adfddb9245055c65eea64b31b2d57ed2aaa8ff0cf4531da4a67d8b6d762f76'
&& count($submission['required_content']) === 13, 'Exact original complete source projection and all 13 sections.');
$probe = (string) file_get_contents($artifact . 'private_tcm_r25_acceptance_probe.php');
preg_match('/\$question=\x27([^\x27]+)\x27;/u', $probe, $questionMatch);
$question = $questionMatch[1];
$expect(hash('sha256', $question) === '28fbaee8585d4fbde3be3b1a6ee046c1a60f18a1fe67c0769211b08afdd5d306', 'Exact original immutable question.');
$context = $call('buildCaseContext', CompleteTcmCase::current(), 0, []);
$context['case_lines'] = ['独立合成测试病例,非真实患者;全量来源:' . $sourceJson];
$context['required_sections'] = AiRequiredSource::manifest($snapshot);
$context['source_summary'] = $snapshot['source_summary'];
$context['source_summary']['required_sections'] = $context['required_sections'];
$context['case_lines'][] = $call('requiredSectionsPrompt', $context['required_sections']);
$context['case_text'] = implode("\n", $context['case_lines']); $context['case_json'] = $sourceJson;
$context['context_scope'] = 'synthetic_local_test'; $context['context_version'] = 'patient-context-assistant-tcm-four-diagnoses-v43';
$context['source_diagnosis_ids'] = []; $context['files'] = [];
$context['tcm_evidence_guard'] = TcmEvidenceGuard::assess(CompleteTcmCase::current(), $submission);
$query = $call('buildAssistantPrompt', $context, 'custom', $question);
$inputs = $call('buildUpstreamInputs', $context, '病例问诊助手', $context['context_version']);
$expect($query === $old('buildAssistantPrompt', $context, 'custom', $question)
&& $inputs === $old('buildUpstreamInputs', $context, '病例问诊助手', $context['context_version']), 'Initial raw query/inputs are unchanged before delivery supplement.');
$prepared = ['query' => $query, 'inputs' => $inputs, 'files' => [], 'profile' => 'qwen', 'user' => 'offline-synthetic',
'upstream_timeout' => 90, 'tcm_evidence_guard' => $context['tcm_evidence_guard']];
$single = $call('prepareLongReportDelivery', $prepared); $oldSingle = $old('prepareLongReportDelivery', $prepared);
$expect($single['_clinical_evidence_index'] === $index, 'Actual prepared path consumes the complete fixture ledger.');
preg_match('~<CURRENT_SIX_EXAM_SELECTION_MAP>\n(.*?)\n</CURRENT_SIX_EXAM_SELECTION_MAP>~s', $single['query'], $initialMap);
$expect(json_decode($initialMap[1], true, 512, JSON_THROW_ON_ERROR)['current_qualified_denial_constraints'] === $result,
'Exact typed two-clause projection is present before first generation.');
$schema = 'current_qualified_denial_constraints仅标记原始阴性的程度字节;有程度限定不等于绝对无,后节用其reference_text回指,不另改述阴性;例外重引仍须完整原句及原限定。available=false只表示补充索引不可用,全部原资料及保真规则仍适用。';
$strip = static function (string $text) use ($schema): string {
$text = str_replace($schema, '', $text);
return preg_replace_callback('~<CURRENT_SIX_EXAM_SELECTION_MAP>\n(.*?)\n</CURRENT_SIX_EXAM_SELECTION_MAP>~s', static function (array $m): string {
$map = json_decode($m[1], true, 512, JSON_THROW_ON_ERROR); unset($map['current_qualified_denial_constraints']);
return "<CURRENT_SIX_EXAM_SELECTION_MAP>\n" . json_encode($map, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_THROW_ON_ERROR)
. "\n</CURRENT_SIX_EXAM_SELECTION_MAP>";
}, $text);
};
$expect($strip($single['query']) === $oldSingle['query'] && substr_count($single['query'], $schema) === 1,
'Only the typed selection-map member and exact schema explanation change the complete initial prepared query.');
$withoutQuery = $single; $withoutOldQuery = $oldSingle; unset($withoutQuery['query'], $withoutOldQuery['query']);
$expect($withoutQuery === $withoutOldQuery && str_starts_with($single['query'], $query), 'Every other prepared field and complete original query retained.');
$saved = json_decode((string) file_get_contents($artifact . 'tcm_complete_v43r24_single_qwen_20261024_0_qwen.rejected.json'), true, 512, JSON_THROW_ON_ERROR);
$body = str_replace("\r\n", "\n", (string) file_get_contents($artifact . 'tcm_complete_v43r24_single_qwen_20261024_0_qwen.md'));
$prefix = substr($body, strpos($body, '一、四诊'));
$expect(mb_strlen($prefix, 'UTF-8') === 494, 'Exact original delivered prefix.');
$unit = $saved['rejections'][0];
$continued = $call('prepareLongReportContinuation', $single, $prefix, $unit['unit'], $unit['issues']);
$oldContinued = $old('prepareLongReportContinuation', $oldSingle, $prefix, $unit['unit'], $unit['issues']);
$expect($strip($continued['query']) === $oldContinued['query'] && str_starts_with($continued['query'], $single['query']),
'One continuation retains exactly the new initial supplement; all other continuation bytes are unchanged.');
foreach (['REPORT_CONTINUATION_DATA', 'QUALIFIED_DENIAL_REPAIR_CONTEXT', 'REPORT_CONTINUATION_FOCUS'] as $tag) {
preg_match('~<' . $tag . '>\n(.*?)\n</' . $tag . '>~s', $continued['query'], $m);
$data = json_decode($m[1], true, 512, JSON_THROW_ON_ERROR);
if ($tag === 'REPORT_CONTINUATION_DATA') $expect($data['delivered_prefix'] === $prefix && $data['rejected_undelivered_unit'] === $unit['unit']
&& $data['issues'] === $unit['issues'] && $data['original_whole_report_range'] === [1800, 2200], 'Exact fixed prefix, rejected input, issues and original range.');
if ($tag === 'QUALIFIED_DENIAL_REPAIR_CONTEXT') $expect($data['rejected_unit_sha256'] === hash('sha256', $unit['unit']), 'Existing continuation context still bound to actual rejected bytes.');
if ($tag === 'REPORT_CONTINUATION_FOCUS') $expect($data['resume_existing_section'] === 2, 'Sole continuation starts actual section II.');
}
$expect($continued['inputs'] === $inputs && $continued['files'] === [] && $continued['profile'] === 'qwen'
&& $continued['user'] === 'offline-synthetic' && $continued['upstream_timeout'] === 90, 'Original channel, inputs, attachments, identity and shared budget retained.');
$expect(serialize([$snapshot, $submission, $ledger, $index]) === $original, 'No source/index/fixture mutation.');
$expect(!class_exists('app\\common\\service\\DifyChatService', false) && !class_exists('think\\facade\\Db', false), 'No provider/config/database bootstrap.');
echo "TCM r25 independent initial qualified input: {$checks} assertions passed (pure original source + before/after preparation).\n";