90, 'models' => [ 'qwen' => ['name' => 'r37-local-qwen', 'label' => 'R37 local Qwen'], 'openai' => ['name' => 'r37-local-openai', 'label' => 'R37 local OpenAI'], ]]; } class DiagnosisLogic { public static function detail($params, $adminInfo): array { return $GLOBALS['r37Current']; } } class PatientAiReportLogic { public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array { return $GLOBALS['r37Context']; } public static function compactSourceForPrompt(...$args): array { throw new \RuntimeException('Unexpected compaction; finite source is below the existing budget.'); } } } namespace app\common\service { class DifyChatService { public static array $calls = []; public static array $responses = []; public static int $totalCalls = 0; public static bool $streaming = false; private static function take(): array { if (self::$responses === []) throw new \RuntimeException('Unexpected extra local transport invocation.'); return array_shift(self::$responses); } public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array { ++self::$totalCalls; self::$calls[] = compact('profile', 'inputs', 'query', 'user', 'files', 'options') + ['transport' => 'chat']; return self::take(); } public static function streamChat($profile, $inputs, $query, $user, $delta, $abort, $files = [], $options = []): array { ++self::$totalCalls; self::$calls[] = compact('profile', 'inputs', 'query', 'user', 'files', 'options') + ['transport' => 'stream']; $result = self::take(); if (empty($result['ok'])) return $result; self::$streaming = true; try { foreach (mb_str_split($result['content'], 19, 'UTF-8') as $chunk) { if (($abort !== null && $abort()) || $delta($chunk) === false) { return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED']; } } } finally { self::$streaming = false; } return $result; } } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\adminapi\logic\tcm\DiagnosisAiLogic; use app\adminapi\service\AiRequiredSource; use app\adminapi\service\TcmEvidenceGuard; use app\adminapi\service\TcmClinicalFactStateGuard; use app\common\service\DifyChatService; $checks = []; function r37Check(bool $passed, string $name): void { $GLOBALS['checks'][] = compact('passed', 'name'); } function r37Reset(array $responses): void { DifyChatService::$calls = []; DifyChatService::$responses = $responses; } function r37Fixture(string $variant): void { $GLOBALS['r37Current'] = [ 'id' => 37, 'patient_id' => 999, 'diagnosis_date' => '2026-10-26', 'age' => 46, 'chief_complaint' => $variant === 'tongue_missing' ? '口干两周。' : '未记录', 'tongue' => $variant === 'symptoms_missing' ? '舌淡红,苔薄白。' : '未记录', 'pulse' => '脉细', 'allergy_history' => '不详', 'past_history' => '2020年外院糖尿病诊断记录。', 'current_medications' => '二甲双胍500mg,每日2次。', 'fasting_blood_sugar' => 7.9, 'systolic_pressure' => 132, 'diastolic_pressure' => 82, ]; $sections = []; $metadata = []; foreach (AiRequiredSource::LABELS as $key => $label) { $value = [['marker' => 'R37_FULL_SOURCE_' . $key]]; if ($key === 'diagnosis_info') $value[] = $GLOBALS['r37Current']; if ($key === 'prescriptions') $value[] = ['prescription_date' => '2020-02-01', 'prescription_name' => '历史用药记录', 'usage' => '格列美脲已停用。']; if ($key === 'daily_tracking') $value[] = ['record_date' => '2026-10-25', 'fasting_blood_sugar' => 8.3, 'note' => '晚餐主食较多,晚饭后步行20分钟。']; if ($key === 'chat') $value[] = ['msg_time' => '2020-02-01', 'text' => '历史自述口渴,非本次采集。']; $sections[] = compact('key', 'label', 'value'); $metadata[] = ['key' => $key, 'label' => $label, 'availability' => 'present', 'count' => count($value), 'status' => 'prepared']; } $GLOBALS['r37Context'] = [ 'snapshot' => ['required_content' => $sections], 'source_summary' => ['required_sections' => $metadata, 'snapshot_complete' => true], 'source_diagnosis_ids' => [12, 37], 'files' => [['type' => 'image', 'url' => '/offline-r37/attachment.png']], ]; } // Independent literal answers are local transport fixtures, not generated // medical advice. They exercise distinct useful analyses and byte retention. $answers = [ 'case_analysis' => "主要问题与病程变化\n2026-10-25记录空腹血糖8.3 mmol/L,2026-10-26记录7.9 mmol/L。两次点位可供本次复核,不能据此评价整个病程或确诊并发症。\n\n诊断支持与局限\n2020年外院糖尿病诊断只保留历史身份;结合近期血糖和治疗记录复核当前控制情况,其他病因或辨证方向仍需相应证据。", 'glucose_trend' => "可比较记录\n2026-10-25空腹血糖8.3 mmol/L,2026-10-26为7.9 mmol/L,两次相差0.4 mmol/L;这只是两个记录点,不能代表长期达标或总体稳定。\n\n关联与监测\n日常记录有晚餐主食较多及饭后步行信息,尚不能确立与次日数值的因果;需核实采样条件和记录时间,再结合更多同类数据评价变化。", 'medication_review' => "当前与历史用药\n2026-10-26当前用药记录为“二甲双胍500mg,每日2次。”;历史格列美脲已停用,不对旧药安排再次停用。\n\n安全复核\n现有记录可核对药名和用法;肾功能与过敏史的资料尚不完整,不能据此认定适用性或安全性,需由医师结合相应检查和实际服用情况复核,不擅自调药。", 'complication_risk' => "风险依据与范围\n历史糖尿病诊断及近期空腹血糖记录提示需要持续评价相关风险;目前提供的血糖点位和单次血压不能确诊、排除或分级肾脏、眼底等并发症。\n\n筛查与优先级\n肾功能、尿白蛋白及眼底检查的评价范围尚未明确,需核实既有结果与日期后决定后续筛查;资料未提供不代表检查正常或风险为零。", 'summary' => "问诊核心结论\n本次可交接的重点为近期空腹血糖记录及当前用药复核:2026-10-25为8.3 mmol/L,2026-10-26为7.9 mmol/L。\n\n治疗和后续重点\n记录二甲双胍500mg每日2次,历史格列美脲已停用;复核服用情况、检查时点与资料缺口,保持历史记录和本次信息的区分,不把有限点位写成长程疗效结论。", 'lifestyle_advice' => "与本例相关的优先建议\n日常记录提到“晚餐主食较多,晚饭后步行20分钟。”,可先记录餐量、进餐时间和同类血糖点位,帮助复核饮食与血糖的关联;目前不能断定该次餐量造成次日血糖变化。\n\n可行步骤与限制\n保留可持续的活动记录,结合实际耐受和治疗安排由医师细化饮食、活动目标;年龄及少量血糖记录不足以确定个体化热量或运动强度。", ]; $tasks = array_keys($answers); $original = new \ReflectionClass(DiagnosisAiLogic::class); r37Check($original->getConstant('MODEL_KEYS') === ['qwen', 'openai'], 'Original profile whitelist is retained.'); foreach (['PROMPT_VERSION', 'ASSISTANT_PROMPT_VERSION', 'ANALYSIS_PROMPT_VERSION'] as $version) { r37Check(str_ends_with($original->getConstant($version), '-r37'), $version . ' invalidates R36 provenance/cache.'); } foreach (['MAX_REPORT_LENGTH' => 12000, 'MAX_PROMPT_SOURCE_BYTES' => 120000, 'MAX_ANALYSIS_ADVICE_LENGTH' => 1200] as $key => $value) { r37Check($original->getConstant($key) === $value, 'Original finite budget retained: ' . $key); } r37Check(count(array_unique(array_map('sha1', $answers))) === 6, 'Six independently written task results are distinct.'); $invocations = 0; foreach (['both_missing', 'tongue_missing', 'symptoms_missing'] as $variant) { r37Fixture($variant); $rawBefore = serialize([$r37Current, $r37Context]); foreach ($answers as $task => $answer) { $question = 'R37医师原问题:请完成' . $task . ',结合现有记录说明依据及局限。'; $prepared = DiagnosisAiLogic::prepareAssistant(37, $task, $question, 7, []); r37Check(is_array($prepared), $variant . '/' . $task . ': partial evidence prepares successfully.'); if ($prepared === null) continue; $guard = $prepared['tcm_evidence_guard']; r37Check($guard['restricted'] && $guard['analysis_allowed'] && !in_array('脉象', $guard['missing'], true), $variant . '/' . $task . ': completeness marker never includes excluded pulse or disables analysis.'); r37Check(str_contains($prepared['query'], $question) && str_contains($prepared['query'], '输出安排:') && !str_contains($prepared['query'], '只能从下方事实索引选id') && !str_contains($prepared['query'], '只输出fact_ids'), $variant . '/' . $task . ': original question and task contract replace old source-selection protocol.'); $coverage = true; foreach (array_keys(AiRequiredSource::LABELS) as $key) { $coverage = $coverage && str_contains($prepared['query'], 'R37_FULL_SOURCE_' . $key) && str_contains($prepared['inputs']['herbs_json'], 'R37_FULL_SOURCE_' . $key); } r37Check($coverage && count($prepared['required_sections']) === 13 && $prepared['files'] === $r37Context['files'], $variant . '/' . $task . ': all 13 raw source categories and attachments retained.'); $missing = implode('、', $guard['missing']); $modelText = $answer . "\n\n本次资料限制\n本次尚缺可核验的" . $missing . ';依赖这些资料的辨证只作待核实方向,其余记录仍可供相应任务分析。需执业医师复核。'; foreach (['chat', 'stream'] as $mode) { r37Reset([['ok' => true, 'content' => $modelText]]); $visible = ''; $duringTransport = []; $result = $mode === 'chat' ? DiagnosisAiLogic::assistant(37, $task, $question, 7, []) : DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $text) use (&$visible, &$duringTransport): bool { $visible .= $text; $duringTransport[] = DifyChatService::$streaming; return true; }, null, null, ['knowledge_mode' => 'evidence']); $invocations += count(DifyChatService::$calls); $call = DifyChatService::$calls[0] ?? []; $prefix = $variant . '/' . $task . '/' . $mode; r37Check(count(DifyChatService::$calls) === 1 && DifyChatService::$responses === [], $prefix . ': public endpoint invokes the real local transport boundary exactly once.'); r37Check(is_array($result) && $result['answer'] === $modelText, $prefix . ': original useful model answer is returned without any local replacement.'); r37Check(($result['answer_mode'] ?? '') === 'clinical_analysis' && ($result['clinical_fact_state_version'] ?? '') === TcmClinicalFactStateGuard::VERSION && ($result['evidence_limitations'] ?? []) === $guard['missing'], $prefix . ': model analysis and limitations are reported honestly.'); r37Check(($call['profile'] ?? '') === 'qwen' && ($result['model_name'] ?? '') === 'r37-local-qwen' && ($call['options']['knowledge_mode'] ?? '') === 'clinical' && ($call['options']['reference_scope'] ?? '') === 'consultation', $prefix . ': original Qwen route and clinical consultation scope retained.'); r37Check(($call['query'] ?? '') === $prepared['query'] && ($call['inputs'] ?? []) === $prepared['inputs'] && ($call['files'] ?? []) === $prepared['files'] && ($call['options']['required_files'] ?? false), $prefix . ': prompt, inputs, original files and required-source flag reach transport.'); r37Check(($call['options']['timeout_ms'] ?? 0) > 0 && $call['options']['timeout_ms'] <= 90000 && ($call['options']['request_deadline_ns'] ?? 0) > 0, $prefix . ': existing deadline and finite model budget retained.'); if ($mode === 'stream') r37Check($visible === $modelText && in_array(true, $duringTransport, true), $prefix . ': validated original model paragraphs arrive while generation is running.'); } } r37Check(serialize([$r37Current, $r37Context]) === $rawBefore, $variant . ': full source snapshot is never mutated.'); } r37Fixture('both_missing'); $structured = ['diagnosis_advice' => '近期记录有两次空腹血糖点位,可供当前控制情况复核;历史诊断保留日期,症状病史与舌象资料尚待核实,不能据此确定证候。', 'risk_assessment' => [['label' => '既有病史和近期血糖记录需要持续风险评价;并发症检查资料尚不完整。', 'level' => 'medium']], 'treatment_advice' => '复核实际用药、血糖测量条件及已有肾功能等检查日期,再决定监测与随访;当前不形成新增方药或调药结论。']; foreach (['qwen', 'openai'] as $profile) { r37Reset([['ok' => true, 'content' => json_encode($structured, JSON_UNESCAPED_UNICODE)]]); $result = DiagnosisAiLogic::analysis(37, 7, [], $profile); $invocations += count(DifyChatService::$calls); r37Check(is_array($result) && array_intersect_key($result, $structured) === $structured, $profile . ': structured analysis retains actual parsed model findings under partial evidence.'); r37Check(count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['profile'] === $profile && DifyChatService::$calls[0]['options']['knowledge_mode'] === 'clinical' && ($result['model_key'] ?? '') === $profile && ($result['answer_mode'] ?? '') === 'clinical_analysis', $profile . ': selected original channel performs structured clinical analysis.'); } foreach (['请补采脉象。', '本次舌红,苔黄腻。', '本次无过敏史。'] as $unsafe) { $invalid = $structured; $invalid['treatment_advice'] = $unsafe; r37Reset([['ok' => true, 'content' => json_encode($invalid, JSON_UNESCAPED_UNICODE)]]); r37Check(DiagnosisAiLogic::analysis(37, 7, [], 'openai') === null && count(DifyChatService::$calls) === 1, 'Partial structured analysis rejects unsafe model field without local substitution: ' . $unsafe); } r37Reset([['ok' => true, 'content' => '{"fact_ids":["f1"]}'], ['ok' => true, 'content' => '{"fact_ids":["f1"]}']]); r37Check(DiagnosisAiLogic::analysis(37, 7, [], 'openai') === null && count(DifyChatService::$calls) === 2, 'Structured obsolete protocol gets only the existing finite format repair, then an honest error.'); // Independent missing-data cases still request task-specific model analysis. $r37Current['fasting_blood_sugar'] = ''; $r37Current['current_medications'] = '未记录'; foreach ($r37Context['snapshot']['required_content'] as &$section) { if ($section['key'] === 'diagnosis_info') $section['value'] = [$r37Current]; if (in_array($section['key'], ['daily_tracking', 'prescriptions'], true)) $section['value'] = []; } unset($section); foreach (['glucose_trend' => '当前没有可比较的血糖数值,不能计算趋势;可依据既往诊断记录建立同一时段的测量表,补充空腹或餐后身份及测量条件后再评价。', 'medication_review' => '当前用药资料未记录,不能判断具体药物相互作用或剂量;可结合历史诊断核实药名、实际服用状态及监测结果,再作相应安全评价。'] as $task => $answer) { r37Reset([['ok' => true, 'content' => $answer]]); $result = DiagnosisAiLogic::assistant(37, $task, '按本例资料解释局限及能做的分析', 7, []); $invocations += count(DifyChatService::$calls); r37Check(count(DifyChatService::$calls) === 1 && ($result['answer'] ?? '') === $answer, $task . ': absent task measurements produce an actual model explanation, not a bypass or local checklist.'); } r37Fixture('both_missing'); foreach (['请补采脉象。', '本次舌红,苔黄腻。', '本次无过敏史。'] as $unsafe) { r37Reset([['ok' => true, 'content' => $unsafe]]); r37Check(DiagnosisAiLogic::assistant(37, 'case_analysis', '', 7, []) === null && count(DifyChatService::$calls) === 1, 'Partial evidence still rejects forbidden or fabricated fact: ' . $unsafe); $prepared = DiagnosisAiLogic::prepareAssistant(37, 'case_analysis', '', 7, []); r37Reset([['ok' => true, 'content' => $unsafe . "\n\n"]]); $visible = ''; $result = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $text) use (&$visible): bool { $visible .= $text; return true; }); r37Check($result === null && $visible === '', 'Partial stream rejects unsafe model paragraph before delivery: ' . $unsafe); } r37Reset([['ok' => false, 'error_code' => 'UPSTREAM_TIMEOUT', 'error' => 'Local stub timed out.']]); r37Check(DiagnosisAiLogic::assistant(37, 'summary', '', 7, []) === null && DiagnosisAiLogic::getAssistantErrorCode() === 'UPSTREAM_TIMEOUT' && count(DifyChatService::$calls) === 1, 'Model timeout is an honest error, with no fabricated source report.'); $prepared = DiagnosisAiLogic::prepareAssistant(37, 'summary', '', 7, []); foreach (['UPSTREAM_TIMEOUT' => 1, 'UPSTREAM_REJECTED' => 2] as $code => $attempts) { r37Reset(array_fill(0, $attempts, ['ok' => false, 'error_code' => $code, 'error' => 'Local stub unavailable.'])); $visible = ''; $result = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $text) use (&$visible): bool { $visible .= $text; return true; }); r37Check($result === null && $visible === '' && count(DifyChatService::$calls) === $attempts && DiagnosisAiLogic::getAssistantErrorCode() === $code, $code . ': stream keeps bounded existing retry policy and reports failure without a replacement answer.'); } r37Reset([['ok' => true, 'content' => '{"fact_ids":["f1"]}']]); r37Check(DiagnosisAiLogic::assistant(37, 'summary', '', 7, []) === null && DiagnosisAiLogic::getAssistantErrorCode() === 'TASK_ANALYSIS_MISSING', 'Obsolete source-ID-only model answer is rejected rather than expanded locally.'); foreach (['{"fact_ids":["f1"]}', "```json\n{\"fact_ids\":[\"f1\"]}\n```\n\n"] as $obsolete) { $prepared = DiagnosisAiLogic::prepareAssistant(37, 'summary', '', 7, []); r37Reset([['ok' => true, 'content' => $obsolete]]); $visible = ''; $result = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $text) use (&$visible): bool { $visible .= $text; return true; }); r37Check($result === null && $visible === '' && DiagnosisAiLogic::getAssistantErrorCode() === 'TASK_ANALYSIS_MISSING', 'Stream rejects obsolete source selection before delivery, without reporting doctor cancellation.'); } $prepared = DiagnosisAiLogic::prepareAssistant(37, 'summary', '', 7, []); r37Reset([]); r37Check(DiagnosisAiLogic::streamPreparedAssistant($prepared, static fn (): bool => true, static fn (): bool => true) === null && DifyChatService::$calls === [], 'Cancellation before submission prevents all upstream calls.'); $prepared['request_deadline_ns'] = hrtime(true) - 1; r37Check(DiagnosisAiLogic::streamPreparedAssistant($prepared, static fn (): bool => true) === null && DifyChatService::$calls === [] && DiagnosisAiLogic::getAssistantErrorCode() === 'UPSTREAM_TIMEOUT', 'Expired budget prevents all upstream calls.'); r37Check(DiagnosisAiLogic::prepareAssistant(37, 'prescription_generate', '', 7, []) === null && DiagnosisAiLogic::getAssistantErrorCode() === 'TCM_EVIDENCE_INCOMPLETE' && DifyChatService::$calls === [], 'Separate prescription draft gate is explicit and never disguised as a model response.'); r37Reset([]); $legacyQuestion = '请根据当前病历总结患者病情、证候与风险。'; $authorizedTask = null; $legacy = DiagnosisAiLogic::prepareAssistant(37, 'tcm_pattern', $legacyQuestion, 7, [], null, ['on_authorized' => static function (array $meta) use (&$authorizedTask): bool { $authorizedTask = $meta['task']; return true; }]); r37Check(is_array($legacy) && $legacy['task'] === 'summary' && $authorizedTask === 'summary', 'Released summary shortcut uses summary in both authorization metadata and prepared task.'); r37Check(is_array($legacy) && str_contains($legacy['query'], $legacyQuestion) && str_contains($legacy['query'], '问诊核心结论→病程和关键记录'), 'Legacy summary keeps the exact question and receives the summary output contract.'); $explicit = DiagnosisAiLogic::prepareAssistant(37, 'tcm_pattern', $legacyQuestion . '请生成完整中医辨证报告。', 7, []); r37Check(is_array($explicit) && $explicit['task'] === 'tcm_pattern' && DifyChatService::$calls === [], 'Distinct explicit TCM request is not remapped and preparation makes no model call.'); $failed = array_values(array_filter($checks, static fn (array $check): bool => !$check['passed'])); echo json_encode(['suite' => 'DiagnosisAiTaskSpecificPartialR37IntegrationTest', 'checks' => count($checks), 'passed' => count($checks) - count($failed), 'failed' => count($failed), 'core_transport_calls' => $invocations, 'local_transport_calls' => DifyChatService::$totalCalls, 'failures' => $failed], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n"; exit($failed === [] ? 0 : 1); }