*/ private const MODEL_KEYS = ['qwen', 'openai']; /** @var array */ private const RISK_LEVELS = ['high', 'medium', 'low']; /** @var array */ private const ASSISTANT_TASKS = [ 'summary' => [ 'label' => '病例总结', 'profile' => 'qwen', 'instruction' => '总结当前病例的关键临床信息、缺失信息和下一步需核对事项。', ], 'tcm_pattern' => [ 'label' => '中医辨证', 'profile' => 'qwen', 'instruction' => '从中医辨证角度梳理可能证候、支持点、矛盾点及需要补充的四诊信息。', ], 'prescription_review' => [ 'label' => '处方分析', 'profile' => 'qwen', 'instruction' => '分析病历中的处方或用药信息,提示配伍、剂量和特殊人群的复核重点。', ], 'medication_review' => [ 'label' => '用药复核', 'profile' => 'qwen', 'instruction' => '梳理当前用药与病历的关联,提示需由医师或药师核对的相互作用和用药风险。', ], 'exam_review' => [ 'label' => '检查解读', 'profile' => 'openai', 'instruction' => '解读已记录的检查或生命体征,区分已知、未知与需要进一步检查的项目。', ], 'complication_risk' => [ 'label' => '并发症风险', 'profile' => 'openai', 'instruction' => '基于已记录信息梳理可能的并发症和风险分层,并指出判断依据与信息缺口。', ], 'guideline_review' => [ 'label' => '指南核对', 'profile' => 'openai', 'instruction' => '列出需要结合现行临床指南核对的诊疗要点,不虚构具体指南条款或版本。', ], 'custom' => [ 'label' => '自定义问题', 'profile' => 'qwen', 'instruction' => '回答医务人员提出的病例相关问题。', ], ]; /** @var array */ private const TEXT_REPORT_SECTIONS = [ '核心判断' => 'summary', '可能症状与证候' => 'possible_symptoms', '主治方向' => 'main_indications', '主要功效' => 'efficacy', '可能适用人群' => 'suitable_people', '配伍分析' => 'compatibility_analysis', '用药与复核提醒' => 'cautions', '免责声明' => 'disclaimer', ]; /** @var array */ private const TEXT_REPORT_LIST_FIELDS = [ 'possible_symptoms', 'efficacy', 'suitable_people', 'cautions', ]; /** * 病例摘要字段:仅临床内容,不把身份证/手机号送给模型。 * * @var array}> */ private const CASE_FIELDS = [ ['诊断日期', ['diagnosis_date', 'diagnosis_date_text']], ['诊断类型', ['diagnosis_type_text', 'diagnosis_type_desc', 'consultation_type', 'diagnosis_type']], ['婚姻状态', ['marital_status_text', 'marital_status_desc', 'marital_status']], ['主诉', ['chief_complaint', 'complaint']], ['主要症状', ['symptoms', 'main_symptoms']], ['现病史', ['present_illness', 'present_illness_history']], ['证型', ['syndrome_type_text', 'syndrome_type_desc', 'syndrome_type']], ['糖尿病类型', ['diabetes_type_text', 'diabetes_type']], ['糖尿病史', ['diabetes_history_text', 'diabetes_history', 'diabetes_desc']], ['发现糖尿病年', ['diabetes_discovery_year_text', 'diabetes_discovery_year']], ['当地就诊医院', ['local_hospital_name', 'local_hospital']], ['当地医院诊断结果', ['local_hospital_diagnosis', 'local_diagnosis']], ['口腔感觉', ['appetite_text', 'appetite_desc', 'appetite']], ['每日饮水量', ['water_intake_text', 'water_intake_desc', 'water_intake']], ['近月体重变化', ['weight_change_text', 'weight_change_desc', 'weight_change']], ['脂肪肝程度', ['fatty_liver_degree_text', 'fatty_liver_degree_desc', 'fatty_liver_degree']], ['饮食情况', ['diet_condition_text', 'diet_condition_desc', 'diet_condition']], ['肢体感觉', ['body_feeling_text', 'body_feeling_desc', 'body_feeling']], ['睡眠情况', ['sleep_condition_text', 'sleep_condition_desc', 'sleep_condition']], ['眼睛情况', ['eye_condition_text', 'eye_condition_desc', 'eye_condition']], ['头部感觉', ['head_feeling_text', 'head_feeling_desc', 'head_feeling']], ['出汗情况', ['sweat_condition_text', 'sweat_condition_desc', 'sweat_condition']], ['皮肤情况', ['skin_condition_text', 'skin_condition_desc', 'skin_condition']], ['小便情况', ['urine_condition_text', 'urine_condition_desc', 'urine_condition']], ['大便情况', ['stool_condition_text', 'stool_condition_desc', 'stool_condition']], ['腰肾情况', ['kidney_condition_text', 'kidney_condition_desc', 'kidney_condition']], ['既往史', ['past_history_text', 'past_history_desc', 'past_history']], ['外伤史', ['trauma_history_text', 'trauma_history_desc', 'trauma_history']], ['手术史', ['surgery_history_text', 'surgery_history_desc', 'surgery_history']], ['过敏史', ['allergy_history_text', 'allergy_history_desc', 'allergy_history']], ['个人史', ['personal_history_text', 'personal_history_desc', 'personal_history']], ['家族史', ['family_history_text', 'family_history_desc', 'family_history']], ['妊娠哺乳史', ['pregnancy_history_text', 'pregnancy_history_desc', 'pregnancy_history']], ['当前用药', ['current_medications', 'current_medicine', 'current_medication']], ['临床诊断', ['clinical_diagnosis', 'diagnosis']], ['舌象', ['tongue', 'tongue_coating']], ['脉象', ['pulse', 'pulse_condition']], ['治则', ['treatment_principle']], ['处方意见', ['prescription_opinion', 'prescription_advice']], ['其他病史', ['other_history', 'medical_history_other']], ['病例备注', ['remark']], ]; /** * @param array $adminInfo * @return array|null */ public static function getSavedReports(int $id, int $adminId, array $adminInfo): ?array { $diagnosis = self::loadAuthorizedDiagnosis( $id, $adminId, $adminInfo, self::PERMISSION_READ, '权限不足,无法查看诊单 AI 报告' ); if ($diagnosis === null) { return null; } $context = self::buildCaseContext($diagnosis); return self::buildReportsPayload($context, $adminId, $adminInfo); } /** * 病例内 AI 助手。只使用授权诊单的脱敏临床摘要,不持久化问答内容。 * * @param array $adminInfo * @return array{answer:string,model_key:string,model_label:string,model_name:string,task:string}|null */ public static function assistant( int $diagnosisId, string $task, string $prompt, int $adminId, array $adminInfo ): ?array { $diagnosis = self::loadAuthorizedDiagnosis( $diagnosisId, $adminId, $adminInfo, self::PERMISSION_ASSISTANT, '权限不足,无法使用诊单 AI 助手' ); if ($diagnosis === null) { return null; } $task = strtolower(trim($task)); if (!isset(self::ASSISTANT_TASKS[$task])) { self::setError('不支持的 AI 助手任务'); return null; } $prompt = self::cleanText($prompt, self::MAX_ASSISTANT_PROMPT_LENGTH, true); if ($task === 'custom' && $prompt === '') { self::setError('请输入要咨询的问题'); return null; } $context = self::buildCaseContext($diagnosis); if ($context['case_lines'] === []) { self::setError('该诊单暂无有效病例信息,无法使用 AI 助手'); return null; } $profile = self::selectAssistantProfile($task, $prompt); $modelConfig = self::modelConfigs()[$profile] ?? []; $model = trim((string) ($modelConfig['name'] ?? '')); $modelLabel = trim((string) ($modelConfig['label'] ?? $profile)); if ($model === '') { self::setError('AI 模型服务尚未完整配置'); return null; } try { $result = DifyChatService::chat( $profile, self::buildUpstreamInputs( $context, '病例问诊助手', self::ASSISTANT_PROMPT_VERSION ), self::buildAssistantPrompt($context, $task, $prompt), 'admin-diagnosis-assistant-' . $adminId ); } catch (\Throwable $e) { Log::warning('diagnosis ai assistant upstream call failed', [ 'diagnosis_id' => $diagnosisId, 'profile' => $profile, 'admin_id' => $adminId, 'exception_class' => get_class($e), ]); self::setError('AI 助手暂时不可用,请稍后重试'); return null; } if (empty($result['ok'])) { self::setError((string) ($result['error'] ?? 'AI 助手暂时不可用,请稍后重试')); return null; } $content = self::cleanText($result['content'] ?? '', self::MAX_REPORT_LENGTH, true); if ($content === '') { self::setError('AI 助手未返回内容,请重试'); return null; } return [ 'answer' => $content, 'model_key' => $profile, 'model_label' => $modelLabel, 'model_name' => $model, 'task' => $task, ]; } /** * 接诊台结构化 AI 智能分析。每次只调用客户端白名单键对应的服务端模型, * 上游失败或响应不符合契约时直接失败,不构造本地伪分析。 * * @param array $adminInfo * @return array{ * diagnosis_advice:string, * risk_assessment:array, * treatment_advice:string, * model_key:string, * model_label:string, * model_name:string, * generated_at:string * }|null */ public static function analysis( int $diagnosisId, int $adminId, array $adminInfo, string $modelKey = 'qwen' ): ?array { $profile = self::selectAnalysisProfile($modelKey); if ($profile === null) { self::setError('AI模型仅支持qwen或openai'); return null; } $diagnosis = self::loadAuthorizedDiagnosis( $diagnosisId, $adminId, $adminInfo, self::PERMISSION_ANALYSIS, '权限不足,无法使用AI智能分析' ); if ($diagnosis === null) { return null; } $context = self::buildCaseContext($diagnosis); if ($context['case_lines'] === []) { self::setError('该诊单暂无有效病例信息,无法生成AI智能分析'); return null; } $modelConfig = self::modelConfigs()[$profile] ?? []; $modelName = trim((string) ($modelConfig['name'] ?? '')); $modelLabel = trim((string) ($modelConfig['label'] ?? $profile)); if ($modelName === '' || $modelLabel === '') { self::setError('AI模型服务尚未完整配置'); return null; } try { $result = DifyChatService::chat( $profile, self::buildUpstreamInputs( $context, '诊单结构化分析', self::ANALYSIS_PROMPT_VERSION ), self::buildAnalysisPrompt($context), 'admin-diagnosis-analysis-' . $adminId ); } catch (\Throwable $e) { Log::warning('diagnosis ai analysis upstream call failed', [ 'diagnosis_id' => $diagnosisId, 'profile' => $profile, 'admin_id' => $adminId, 'exception_class' => get_class($e), ]); self::setError('AI智能分析暂时不可用,请稍后重试'); return null; } if (empty($result['ok'])) { self::setError('AI智能分析暂时不可用,请稍后重试'); return null; } $analysis = self::parseAnalysisResponse((string) ($result['content'] ?? '')); if ($analysis === null) { self::setError('AI返回的分析结构不符合要求,请重试'); return null; } return array_merge($analysis, [ 'model_key' => $profile, 'model_label' => $modelLabel, 'model_name' => $modelName, 'generated_at' => date('Y-m-d H:i:s'), ]); } /** * @param array $adminInfo * @return array|null */ public static function generateAll(int $id, int $adminId, array $adminInfo): ?array { $diagnosis = self::loadAuthorizedDiagnosis( $id, $adminId, $adminInfo, self::PERMISSION_REFRESH, '权限不足,无法生成诊单 AI 报告' ); if ($diagnosis === null) { return null; } $context = self::buildCaseContext($diagnosis); if ($context['case_lines'] === []) { self::setError('该诊单暂无有效病例信息,无法生成报告'); return null; } $modelConfigs = self::modelConfigs(); $results = []; $successCount = 0; $failureCount = 0; foreach (self::MODEL_KEYS as $modelKey) { $modelConfig = $modelConfigs[$modelKey] ?? []; $modelName = (string) ($modelConfig['name'] ?? $modelKey); $modelLabel = (string) ($modelConfig['label'] ?? $modelKey); $resultBase = [ 'model_key' => $modelKey, 'model_name' => $modelName, 'model_label' => $modelLabel, ]; try { $result = DifyChatService::chat( $modelKey, self::buildUpstreamInputs($context, '病例', self::PROMPT_VERSION), self::buildPrompt($context), 'admin-diagnosis-' . $adminId ); } catch (\Throwable $e) { Log::warning('diagnosis ai upstream call failed', [ 'diagnosis_id' => $id, 'model_key' => $modelKey, 'admin_id' => $adminId, 'exception_class' => get_class($e), ]); $result = [ 'ok' => false, 'error_code' => 'UPSTREAM_EXCEPTION', 'error' => '模型调用异常,请稍后重试', 'latency_ms' => 0, ]; } if (empty($result['ok'])) { $failureCount++; $results[] = array_merge($resultBase, [ 'status' => 'error', 'error_code' => (string) ($result['error_code'] ?? 'AI_ERROR'), 'error_message' => (string) ($result['error'] ?? '报告生成失败,请稍后重试'), 'latency_ms' => (int) ($result['latency_ms'] ?? 0), ]); continue; } $content = self::cleanText($result['content'] ?? '', self::MAX_REPORT_LENGTH, true); if ($content === '') { $failureCount++; $results[] = array_merge($resultBase, [ 'status' => 'error', 'error_code' => 'EMPTY_RESPONSE', 'error_message' => '模型未返回报告内容,请重试', 'latency_ms' => (int) ($result['latency_ms'] ?? 0), ]); continue; } try { $reportId = self::upsertGeneratedReport( $context, $modelKey, $modelName, $modelLabel, $content, (string) ($result['message_id'] ?? ''), $adminId ); } catch (\Throwable $e) { Log::warning('diagnosis ai report persist failed', [ 'diagnosis_id' => $id, 'model_key' => $modelKey, 'admin_id' => $adminId, 'exception_class' => get_class($e), ]); $failureCount++; $results[] = array_merge($resultBase, [ 'status' => 'error', 'error_code' => 'PERSIST_FAILED', 'error_message' => '报告已生成但保存失败,请稍后重试', 'latency_ms' => (int) ($result['latency_ms'] ?? 0), ]); continue; } $successCount++; $results[] = array_merge($resultBase, [ 'report_id' => $reportId, 'status' => 'success', 'message_id' => (string) ($result['message_id'] ?? ''), 'prompt_version' => self::PROMPT_VERSION, 'latency_ms' => (int) ($result['latency_ms'] ?? 0), ]); } $payload = self::buildReportsPayload($context, $adminId, $adminInfo); $payload['status'] = $successCount === count(self::MODEL_KEYS) ? 'success' : ($successCount > 0 ? 'partial' : 'error'); $payload['partial'] = $successCount > 0 && $failureCount > 0; $payload['success_count'] = $successCount; $payload['failure_count'] = $failureCount; $payload['results'] = $results; return $payload; } /** * @param mixed $content * @param array $adminInfo * @return array|null */ public static function editReport( int $id, int $reportId, $content, int $adminId, array $adminInfo ): ?array { $diagnosis = self::loadAuthorizedDiagnosis( $id, $adminId, $adminInfo, self::PERMISSION_EDIT, '权限不足,无法编辑诊单 AI 报告' ); if ($diagnosis === null) { return null; } if (!is_string($content)) { self::setError('报告内容格式错误'); return null; } $content = trim(str_replace("\0", '', strip_tags($content))); if ($content === '') { self::setError('报告内容不能为空'); return null; } if (mb_strlen($content) > self::MAX_REPORT_LENGTH) { self::setError('报告内容最多12000个字符'); return null; } $report = DiagnosisAiReport::where('id', $reportId) ->where('diagnosis_id', $id) ->findOrEmpty(); if ($report->isEmpty()) { self::setError('报告不存在或不属于当前诊单'); return null; } $now = time(); $report->save([ 'report_content' => $content, 'edited_by' => $adminId, 'edited_time' => $now, 'update_time' => $now, ]); $context = self::buildCaseContext($diagnosis); return [ 'diagnosis_id' => $id, 'report' => self::formatReportRow($report->toArray(), $context['fingerprint']), 'can_edit' => self::hasPermission($adminId, $adminInfo, self::PERMISSION_EDIT), 'can_refresh' => self::hasPermission($adminId, $adminInfo, self::PERMISSION_REFRESH), ]; } /** * @param array $adminInfo */ private static function hasPermission(int $adminId, array $adminInfo, string $permission): bool { if (!empty($adminInfo['root']) && (int) $adminInfo['root'] === 1) { return true; } $uris = (new AdminAuthCache($adminId))->getAdminUri() ?? []; $uris = array_map( static fn ($uri): string => strtolower(trim((string) $uri)), is_array($uris) ? $uris : [] ); return in_array(strtolower($permission), $uris, true); } /** * @param array $adminInfo * @return array|null */ private static function loadAuthorizedDiagnosis( int $id, int $adminId, array $adminInfo, string $permission, string $permissionError ): ?array { if ($id <= 0) { self::setError('诊单ID必须大于0'); return null; } if (!self::hasPermission($adminId, $adminInfo, $permission)) { self::setError($permissionError); return null; } $accessQuery = Diagnosis::where('id', $id)->whereNull('delete_time'); $isRoot = !empty($adminInfo['root']) && (int) $adminInfo['root'] === 1; if (!$isRoot) { $roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id')); if (in_array(2, $roleIds, true)) { $accessQuery->where('assistant_id', $adminId); } if (DataScopeService::isEnabled()) { $visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo); if ($visibleIds === []) { self::setError('诊单不存在或无权访问'); return null; } if (is_array($visibleIds)) { $accessQuery->whereIn('assistant_id', $visibleIds); } } } if (!$accessQuery->find()) { self::setError('诊单不存在或无权访问'); return null; } $diagnosis = DiagnosisLogic::detail(['id' => $id], $adminInfo); if ($diagnosis === [] || empty($diagnosis['id'])) { self::setError('诊单不存在或无权访问'); return null; } return $diagnosis; } /** * @param array $diagnosis * @return array */ private static function buildCaseContext(array $diagnosis): array { $gender = self::genderText($diagnosis['gender_desc'] ?? $diagnosis['gender'] ?? ''); $age = self::cleanText($diagnosis['age'] ?? '', 8); $demographics = trim($gender . ($age !== '' ? ' · ' . $age . '岁' : ''), ' ·'); $caseLines = []; $systolic = self::cleanText($diagnosis['systolic_pressure'] ?? '', 20); $diastolic = self::cleanText($diagnosis['diastolic_pressure'] ?? '', 20); if ($systolic !== '' || $diastolic !== '') { $caseLines[] = '血压:' . trim($systolic . '/' . $diastolic, '/') . ' mmHg'; } $bloodSugar = self::firstNonEmpty($diagnosis, [ 'fasting_blood_sugar', 'fasting_glucose', 'fasting_blood_glucose', 'blood_sugar', ]); if ($bloodSugar !== '') { $caseLines[] = '空腹血糖:' . $bloodSugar . ' mmol/L'; } $height = self::cleanText($diagnosis['height'] ?? '', 20); $weight = self::cleanText($diagnosis['weight'] ?? '', 20); if ($height !== '') { $caseLines[] = '身高:' . $height . ' cm'; } if ($weight !== '') { $caseLines[] = '体重:' . $weight . ' kg'; } $tongueImageCount = self::attachmentCount( $diagnosis['tongue_images'] ?? $diagnosis['tongue_photo'] ?? [] ); if ($tongueImageCount > 0) { $caseLines[] = '舌象图片附件:已上传' . $tongueImageCount . '份(未提供附件内容)'; } $reportFileCount = self::attachmentCount( $diagnosis['report_files'] ?? $diagnosis['examination_report'] ?? [] ); if ($reportFileCount > 0) { $caseLines[] = '检查报告附件:已上传' . $reportFileCount . '份(未提供附件内容)'; } foreach (self::CASE_FIELDS as [$caption, $keys]) { $value = self::firstNonEmpty($diagnosis, $keys); if ($value !== '') { $caseLines[] = $caption . ':' . $value; } } $fingerprintPayload = [ 'gender' => $gender, 'age' => $age, 'case_lines' => $caseLines, ]; $fingerprintJson = json_encode( $fingerprintPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ) ?: '{}'; $caseTitle = $demographics !== '' ? $demographics . ' 病例' : '诊单病例'; return [ 'diagnosis_id' => (int) ($diagnosis['id'] ?? 0), 'patient_name' => self::cleanText($diagnosis['patient_name'] ?? '', 50), 'demographics' => $demographics, 'case_title' => $caseTitle, 'case_lines' => $caseLines, 'case_text' => implode("\n", $caseLines), 'case_json' => $fingerprintJson, 'fingerprint' => hash('sha256', $fingerprintJson), 'diagnosis_updated_at' => (string) ($diagnosis['update_time'] ?? ''), ]; } /** * @param array $context */ private static function buildPrompt(array $context): string { $caseBlock = $context['case_text'] !== '' ? $context['case_text'] : '(病例字段为空)'; $caseBlock = self::redactSensitiveIdentifiers($caseBlock); $demographics = $context['demographics'] !== '' ? $context['demographics'] : '未填写'; $demographics = self::redactSensitiveIdentifiers($demographics); return << $context * @return array{prescription_name:string,formula_type:string,herbs_json:string,prompt_version:string} */ private static function buildUpstreamInputs( array $context, string $formulaType, string $promptVersion ): array { return [ 'prescription_name' => self::redactSensitiveIdentifiers( (string) ($context['case_title'] ?? '诊单病例') ), 'formula_type' => $formulaType, 'herbs_json' => self::redactSensitiveIdentifiers( (string) ($context['case_json'] ?? '{}') ), 'prompt_version' => $promptVersion, ]; } /** * 旧请求未提供模型键时保持 qwen;显式选择只接受精确的小写白名单键。 */ private static function selectAnalysisProfile(?string $modelKey = null): ?string { $modelKey = $modelKey ?? 'qwen'; return in_array($modelKey, self::MODEL_KEYS, true) ? $modelKey : null; } private static function selectAssistantProfile(string $task, string $prompt): string { $taskConfig = self::ASSISTANT_TASKS[$task] ?? self::ASSISTANT_TASKS['summary']; if ($task !== 'custom') { return $taskConfig['profile']; } // 自由提问先识别具体临床领域,再处理泛化的风险词,避免“用药风险” // 被误归到并发症模型。 if (preg_match('/处方|中医|中药|用药|药物|辨证|证候|方剂|舌|脉/u', $prompt)) { return 'qwen'; } if (preg_match('/检查|检验|化验|影像|并发症|指南|风险|预后|急症/u', $prompt)) { return 'openai'; } return 'qwen'; } /** @param array $context */ private static function buildAnalysisPrompt(array $context): string { $caseBlock = (string) ($context['case_text'] ?? ''); $caseBlock = $caseBlock !== '' ? $caseBlock : '(病例字段为空)'; $caseBlock = self::escapeAssistantData(self::redactSensitiveIdentifiers($caseBlock)); $caseBlock = self::cleanText($caseBlock, self::MAX_ANALYSIS_CASE_LENGTH, true); $demographics = (string) ($context['demographics'] ?? ''); $demographics = $demographics !== '' ? $demographics : '未填写'; $demographics = self::escapeAssistantData(self::redactSensitiveIdentifiers($demographics)); $maxAdvice = self::MAX_ANALYSIS_ADVICE_LENGTH; $maxRisks = self::MAX_ANALYSIS_RISK_ITEMS; $maxRiskLabel = self::MAX_ANALYSIS_RISK_LABEL_LENGTH; return << 患者概况:{$demographics} {$caseBlock} 只输出一个 JSON 对象,禁止代码块和额外文字。字段与约束如下: {"diagnosis_advice":"诊断与辨证建议","risk_assessment":[{"label":"可由病历支持的风险点","level":"high"}],"treatment_advice":"处理、复核与随访建议"} - diagnosis_advice 与 treatment_advice 必须是非空字符串,各不超过 {$maxAdvice} 个字符。 - risk_assessment 必须是数组,最多 {$maxRisks} 项;没有充分风险依据时输出空数组。 - 每项只能使用 label 和 level 语义;label 必须是非空字符串且不超过 {$maxRiskLabel} 个字符。 - level 只能严格使用小写枚举 high、medium、low,不得使用其他值。 - 不编造病历未记录的检查结果、证候、疾病、药物或剂量;每条风险应能由已提供资料支持。 PROMPT; } /** @param array $context */ private static function buildAssistantPrompt(array $context, string $task, string $prompt): string { $taskConfig = self::ASSISTANT_TASKS[$task] ?? self::ASSISTANT_TASKS['summary']; $caseBlock = $context['case_text'] !== '' ? $context['case_text'] : '(病例字段为空)'; $caseBlock = self::escapeAssistantData(self::redactSensitiveIdentifiers($caseBlock)); $demographics = $context['demographics'] !== '' ? $context['demographics'] : '未填写'; $demographics = self::escapeAssistantData(self::redactSensitiveIdentifiers($demographics)); $question = self::escapeAssistantData(self::redactSensitiveIdentifiers( $prompt !== '' ? $prompt : $taskConfig['instruction'] )); return << 患者概况:{$demographics} {$caseBlock} {$question} 请直接给出简洁、分点的专业回答,并在末尾提示需由执业医师结合完整资料复核。 PROMPT; } private static function escapeAssistantData(string $value): string { // 防止病例或自由问题伪造提示词边界标签。 return str_replace(['<', '>'], ['<', '>'], $value); } private static function redactSensitiveIdentifiers(string $value): string { $value = preg_replace('/(? $context */ private static function upsertGeneratedReport( array $context, string $modelKey, string $modelName, string $modelLabel, string $content, string $messageId, int $adminId ): int { $now = time(); $row = [ 'diagnosis_id' => (int) $context['diagnosis_id'], 'model_key' => $modelKey, 'model_name' => self::cleanText($modelName, 100), 'model_label' => self::cleanText($modelLabel, 50), 'report_content' => $content, 'message_id' => self::cleanText($messageId, 191), 'prompt_version' => self::PROMPT_VERSION, 'case_fingerprint' => (string) $context['fingerprint'], 'generated_by' => $adminId, 'generated_time' => $now, 'edited_by' => 0, 'edited_time' => 0, 'create_time' => $now, 'update_time' => $now, ]; Db::name('diagnosis_ai_report')->duplicate([ 'model_name', 'model_label', 'report_content', 'message_id', 'prompt_version', 'case_fingerprint', 'generated_by', 'generated_time', 'edited_by', 'edited_time', 'update_time', ])->insert($row); return (int) Db::name('diagnosis_ai_report') ->where('diagnosis_id', (int) $context['diagnosis_id']) ->where('model_key', $modelKey) ->value('id'); } /** * @param array $context * @param array $adminInfo * @return array */ private static function buildReportsPayload(array $context, int $adminId, array $adminInfo): array { $rows = DiagnosisAiReport::where( 'diagnosis_id', (int) $context['diagnosis_id'] )->order('id', 'asc')->select()->toArray(); $rowsByModel = []; foreach ($rows as $row) { $modelKey = (string) ($row['model_key'] ?? ''); if (in_array($modelKey, self::MODEL_KEYS, true)) { $rowsByModel[$modelKey] = $row; } } $reports = []; foreach (self::MODEL_KEYS as $modelKey) { if (isset($rowsByModel[$modelKey])) { $reports[] = self::formatReportRow( $rowsByModel[$modelKey], (string) $context['fingerprint'] ); } } $canView = self::hasPermission($adminId, $adminInfo, self::PERMISSION_READ); $canRefresh = self::hasPermission($adminId, $adminInfo, self::PERMISSION_REFRESH); $canEdit = self::hasPermission($adminId, $adminInfo, self::PERMISSION_EDIT); return [ 'diagnosis_id' => (int) $context['diagnosis_id'], 'patient_name' => (string) $context['patient_name'], 'case_summary' => (string) $context['case_text'], 'diagnosis_updated_at' => (string) $context['diagnosis_updated_at'], 'case_fingerprint' => (string) $context['fingerprint'], 'prompt_version' => self::PROMPT_VERSION, 'reports' => $reports, 'missing_model_keys' => array_values(array_diff(self::MODEL_KEYS, array_keys($rowsByModel))), 'can_view' => $canView, 'can_refresh' => $canRefresh, 'can_edit' => $canEdit, 'capabilities' => [ 'can_view' => $canView, 'can_refresh' => $canRefresh, 'can_edit' => $canEdit, ], ]; } /** * @param array $row * @return array */ private static function formatReportRow(array $row, string $currentFingerprint): array { $content = (string) ($row['report_content'] ?? ''); $generatedTime = (int) ($row['generated_time'] ?? 0); $editedTime = (int) ($row['edited_time'] ?? 0); return [ 'id' => (int) ($row['id'] ?? 0), 'report_id' => (int) ($row['id'] ?? 0), 'model_key' => (string) ($row['model_key'] ?? ''), 'model_name' => (string) ($row['model_name'] ?? ''), 'model_label' => (string) ($row['model_label'] ?? ''), 'content' => $content, 'report' => self::parseReport($content), 'message_id' => (string) ($row['message_id'] ?? ''), 'prompt_version' => (string) ($row['prompt_version'] ?? ''), 'case_fingerprint' => (string) ($row['case_fingerprint'] ?? ''), 'prescription_fingerprint' => (string) ($row['case_fingerprint'] ?? ''), 'is_stale' => !hash_equals( $currentFingerprint, (string) ($row['case_fingerprint'] ?? '') ), 'generated_by' => (int) ($row['generated_by'] ?? 0), 'generated_time' => $generatedTime, 'generated_at' => $generatedTime > 0 ? date('Y-m-d H:i:s', $generatedTime) : '', 'edited_by' => (int) ($row['edited_by'] ?? 0), 'edited_time' => $editedTime, 'edited_at' => $editedTime > 0 ? date('Y-m-d H:i:s', $editedTime) : '', 'is_edited' => $editedTime > 0, ]; } /** @return array> */ private static function modelConfigs(): array { $config = config('prescription_ai') ?: []; return is_array($config['models'] ?? null) ? $config['models'] : []; } /** * 容错接受 JSON 代码块、JSON 前后说明文字和常见单层包装字段,随后严格 * 校验业务字段类型、长度、风险数量与枚举。任何越界都返回 null。 * * @return array{ * diagnosis_advice:string, * risk_assessment:array, * treatment_advice:string * }|null */ private static function parseAnalysisResponse(string $content): ?array { $content = preg_replace('/^\x{FEFF}/u', '', trim($content)) ?? trim($content); if ($content === '' || strlen($content) > self::MAX_ANALYSIS_RESPONSE_BYTES) { return null; } $candidate = self::extractFirstJsonObject($content); if ($candidate === '') { return null; } $decoded = json_decode($candidate, true); if (!is_array($decoded)) { return null; } $decoded = self::unwrapAnalysisObject($decoded); if ($decoded === null) { return null; } $diagnosisAdvice = self::validatedAnalysisText( $decoded['diagnosis_advice'] ?? null, self::MAX_ANALYSIS_ADVICE_LENGTH, true ); $treatmentAdvice = self::validatedAnalysisText( $decoded['treatment_advice'] ?? null, self::MAX_ANALYSIS_ADVICE_LENGTH, true ); $riskAssessment = $decoded['risk_assessment'] ?? null; if ( $diagnosisAdvice === null || $treatmentAdvice === null || !is_array($riskAssessment) || !array_is_list($riskAssessment) || count($riskAssessment) > self::MAX_ANALYSIS_RISK_ITEMS ) { return null; } $risks = []; foreach ($riskAssessment as $risk) { if (!is_array($risk)) { return null; } $label = self::validatedAnalysisText( $risk['label'] ?? null, self::MAX_ANALYSIS_RISK_LABEL_LENGTH, false ); $level = $risk['level'] ?? null; if ($label === null || !is_string($level) || !in_array($level, self::RISK_LEVELS, true)) { return null; } $risks[] = [ 'label' => $label, 'level' => $level, ]; } return [ 'diagnosis_advice' => $diagnosisAdvice, 'risk_assessment' => $risks, 'treatment_advice' => $treatmentAdvice, ]; } private static function extractFirstJsonObject(string $content): string { $content = preg_replace('/^\s*```(?:json)?\s*/iu', '', $content) ?? $content; $content = preg_replace('/\s*```\s*$/u', '', $content) ?? $content; $start = strpos($content, '{'); if ($start === false) { return ''; } $depth = 0; $inString = false; $escaped = false; $length = strlen($content); for ($index = $start; $index < $length; $index++) { $character = $content[$index]; if ($inString) { if ($escaped) { $escaped = false; continue; } if ($character === '\\') { $escaped = true; continue; } if ($character === '"') { $inString = false; } continue; } if ($character === '"') { $inString = true; continue; } if ($character === '{') { $depth++; continue; } if ($character === '}') { $depth--; if ($depth === 0) { return substr($content, $start, $index - $start + 1); } if ($depth < 0) { return ''; } } } return ''; } /** @param array $decoded */ private static function unwrapAnalysisObject(array $decoded): ?array { for ($depth = 0; $depth < 2; $depth++) { if ( array_key_exists('diagnosis_advice', $decoded) || array_key_exists('risk_assessment', $decoded) || array_key_exists('treatment_advice', $decoded) ) { return $decoded; } $wrapped = null; foreach (['analysis', 'data', 'result', 'output'] as $key) { if (is_array($decoded[$key] ?? null)) { $wrapped = $decoded[$key]; break; } if (is_string($decoded[$key] ?? null)) { $candidate = self::extractFirstJsonObject($decoded[$key]); $candidate = $candidate !== '' ? json_decode($candidate, true) : null; if (is_array($candidate)) { $wrapped = $candidate; break; } } } if (!is_array($wrapped)) { return null; } $decoded = $wrapped; } return ( array_key_exists('diagnosis_advice', $decoded) || array_key_exists('risk_assessment', $decoded) || array_key_exists('treatment_advice', $decoded) ) ? $decoded : null; } /** @param mixed $value */ private static function validatedAnalysisText($value, int $maxLength, bool $preserveLines): ?string { if (!is_string($value)) { return null; } $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? $value; $text = str_replace(['<', '>'], ['<', '>'], trim($text)); if ($preserveLines) { $text = preg_replace('/[ \t]+/u', ' ', $text) ?? $text; $text = preg_replace('/\R{3,}/u', "\n\n", $text) ?? $text; } else { $text = preg_replace('/\s+/u', ' ', $text) ?? $text; } if ($text === '' || mb_strlen($text) > $maxLength) { return null; } return $text; } /** @param mixed $value */ private static function attachmentCount($value): int { if (is_string($value)) { $decoded = json_decode($value, true); $value = is_array($decoded) ? $decoded : explode(',', $value); } if (!is_array($value)) { return 0; } $count = 0; foreach ($value as $item) { if (is_scalar($item) && trim((string) $item) !== '') { $count++; } } return min($count, 100); } /** @return array|null */ private static function parseReport(string $content): ?array { $textCandidate = trim($content); $candidate = $textCandidate; $candidate = preg_replace('/^```(?:json)?\s*|\s*```$/iu', '', $candidate) ?? $candidate; $start = strpos($candidate, '{'); $end = strrpos($candidate, '}'); if ($start !== false && $end !== false && $end >= $start) { $candidate = substr($candidate, $start, $end - $start + 1); } $decoded = json_decode($candidate, true); if (!is_array($decoded)) { $decoded = self::parseStructuredTextReport($textCandidate); } if (!is_array($decoded)) { return null; } $report = [ 'summary' => self::cleanText($decoded['summary'] ?? '', 500), 'possible_symptoms' => self::cleanList($decoded['possible_symptoms'] ?? []), 'main_indications' => self::cleanText($decoded['main_indications'] ?? '', 800), 'efficacy' => self::cleanList($decoded['efficacy'] ?? []), 'suitable_people' => self::cleanList($decoded['suitable_people'] ?? []), 'compatibility_analysis' => self::cleanText($decoded['compatibility_analysis'] ?? '', 1500), 'cautions' => self::cleanList($decoded['cautions'] ?? []), 'disclaimer' => self::cleanText( $decoded['disclaimer'] ?? '仅供专业人员辅助辨证,不替代面诊、诊断和处方审核。', 500 ), ]; $hasContent = $report['summary'] !== '' || $report['main_indications'] !== '' || $report['efficacy'] !== [] || $report['possible_symptoms'] !== []; return $hasContent ? $report : null; } /** * @return array|null */ private static function parseStructuredTextReport(string $content): ?array { $content = preg_replace('/^\x{FEFF}/u', '', trim($content)) ?? trim($content); if ($content === '') { return null; } $lines = preg_split('/\R/u', $content) ?: []; $expectedTitles = array_keys(self::TEXT_REPORT_SECTIONS); $sections = array_fill_keys($expectedTitles, []); $seenTitles = []; $currentTitle = null; foreach ($lines as $line) { $trimmed = trim((string) $line); $possibleTitle = preg_replace('/[::]\s*$/u', '', $trimmed) ?? $trimmed; if (array_key_exists($possibleTitle, self::TEXT_REPORT_SECTIONS)) { $expectedTitle = $expectedTitles[count($seenTitles)] ?? null; if ($possibleTitle !== $expectedTitle || isset($seenTitles[$possibleTitle])) { return null; } $seenTitles[$possibleTitle] = true; $currentTitle = $possibleTitle; continue; } if ($currentTitle === null) { if ($trimmed !== '') { return null; } continue; } $sections[$currentTitle][] = (string) $line; } if (array_keys($seenTitles) !== $expectedTitles) { return null; } $decoded = []; foreach (self::TEXT_REPORT_SECTIONS as $title => $field) { $sectionLines = $sections[$title]; if (in_array($field, self::TEXT_REPORT_LIST_FIELDS, true)) { $decoded[$field] = self::parseStructuredTextList($sectionLines); continue; } $value = trim(implode("\n", $sectionLines)); $decoded[$field] = $value === '暂无' ? '' : $value; } return $decoded; } /** * @param array $lines * @return array */ private static function parseStructuredTextList(array $lines): array { $items = []; foreach ($lines as $line) { $item = trim((string) $line); if ($item === '' || $item === '暂无' || $item === '-' || $item === '•') { continue; } $item = preg_replace('/^(?:-\s+|•\s*)/u', '', $item) ?? $item; $item = trim($item); if ($item !== '' && $item !== '暂无') { $items[] = $item; } } return $items; } /** * @param mixed $value * @return array */ private static function cleanList($value): array { if (is_string($value) && trim($value) !== '') { $value = preg_split('/[\r\n;;]+/u', $value) ?: []; } if (!is_array($value)) { return []; } $items = []; foreach (array_slice($value, 0, 10) as $item) { $text = self::cleanText($item, 300); if ($text !== '') { $items[] = $text; } } return $items; } /** @param mixed $value */ private static function cleanText($value, int $maxLength, bool $preserveLines = false): string { if (is_array($value)) { $parts = []; foreach ($value as $item) { if (is_scalar($item) && trim((string) $item) !== '') { $parts[] = trim((string) $item); } } $value = implode('、', $parts); } if (!is_scalar($value)) { return ''; } $text = trim((string) $value); if (!$preserveLines) { $text = preg_replace('/\s+/u', ' ', $text) ?? $text; } return mb_substr($text, 0, $maxLength); } /** * @param array $row * @param array $keys */ private static function firstNonEmpty(array $row, array $keys): string { foreach ($keys as $key) { $text = self::cleanText($row[$key] ?? '', 800); if ($text !== '') { return $text; } } return ''; } /** @param mixed $value */ private static function genderText($value): string { $normalized = strtolower(trim((string) $value)); return match ($normalized) { '1', 'm', 'male', '男' => '男', '2', 'f', 'female', '女' => '女', '0', 'unknown', '未知' => '未知', default => self::cleanText($value, 10), }; } }