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\common\service\DifyChatService; $checks = []; function integrationR36(bool $ok, string $name): void { $GLOBALS['checks'][] = ['name' => $name, 'passed' => $ok]; } $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' => 'R36_' . $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' => []]; $frozen = serialize($r36Longitudinal); $prepared = DiagnosisAiLogic::prepareAssistant(36, 'summary', '复核本病例', 7, []); integrationR36(is_array($prepared) && !$prepared['tcm_evidence_guard']['restricted'], 'actual preparation accepts empty pulse with independent current evidence'); integrationR36($prepared['reference_scope'] === 'consultation' && count($prepared['required_sections']) === 13, 'preparation keeps all thirteen sources and selects scoped policy'); integrationR36(str_contains($prepared['query'], '历史脉弦细。') && str_contains($prepared['inputs']['herbs_json'], '历史脉弦细。'), 'historical raw pulse remains in both full upstream input channels'); DifyChatService::$reply = '八纲判断须结合实采舌象及症状,证候和用药仍待医师复核。'; $ordinary = DiagnosisAiLogic::assistant(36, 'summary', '复核本病例', 7, []); integrationR36(is_array($ordinary) && $ordinary['answer_mode'] === 'clinical_analysis', 'ordinary answer is not replaced with source-only pulse-missing fallback'); integrationR36(end(DifyChatService::$calls)['options']['reference_scope'] === 'consultation', 'ordinary transport receives explicit consultation scope'); foreach (['脉细支持本次辨证。', '脉象未采集,请补采。', '缺脉象,不得选方。'] as $bad) { DifyChatService::$reply = $bad; integrationR36(DiagnosisAiLogic::assistant(36, 'summary', '复核本病例', 7, []) === null, 'ordinary endpoint rejects whole forbidden response: ' . $bad); } DifyChatService::$reply = '请补采脉象。'; $visible = ''; $stream = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $part) use (&$visible): bool { $visible .= $part; return true; }); integrationR36($stream === null && $visible === '', 'stream does not deliver forbidden pulse paragraph'); $analysis = ['diagnosis_advice' => '证候方向须结合实采舌象和症状由医师复核。', 'treatment_advice' => '本次用药须按实际支持证据及禁忌复核。', 'risk_assessment' => []]; DifyChatService::$reply = json_encode($analysis, JSON_UNESCAPED_UNICODE); $structured = DiagnosisAiLogic::analysis(36, 7, []); integrationR36(is_array($structured) && $structured['answer_mode'] === 'clinical_analysis', 'structured diagnosis remains clinical with empty pulse'); integrationR36(end(DifyChatService::$calls)['options']['reference_scope'] === 'consultation', 'structured transport receives scoped policy'); $analysis['treatment_advice'] = '请补采脉象后用药。'; DifyChatService::$reply = json_encode($analysis, JSON_UNESCAPED_UNICODE); integrationR36(DiagnosisAiLogic::analysis(36, 7, []) === null, 'structured endpoint rejects pulse collection requirement'); $draftPrepared = DiagnosisAiLogic::prepareAssistant(36, 'prescription_generate', '生成草稿', 7, []); integrationR36(is_array($draftPrepared) && !$draftPrepared['tcm_evidence_guard']['restricted'], 'prescription preparation is not blocked solely by absent pulse'); $draft = ['clinical_diagnosis' => '医师待复核方向', 'herbs' => [['name' => '测试药材', 'dosage' => 1, 'formula_type' => '主方']], 'rationale' => '由医师结合实际证据逐味复核。']; DifyChatService::$reply = json_encode(['prescription_draft' => $draft], JSON_UNESCAPED_UNICODE); $generated = DiagnosisAiLogic::assistant(36, 'prescription_generate', '生成草稿', 7, []); integrationR36(is_array($generated) && !array_key_exists('pulse', $generated['prescription_draft']), 'draft endpoint returns new schema without pulse'); DifyChatService::$reply = json_encode(['prescription_draft' => $draft + ['pulse' => '脉细']], JSON_UNESCAPED_UNICODE); integrationR36(DiagnosisAiLogic::assistant(36, 'prescription_generate', '生成草稿', 7, []) === null, 'draft endpoint rejects obsolete pulse data rather than concealing it'); $r36Current['tongue'] = ''; $incomplete = DiagnosisAiLogic::prepareAssistant(36, 'summary', '复核本病例', 7, []); integrationR36(is_array($incomplete) && $incomplete['tcm_evidence_guard']['restricted'] && $incomplete['tcm_evidence_guard']['missing'] === ['舌象'], 'independent tongue gap still selects source-only safety path'); integrationR36(DiagnosisAiLogic::prepareAssistant(36, 'prescription_generate', '生成草稿', 7, []) === null, 'independent tongue gap still blocks a new prescription'); integrationR36(serialize($r36Longitudinal) === $frozen, 'endpoint preparation never rewrites original full source snapshot'); $failed = array_values(array_filter($checks, static fn (array $check): bool => !$check['passed'])); echo json_encode(['suite' => 'DiagnosisAiPulseExcludedR36IntegrationTest', 'checks' => count($checks), 'passed' => count($checks) - count($failed), 'failed' => count($failed), 'failures' => $failed], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"; exit($failed === [] ? 0 : 1); }