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

146 lines
10 KiB
PHP

<?php
declare(strict_types=1);
// Pure request construction. No generated output, transport, database or model.
require dirname(__DIR__) . '/vendor/autoload.php';
require __DIR__ . '/fixtures/CompleteTcmCase.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\service\AiRequiredSource;
use app\adminapi\service\TcmClinicalEvidenceIndex;
use app\adminapi\service\TcmEvidenceGuard;
use app\common\service\TcmClinicalResponsePolicy;
$assertions = 0;
function evidencePromptExpect(bool $ok, string $reason): void
{
++$GLOBALS['assertions'];
if (!$ok) throw new RuntimeException($reason);
}
function evidencePromptBlock(string $text, string $tag): string
{
preg_match_all('~<' . $tag . '>(.*?)</' . $tag . '>~su', $text, $matches);
evidencePromptExpect(count($matches[1]) === 1, 'One original boundary: ' . $tag);
return $matches[1][0];
}
$originalRows = CompleteTcmCase::rows();
$snapshot = $originalRows + [
'daily_records' => ['blood_glucose_pressure' => $originalRows['blood_records'],
'diet' => $originalRows['diet_records'], 'exercise' => $originalRows['exercise_records']],
'chat_records' => ['tencent_im' => $originalRows['im_messages'], 'wechat_work' => []],
];
$differentCurrent = [
'id' => 900000081, 'diagnosis_date' => '2026-10-19',
'chief_complaint' => '右腕疼痛2天。',
'present_illness' => '自述搬物后右腕疼痛,转动时加重,食欲正常;未记录夜间疼痛和体重变化。',
'symptoms' => '望诊右腕轻度肿胀;闻诊语声清晰,未记录异常气味。问诊及切诊见本次记录。',
'tongue' => '本次面诊:舌质淡红,苔薄白。',
'pulse' => '本次医师切诊:脉弦,按之有力。',
'remark' => '独立工程资料;无附件。', 'allergy_history' => 0,
];
$differentSnapshot = ['diagnoses' => [$differentCurrent], 'doctor_notes' => [],
'daily_records' => ['blood_glucose_pressure' => [], 'diet' => [], 'exercise' => []],
'prescriptions' => [], 'video_calls' => [],
'chat_records' => ['tencent_im' => [], 'wechat_work' => []]];
$cases = [
['current' => CompleteTcmCase::current(), 'snapshot' => $snapshot, 'profile' => 'openai'],
['current' => $differentCurrent, 'snapshot' => $differentSnapshot, 'profile' => 'qwen'],
];
$reflection = new ReflectionClass(DiagnosisAiLogic::class);
$build = $reflection->getMethod('buildAssistantPrompt');
$delivery = $reflection->getMethod('prepareLongReportDelivery');
$resume = $reflection->getMethod('prepareLongReportContinuation');
$manifestPrompt = $reflection->getMethod('requiredSectionsPrompt');
$question = "生成1800至2200字完整中医辨证长报告,完整六节。\n保留全部来源、原日期与未知状态。";
foreach ($cases as $number => $case) {
$submission = AiRequiredSource::submission($case['snapshot']);
$originalSubmission = serialize($submission);
evidencePromptExpect(array_column($submission['required_content'], 'key') === array_keys(AiRequiredSource::LABELS),
'All thirteen original source categories are submitted: ' . $number);
$source = json_encode(['original_rows' => $case['snapshot'], 'required_submission' => $submission],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$guard = TcmEvidenceGuard::assess($case['current']);
evidencePromptExpect(empty($guard['restricted']), 'Both unrelated cases have actual tongue/pulse source records.');
$context = ['case_text' => $source, 'demographics' => '独立合成工程病例', 'tcm_evidence_guard' => $guard];
$query = $build->invoke(null, $context, 'custom', $question);
$prepared = ['task' => 'custom', 'query' => $query, 'profile' => $case['profile'],
'user' => 'offline-prompt-case-' . $number, 'tcm_evidence_guard' => $guard,
'inputs' => ['all_original_sources' => $source, 'required_submission' => $submission, 'opaque' => "literal\r\nbytes"],
'files' => [['type' => 'image', 'url' => 'https://synthetic.invalid/original.png']], 'upstream_timeout' => 90];
$before = serialize($prepared);
$actual = $delivery->invoke(null, $prepared);
evidencePromptExpect(serialize($prepared) === $before && serialize($submission) === $originalSubmission,
'Input preparation does not mutate any source request or source category.');
foreach (['task', 'profile', 'user', 'inputs', 'files', 'upstream_timeout', 'tcm_evidence_guard'] as $key) {
evidencePromptExpect($actual[$key] === $prepared[$key], 'Original request field survives: ' . $key);
}
foreach (['CASE_DATA', 'USER_QUESTION'] as $tag) {
evidencePromptExpect(evidencePromptBlock($actual['query'], $tag) === evidencePromptBlock($query, $tag),
'Original source/question bytes survive: ' . $tag);
}
evidencePromptExpect(trim(evidencePromptBlock($actual['query'], 'USER_QUESTION')) === $question,
'The exact original multiline question is not replaced by a generated task.');
$index = TcmClinicalEvidenceIndex::build($guard['fact_state_ledger']);
evidencePromptExpect($actual['_clinical_evidence_index'] === $index, 'The application-owned evidence index is retained.');
evidencePromptExpect(array_keys($index['sources']) === array_keys($guard['fact_state_ledger']['sources']),
'The supplemental index keeps original source IDs, not newly assigned span IDs.');
$indexPrompt = TcmClinicalEvidenceIndex::prompt($index,
$reflection->getMethod('initialQualifiedDenialConstraints')->invoke(null, $index));
evidencePromptExpect($indexPrompt !== '' && substr_count($actual['query'], $indexPrompt) === 1,
'The role protocol/index is supplied exactly once.');
evidencePromptExpect(substr_count($actual['query'], '辨证逐项举证:') === 1,
'Component evidence rules appear once, without competing copies.');
foreach (['每个脏腑限定、证名成分、治法成分及方药支持点', '一般”“正常”本身不是病理阳性',
'较宽但有据的主方向和完整报告', '津少、阴虚、虚热与阴虚火旺分别判断', '关键条件未证实则暂不列方'] as $rule) {
evidencePromptExpect(str_contains($query, $rule), 'The compact full-report prompt includes the shared principle: ' . $rule);
}
$control = str_replace([$indexPrompt, evidencePromptBlock($query, 'CASE_DATA'),
evidencePromptBlock($query, 'USER_QUESTION')], '', $actual['query']);
foreach (['生脉散', '肺脾气虚', '右腕疼痛', '藿香正气散'] as $caseSpecific) {
evidencePromptExpect(!str_contains($control, $caseSpecific), 'Control prose never prescribes a fixture diagnosis/formula: ' . $caseSpecific);
}
$manifest = AiRequiredSource::manifest($case['snapshot']);
$manifestBefore = serialize($manifest);
$availability = $manifestPrompt->invoke(null, $manifest);
evidencePromptExpect(str_contains($availability, '该栏目未提供记录(原标记:无)')
&& !preg_match('/:无(?:\r?\n|$)/u', $availability), 'Empty columns have explicit record-status wording.');
evidencePromptExpect(serialize($manifest) === $manifestBefore && serialize($submission) === $originalSubmission,
'Availability explanation does not rewrite raw payload values or manifest state.');
// This test treats already delivered bytes as opaque; it does not certify a clinical prefix.
$prefix = "一、四诊\r\n\r\n已交付原样片段。\r\n\r\n";
$sourceId = array_key_first($index['sources']);
$continuation = $resume->invoke(null, $actual, $prefix, '未交付的工程片段',
[['code' => 'EXAM_ROLE_MISMATCH', 'source_id' => $sourceId]]);
evidencePromptExpect($continuation['_clinical_evidence_index'] === $index
&& $continuation['_validated_report_prefix'] === $prefix, 'Continuation retains the same evidence index and exact fixed prefix.');
evidencePromptExpect(substr_count($continuation['query'], $indexPrompt) === 1,
'Continuation reuses the original role protocol/index without another copy.');
foreach (['inputs', 'files', 'profile', 'user'] as $key) evidencePromptExpect($continuation[$key] === $prepared[$key],
'Continuation preserves the authorized original field: ' . $key);
foreach (['CASE_DATA', 'USER_QUESTION'] as $tag) evidencePromptExpect(
evidencePromptBlock($continuation['query'], $tag) === evidencePromptBlock($query, $tag),
'Continuation cannot change original source/question bytes.');
$data = json_decode(evidencePromptBlock($continuation['query'], 'REPORT_CONTINUATION_DATA'), true, 64, JSON_THROW_ON_ERROR);
evidencePromptExpect($data['delivered_prefix'] === $prefix && $data['original_whole_report_range'] === [1800, 2200]
&& $data['remaining_suffix_character_range'] === [1800 - mb_strlen($prefix, 'UTF-8'), 2200 - mb_strlen($prefix, 'UTF-8')],
'The original whole-report range and exact prefix arithmetic survive role-repair hints.');
foreach (['EXAM_ROLE_MISMATCH', 'EXAM_SOURCE_QUOTE_INVALID', 'EVIDENCE_SOURCE_DATE_MISMATCH',
'EXAM_FACT_PROTOCOL_INVALID', 'EXAM_KNOWN_ROLE_TO_UNKNOWN', 'UNQUOTED_PATIENT_DENIAL'] as $code) {
evidencePromptExpect(str_contains($continuation['query'], $code), 'Repair explanations cover the bounded source protocol: ' . $code);
}
}
$policy = TcmClinicalResponsePolicy::reference();
evidencePromptExpect(mb_strlen($policy, 'UTF-8') <= 600, 'The global response policy stays compact in Unicode characters.');
$once = TcmClinicalResponsePolicy::augmentQuery('原始工程问题');
evidencePromptExpect(TcmClinicalResponsePolicy::augmentQuery($once) === $once
&& substr_count($once, '[TCM_CLINICAL_RESPONSE:') === 1, 'Global policy augmentation remains idempotent.');
evidencePromptExpect(str_contains($policy, '按实际采集方式归属') && str_contains($policy, '每个限定成分分别举证'),
'The bounded global rule includes role and component evidence principles.');
foreach (['ASSISTANT_PROMPT_VERSION', 'ANALYSIS_PROMPT_VERSION'] as $constant) {
evidencePromptExpect(str_ends_with($reflection->getConstant($constant), '-v43'), 'Changed prompt contracts have a fresh version: ' . $constant);
}
echo "TCM evidence-role input prompts: {$assertions} assertions passed (two unrelated source cases; offline).\n";