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

156 lines
12 KiB
PHP

<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic;
use app\adminapi\service\AiRequiredSource;
use app\adminapi\service\TcmClinicalEvidenceContract;
use app\adminapi\service\TcmClinicalEvidenceIndex;
use app\adminapi\service\TcmClinicalFactStateGuard;
use app\adminapi\service\TcmClinicalReportStream;
use app\adminapi\service\TcmConsultationPulsePolicy;
use app\adminapi\service\TcmEvidenceGuard;
use app\adminapi\service\TcmHerbalPremiseGuard;
use app\adminapi\service\TcmLongReportContract;
use app\common\service\ClinicalKnowledgeReference;
use app\common\service\TcmClinicalResponsePolicy;
$checks = [];
function r36Expect(bool $ok, string $name): void
{
$GLOBALS['checks'][] = ['name' => $name, 'passed' => $ok];
}
$current = ['id' => 36, 'diagnosis_date' => '2026-10-26',
'chief_complaint' => '口干,乏力。', 'present_illness' => '饮水后口干稍缓。',
'tongue' => '舌淡红,苔薄白。', 'pulse' => '', 'pulse_condition' => ''];
$history = ['id' => 12, 'diagnosis_date' => '2020-01-01', 'pulse' => '脉弦细。', 'tongue' => '舌红。'];
$submission = AiRequiredSource::submission(['diagnoses' => [$current, $history]]);
$before = serialize([$current, $submission]);
$guard = TcmEvidenceGuard::assess($current, $submission);
$ledger = $guard['fact_state_ledger'];
r36Expect(!$guard['restricted'] && $guard['missing'] === [], 'empty pulse alone does not force source-only review');
r36Expect($guard['pulse_assessment'] === 'excluded_by_workflow' && !isset($guard['current_pulse']), 'no active pulse examination is extracted');
foreach (['未采集', '72次/分', '脉细', '患者自述脉细', '不详'] as $raw) {
r36Expect(!TcmEvidenceGuard::assess(array_replace($current, ['pulse' => $raw]))['restricted'],
'pulse source values never gate completeness: ' . $raw);
}
r36Expect(TcmEvidenceGuard::assess(array_replace($current, ['tongue' => '']))['missing'] === ['舌象'],
'missing current tongue still independently restricts clinical generation');
r36Expect(TcmEvidenceGuard::assess(['tongue' => '舌红。'])['missing'] === ['本次症状病史'],
'missing current symptoms still independently restrict clinical generation');
r36Expect(TcmEvidenceGuard::assess(array_replace($current, ['tongue' => '']), $submission)['restricted'],
'historical tongue cannot replace a current examination');
r36Expect(serialize([$current, $submission]) === $before, 'assessment does not mutate current or full historical input');
$oldPulse = array_values(array_filter($ledger['sources'], static fn (array $source): bool => $source['scope'] === 'historical'
&& $source['field'] === 'pulse' && $source['quote'] === '脉弦细。'));
r36Expect(count($oldPulse) === 1, 'complete ledger retains original dated historical pulse bytes');
$index = TcmClinicalEvidenceIndex::build($ledger);
$indexPrompt = TcmClinicalEvidenceIndex::prompt($index);
preg_match('~<CURRENT_SIX_EXAM_SELECTION_MAP>(.*?)</CURRENT_SIX_EXAM_SELECTION_MAP>~s', $indexPrompt, $selection);
$map = json_decode($selection[1] ?? '', true);
r36Expect(count($map['roles'] ?? []) === 5 && !in_array('pulse', array_column($map['roles'] ?? [], 'role'), true),
'output selection has five roles and no pulse request');
r36Expect(str_contains($indexPrompt, '脉弦细。') && str_contains($indexPrompt, 'historical'),
'source index still contains unmodified historical pulse evidence');
$symptom = array_values(array_filter($index['sources'], static fn (array $source): bool => $source['path'] === 'current.chief_complaint'))[0];
$tongue = array_values(array_filter($index['sources'], static fn (array $source): bool => $source['path'] === 'current.tongue'))[0];
$first = "一、四诊\n望诊:未记录。\n\n闻诊:未记录。\n\n问诊:2026-10-26“口干,乏力。”({$symptom['id']})。\n\n切诊:未记录。\n\n舌象:2026-10-26“舌淡红,苔薄白。”({$tongue['id']})。\n";
r36Expect(TcmClinicalEvidenceContract::validate($index, $first, true)['valid'],
'dated five-role report passes provenance contract without a pulse row');
r36Expect(!TcmClinicalEvidenceContract::validate($index, str_replace('2026-10-26“舌', '2020-01-01“舌', $first), true)['valid'],
'date/source mismatch still fails');
r36Expect(!TcmClinicalEvidenceContract::validate($index, str_replace('口干,乏力。', '口干,乏力,无失眠。', $first), true)['valid'],
'altered symptom quotation still fails');
foreach (['脉象:待采集。', '请补采脉象。', '需诊脉后方可辨证。', '本次脉弦细。',
'未记录涩脉,故脉不涩。', '脉可见细弱。', '依据旧脉细支持本次阴虚。', 'pulse: thin'] as $bad) {
r36Expect(!TcmConsultationPulsePolicy::validate($ledger, $bad)['valid'], 'active pulse output is rejected whole: ' . $bad);
}
foreach (['请补充舌质、舌苔及寒热病史。', '原心率72次/分,仍须结合时点复核。',
'动脉检查项目及原日期待核实。', '舌下络脉尚未记录。', '冠状动脉硬化风险须结合原检查复核。',
'静脉血栓检查项目与时点待核实。', '原脉压和脉率数值须保留单位。', 'pulse rate: 72 bpm; pulse pressure: 40 mmHg。',
'原脉搏72次/分仅作为生命体征记录。'] as $ok) {
r36Expect(TcmConsultationPulsePolicy::validate($ledger, $ok)['valid'], 'independent clinical evidence remains available: ' . $ok);
}
$old = $oldPulse[0];
$historical = "历史记录:2020-01-01“脉弦细。”({$old['id']})。";
r36Expect(TcmConsultationPulsePolicy::validate($ledger, $historical)['valid'], 'exact dated historical pulse quotation survives unchanged');
r36Expect(TcmClinicalEvidenceContract::validate($index, $first . "\n" . $historical, true)['valid'],
'historical pulse quotation survives composed provenance contract');
r36Expect(TcmClinicalFactStateGuard::validate($ledger, $first . "\n" . $historical)['valid'],
'historical pulse quotation survives original full fact-state validation');
r36Expect(!TcmConsultationPulsePolicy::validate($ledger, $historical . '因此本次脉细。')['valid'],
'historical quotation cannot authorize a new current assertion');
r36Expect(!TcmConsultationPulsePolicy::validate($ledger, str_replace('2020-01-01', '2026-10-26', $historical))['valid'],
'historical pulse quote cannot lose its original date');
r36Expect(!TcmClinicalFactStateGuard::validate($ledger, '舌红绛,苔黄腻。')['valid'],
'uncollected tongue features remain rejected by original invention guard');
r36Expect(!TcmClinicalFactStateGuard::validate($ledger, '脉不涩。')['valid'],
'original pulse invention guard remains intact independently of workflow exclusion');
r36Expect(!TcmHerbalPremiseGuard::validate('六、中药方药及复核监测' . "\n" . '关键方证依据不足,推荐六味地黄丸。', $ledger)['valid'],
'excluding pulse does not validate a formula with expressly insufficient key evidence');
$sent = '';
$stream = new TcmClinicalReportStream($ledger, static function (string $part) use (&$sent): bool { $sent .= $part; return true; },
null, '', null, null, null, null, true);
r36Expect($stream->push("证候与治法须结合本次实采证据复核。\n\n"), 'scoped stream accepts evidence-conditional text');
$acceptedBefore = $sent;
r36Expect(!$stream->push("脉象未采集,请补脉后选方。\n\n") && $stream->rejected() && $sent === $acceptedBefore,
'forbidden pulse paragraph is never displayed or silently rewritten');
$reflection = new ReflectionClass(DiagnosisAiLogic::class);
$context = ['case_text' => '完整原始历史资料:2020-01-01脉弦细。', 'demographics' => '成人', 'tcm_evidence_guard' => $guard];
$forbiddenRequests = ['舌象、脉象各单列', '舌象、脉象单列', '舌象或脉象缺失', '舌脉等支持点',
'请补采脉象', '脉象详情', '"pulse":"', '"pulse_condition":"'];
foreach (array_keys($reflection->getConstant('ASSISTANT_TASKS')) as $task) {
$query = $reflection->getMethod('buildAssistantPrompt')->invoke(null, $context, $task, '请复核本病例');
r36Expect(str_contains($query, '2020-01-01脉弦细。'), 'complete raw source survives quick task: ' . $task);
r36Expect($reflection->getMethod('assistantReferenceScope')->invoke(null, ['task' => $task]) === 'consultation',
'quick task selects scoped policy: ' . $task);
foreach ($forbiddenRequests as $request) r36Expect(!str_contains($query, $request), 'no obsolete pulse output request in ' . $task . ': ' . $request);
}
foreach (['buildAnalysisPrompt', 'buildPrompt'] as $method) {
$query = $reflection->getMethod($method)->invoke(null, $context);
foreach ($forbiddenRequests as $request) r36Expect(!str_contains($query, $request), 'no obsolete pulse output request in ' . $method . ': ' . $request);
}
$query = $reflection->getMethod('buildAssistantPrompt')->invoke(null, $context, 'custom', '生成完整中医辨证长报告。');
$prepared = ['query' => $query, 'tcm_evidence_guard' => $guard];
$delivery = $reflection->getMethod('prepareLongReportDelivery')->invoke(null, $prepared);
r36Expect(str_contains($delivery['query'], '五个规范栏目名') && str_contains($delivery['query'], '“舌象:”')
&& !str_contains($delivery['query'], '舌脉单列'),
'long report and continuation contract requests only tongue as separate examination');
$shape = TcmLongReportContract::validate($first, '<USER_QUESTION>生成完整中医辨证长报告。</USER_QUESTION>', 'first');
r36Expect(!in_array('pulse', array_column($shape['issues'], 'exam'), true), 'long-report shape does not require pulse');
$draft = ['clinical_diagnosis' => '待医师复核的辨证方向', 'herbs' => [['name' => '测试药材', 'dosage' => 1, 'formula_type' => '主方']]];
$parsed = $reflection->getMethod('parsePrescriptionDraft')->invoke(null, json_encode($draft, JSON_UNESCAPED_UNICODE));
r36Expect(is_array($parsed) && !array_key_exists('pulse', $parsed) && !array_key_exists('pulse_condition', $parsed),
'valid new prescription draft has no pulse output fields');
foreach (['pulse' => '脉细', 'pulse_condition' => '未采集', 'pulse_empty' => ''] as $key => $value) {
$key = $key === 'pulse_empty' ? 'pulse' : $key;
$invalidDraft = $draft + [$key => $value];
r36Expect($reflection->getMethod('parsePrescriptionDraft')->invoke(null, json_encode($invalidDraft, JSON_UNESCAPED_UNICODE)) === null,
'obsolete pulse draft field is rejected, never stripped: ' . $key . ':' . $value);
}
$wire = ClinicalKnowledgeReference::augmentCaseQuery($query, true);
r36Expect(str_starts_with($wire, TcmClinicalResponsePolicy::consultationReference()) && str_ends_with($wire, $query),
'consultation policy is prepended without rewriting user or source bytes');
r36Expect(ClinicalKnowledgeReference::augmentCaseQuery($wire, true) === $wire, 'consultation policy is idempotent');
r36Expect(!str_contains($wire, '舌象、脉象各单列'), 'consultation transport never adds the old pulse-required policy');
r36Expect(str_contains(ClinicalKnowledgeReference::augmentCaseQuery('other product'), '舌象、脉象各单列'),
'default policy in other products is unchanged');
$missingGuard = TcmEvidenceGuard::assess(array_replace($current, ['tongue' => '']));
$review = TcmEvidenceGuard::review($missingGuard, '{"fact_ids":[]}');
r36Expect(!str_contains($review['answer'], '- 脉象:') && !str_contains($review['answer'], '补采脉象')
&& !str_contains($review['answer'], '舌苔、脉象'), 'independent evidence fallback never adds an active pulse field or collection request');
$failures = array_values(array_filter($checks, static fn (array $check): bool => !$check['passed']));
$result = ['suite' => 'TcmConsultationPulseExcludedR36Test', 'checks' => count($checks),
'passed' => count($checks) - count($failures), 'failed' => count($failures), 'failures' => $failures];
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
exit($failures === [] ? 0 : 1);