90, 'models' => ['qwen' => ['name' => 'offline-model', 'label' => '离线模型']]]; } class DiagnosisLogic { public static function detail($params, $adminInfo): array { return $GLOBALS['r36Current']; } } class PatientAiReportLogic { public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array { return $GLOBALS['r36Longitudinal']; } public static function compactSourceForPrompt(...$args): array { throw new \RuntimeException('Unexpected compaction.'); } } } namespace app\common\service { class DifyChatService { public static array $calls = []; public static string $reply = ''; public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array { self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options'); return ['ok' => true, 'content' => self::$reply]; } public static function streamChat($profile, $inputs, $query, $user, $delta, $abort, $files = [], $options = []): array { self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options'); if ($delta(self::$reply . "\n\n") === false) return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED']; return ['ok' => true, 'content' => self::$reply . "\n\n"]; } } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\adminapi\logic\tcm\DiagnosisAiLogic; use app\adminapi\service\AiRequiredSource; use app\adminapi\service\TcmClinicalFactStateGuard; use app\adminapi\service\TcmConsultationPulsePolicy; use app\common\service\ClinicalKnowledgeReference; use app\common\service\DifyChatService; $checks = []; function r36bExpect(bool $ok, string $name): void { $GLOBALS['checks'][] = ['name' => $name, 'passed' => $ok]; } function r36bAnalysis(string $diagnosis, string $treatment = '辨证及用药仍须医师结合实采证据复核。', array $risks = []): ?array { DifyChatService::$reply = json_encode(['diagnosis_advice' => $diagnosis, 'treatment_advice' => $treatment, 'risk_assessment' => $risks], JSON_UNESCAPED_UNICODE); return DiagnosisAiLogic::analysis(36, 7, []); } $r36Current = ['id' => 36, 'patient_id' => 99, 'age' => 42, 'diagnosis_date' => '2026-10-26', 'chief_complaint' => '口干、乏力。', 'tongue' => '舌淡红,苔薄白。', 'pulse' => '脉细。', 'pulse_condition' => '']; $sections = []; $metadata = []; foreach (AiRequiredSource::LABELS as $key => $label) { $sections[] = ['key' => $key, 'label' => $label, 'value' => [['marker' => 'R36B_' . $key]]]; $metadata[] = ['key' => $key, 'label' => $label, 'count' => 1, 'availability' => 'present', 'status' => 'prepared']; } foreach ($sections as &$section) if ($section['key'] === 'diagnosis_info') { $section['value'] = [$r36Current, ['id' => 1, 'diagnosis_date' => '2020-01-01', 'pulse' => '脉弦细。']]; } unset($section); $r36Longitudinal = ['snapshot' => ['required_content' => $sections], 'source_summary' => ['required_sections' => $metadata, 'snapshot_complete' => true], 'source_diagnosis_ids' => [1, 36], 'files' => []]; $rawBefore = serialize([$r36Current, $r36Longitudinal]); $prepared = DiagnosisAiLogic::prepareAssistant(36, 'summary', '复核本病例', 7, []); r36bExpect(is_array($prepared) && !$prepared['tcm_evidence_guard']['restricted'], 'preparation still ignores raw pulse as completeness criterion'); $ledger = $prepared['tcm_evidence_guard']['fact_state_ledger']; $historicalSource = array_values(array_filter($ledger['sources'], static fn (array $s): bool => $s['scope'] === 'historical' && $s['field'] === 'pulse' && $s['quote'] === '脉弦细。'))[0]; $currentSource = array_values(array_filter($ledger['sources'], static fn (array $s): bool => $s['path'] === 'current.pulse'))[0]; $history = "历史记录:2020-01-01“脉弦细。”({$historicalSource['id']})。"; r36bExpect(TcmClinicalFactStateGuard::validate($ledger, $history)['valid'], 'original unchanged state guard accepts exact historical source'); r36bExpect(TcmConsultationPulsePolicy::validate($ledger, $history)['valid'], 'history exception itself remains line-bound and exact'); foreach (['缺脉则暂缓选方。', '缺脉时只能等待。', '缺少脉则不能辨证。', '未采集脉故暂缓选方。', '缺脉象则暂缓选方。'] as $text) { r36bExpect(!TcmConsultationPulsePolicy::validate($ledger, $text)['valid'], 'missing traditional pulse cannot gate assessment: ' . $text); } DifyChatService::$reply = '缺脉则暂缓选方。'; $blocking = DiagnosisAiLogic::assistant(36, 'summary', '复核本病例', 7, []); r36bExpect($blocking === null, 'actual blocking route rejects exact missing-pulse escape whole'); $visible = ''; $streaming = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $part) use (&$visible): bool { $visible .= $part; return true; }); r36bExpect($streaming === null && $visible === '', 'actual streaming route never exposes exact missing-pulse escape'); r36bExpect(r36bAnalysis('证候方向仍须医师复核。', '缺脉则暂缓选方。') === null, 'actual analysis route rejects missing-pulse gate'); foreach (['缺脉率记录则补充心率及测量时间。', '缺脉压资料则核对血压记录。', '缺脉搏数值则复核生命体征。', '缺脉络资料则查看原检查。', '缺脉管影像则核对检查记录。', '冠状动脉与静脉检查须保留原时点。', 'pulse rate: 72 bpm; pulse pressure: 40 mmHg。'] as $text) { r36bExpect(TcmConsultationPulsePolicy::validate($ledger, $text)['valid'], 'non-TCM vascular or vital wording is preserved: ' . $text); } $cardio = '冠状动脉、静脉及生命体征项目须结合原检查记录复核。'; DifyChatService::$reply = $cardio; r36bExpect(DiagnosisAiLogic::assistant(36, 'summary', '复核本病例', 7, []) !== null, 'actual blocking path retains cardiovascular discussion'); $visible = ''; $streaming = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $part) use (&$visible): bool { $visible .= $part; return true; }); r36bExpect($streaming !== null && trim($visible) === $cardio, 'actual stream retains cardiovascular discussion byte-for-byte'); $diagnosisHistory = r36bAnalysis($history); r36bExpect(is_array($diagnosisHistory) && $diagnosisHistory['diagnosis_advice'] === $history, 'actual analysis preserves original exact historical quote in diagnosis field'); $treatmentHistory = r36bAnalysis('证候方向仍须医师复核。', $history); r36bExpect(is_array($treatmentHistory) && $treatmentHistory['treatment_advice'] === $history, 'actual analysis preserves original exact historical quote in treatment field'); $paragraph = "已记录资料按原日期引用。\n\n" . $history . "\n\n证候方向仍须结合本次证据复核。"; $paragraphResult = r36bAnalysis($paragraph); r36bExpect(is_array($paragraphResult) && $paragraphResult['diagnosis_advice'] === $paragraph, 'parsed paragraph and line boundaries survive analysis validation'); $riskResult = r36bAnalysis('证候方向仍须医师复核。', '用药依据仍须复核。', [['label' => $history, 'level' => 'low']]); r36bExpect(is_array($riskResult) && $riskResult['risk_assessment'][0]['label'] === $history, 'risk text is checked as its own original string, without JSON escapes'); $forged = str_replace('2020-01-01', '2026-10-26', $history); $currentRow = "历史记录:2026-10-26“脉细。”({$currentSource['id']})。"; foreach ([$forged, $currentRow, $history . '因此本次脉细。', str_replace('脉弦细。', '脉弦细,不涩。', $history), '{"history":"' . $history . '"}', '原文引用:' . $history] as $bad) { r36bExpect(r36bAnalysis($bad) === null, 'analysis rejects forged/current/extended/wrapped historical use: ' . $bad); } r36bExpect(r36bAnalysis('证候方向仍须医师复核。', $forged) === null, 'forged history is rejected in treatment field independently'); r36bExpect(r36bAnalysis('证候方向仍须医师复核。', '用药依据仍须复核。', [['label' => $currentRow, 'level' => 'low']]) === null, 'current-row historical misuse is rejected in risk field independently'); r36bExpect(r36bAnalysis('历史记录:2020-01-01“', '脉弦细。”(' . $historicalSource['id'] . ')。') === null, 'separate fields cannot compose a historical exception across field boundaries'); r36bExpect(r36bAnalysis($history, '脉细支持本次选方。') === null, 'one valid historical field cannot exempt another active pulse field'); r36bExpect(!TcmClinicalFactStateGuard::validate($ledger, '舌红绛,苔黄腻。')['valid'], 'unchanged source guard still rejects invented tongue features'); r36bExpect(str_contains(ClinicalKnowledgeReference::augmentCaseQuery('其他产品'), '舌象、脉象各单列'), 'other-product default reference scope stays unchanged'); r36bExpect(count($prepared['required_sections']) === 13 && str_contains($prepared['query'], '脉弦细。') && str_contains($prepared['inputs']['herbs_json'], '脉弦细。'), 'thirteen-source context retains original historical pulse in both channels'); r36bExpect(serialize([$r36Current, $r36Longitudinal]) === $rawBefore, 'all endpoint checks leave current and historical fixture bytes unchanged'); $r36Current['tongue'] = ''; $incomplete = DiagnosisAiLogic::prepareAssistant(36, 'summary', '复核本病例', 7, []); r36bExpect(is_array($incomplete) && $incomplete['tcm_evidence_guard']['restricted'] && $incomplete['tcm_evidence_guard']['missing'] === ['舌象'], 'independent current tongue requirement remains enforced'); $failures = array_values(array_filter($checks, static fn (array $c): bool => !$c['passed'])); echo json_encode(['suite' => 'DiagnosisAiPulseExcludedR36bRegressionTest', 'checks' => count($checks), 'passed' => count($checks) - count($failures), 'failed' => count($failures), 'failures' => $failures], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; exit($failures === [] ? 0 : 1); }