146 lines
10 KiB
PHP
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";
|