$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('~(.*?)~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, '生成完整中医辨证长报告。', '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);