*/ 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' => '分析病历中的处方或用药信息,提示配伍、剂量和特殊人群的复核重点。', ], 'prescription_generate' => [ 'label' => '生成处方草稿', 'profile' => 'qwen', 'instruction' => '依据患者纵向完整资料生成结构化中医处方草稿;不得生成医师身份、签名或审核结论。', ], 'medication_review' => [ 'label' => '用药复核', 'profile' => 'qwen', 'instruction' => '梳理当前用药与病历的关联,提示需由医师或药师核对的相互作用和用药风险。', ], 'exam_review' => [ 'label' => '检查解读', 'profile' => 'qwen', 'instruction' => '解读已记录的检查或生命体征,区分已知、未知与需要进一步检查的项目。', ], 'complication_risk' => [ 'label' => '并发症风险', 'profile' => 'qwen', 'instruction' => '基于已记录信息梳理可能的并发症和风险分层,并指出判断依据与信息缺口。', ], 'guideline_review' => [ 'label' => '指南核对', 'profile' => 'qwen', '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', ]; /** * AI 助手可选诊单。权限沿用 AI 助手,但数据仍按“我的患者”范围收窄。 * * @param array $params * @param array $adminInfo * @return array{lists:array>,count:int,page_no:int,page_size:int}|null */ public static function patientOptions(array $params, int $adminId, array $adminInfo): ?array { if ($adminId <= 0 || !self::hasPermission($adminId, $adminInfo, self::PERMISSION_ASSISTANT)) { self::setError('权限不足,无法选择 AI 助手患者诊单'); return null; } $normalized = self::normalizePatientOptionsParams($params); if ($normalized === null) { return null; } $diagnosisTable = (new Diagnosis())->getTable(); $appointmentTable = (new \app\common\model\doctor\Appointment())->getTable(); $appointmentDateTime = "CONCAT(patient_option_apt.appointment_date, ' ', " . "COALESCE(NULLIF(TRIM(patient_option_apt.appointment_time), ''), '00:00:00'))"; $lastVisitSql = "SELECT MAX({$appointmentDateTime}) FROM {$appointmentTable} patient_option_apt" . ' WHERE patient_option_apt.patient_id = d.id AND patient_option_apt.status = 3'; $nextAppointmentSql = "SELECT MIN({$appointmentDateTime}) FROM {$appointmentTable} patient_option_apt" . ' WHERE patient_option_apt.patient_id = d.id AND patient_option_apt.status = 1' . " AND {$appointmentDateTime} >= NOW()"; $query = Db::table($diagnosisTable) ->alias('d') ->where('d.status', 1) ->whereNull('d.delete_time'); MyPatientLogic::applyScope($query, $adminId, $adminInfo); $keyword = $normalized['keyword']; if ($keyword !== '') { $like = '%' . $keyword . '%'; $query->where(static function ($keywordQuery) use ($keyword, $like): void { $keywordQuery->where('d.patient_name', 'like', $like) ->whereOr('d.phone', 'like', $like); if (ctype_digit($keyword)) { $id = (int) $keyword; $keywordQuery->whereOr('d.id', $id) ->whereOr('d.patient_id', $id); } }); } $count = (int) (clone $query)->count('d.id'); $offset = ($normalized['page_no'] - 1) * $normalized['page_size']; $rows = $query ->field([ 'd.id AS diagnosis_id', 'd.patient_id AS source_patient_id', 'd.patient_name', 'd.gender', 'd.age', 'd.phone AS phone_value', 'd.diagnosis_date', 'd.syndrome_type AS diagnosis_summary', Db::raw("({$lastVisitSql}) AS last_visit_at"), Db::raw("({$nextAppointmentSql}) AS next_appointment_at"), ]) ->orderRaw("CASE WHEN ({$lastVisitSql}) IS NULL THEN 1 ELSE 0 END ASC") ->orderRaw("({$lastVisitSql}) DESC") ->order('d.id', 'desc') ->limit($offset, $normalized['page_size']) ->select() ->toArray(); return [ 'lists' => array_map([self::class, 'formatPatientOptionRow'], $rows), 'count' => $count, 'page_no' => $normalized['page_no'], 'page_size' => $normalized['page_size'], ]; } /** * 病例摘要字段:仅临床内容,不把身份证/手机号送给模型。 * * @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, $adminId, $adminInfo); 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 { $prepared = self::prepareAssistant($diagnosisId, $task, $prompt, $adminId, $adminInfo); if ($prepared === null) { return null; } try { $result = DifyChatService::chat( $prepared['profile'], $prepared['inputs'], $prepared['query'], $prepared['user'], is_array($prepared['files'] ?? null) ? $prepared['files'] : [] ); } catch (\Throwable $e) { self::logAssistantFailure( $diagnosisId, $prepared['profile'], $adminId, $e, (string) ($prepared['task'] ?? '') ); self::setError('AI 助手暂时不可用,请稍后重试'); return null; } if (empty($result['ok'])) { self::logAssistantUpstreamError( $diagnosisId, $prepared['profile'], $adminId, (string) ($prepared['task'] ?? ''), is_array($result) ? $result : [] ); } return self::formatAssistantResult($prepared, $result); } /** * 在 SSE headers 发出前完成参数、权限、DataScope、病例和模型选择预检。 * 返回值只供同一请求内的流执行使用,绝不能直接序列化给客户端。 * * @param array $adminInfo * @return array{ * diagnosis_id:int,profile:string,model_name:string,model_label:string,task:string, * inputs:array,query:string,user:string,admin_id:int * }|null */ public static function prepareAssistant( int $diagnosisId, string $task, string $prompt, int $adminId, array $adminInfo ): ?array { self::$assistantErrorCode = 'AI_ASSISTANT_FAILED'; $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, $adminId, $adminInfo); 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; } if (!self::fitContextForPrompt($context, $profile)) { return null; } return [ 'diagnosis_id' => $diagnosisId, 'profile' => $profile, 'model_name' => $model, 'model_label' => $modelLabel, 'task' => $task, 'inputs' => self::buildUpstreamInputs( $context, '病例问诊助手', self::ASSISTANT_PROMPT_VERSION ), 'query' => self::buildAssistantPrompt($context, $task, $prompt), 'user' => 'admin-diagnosis-assistant-' . $adminId, 'admin_id' => $adminId, 'files' => is_array($context['files'] ?? null) ? $context['files'] : [], 'context_scope' => (string) ($context['context_scope'] ?? 'patient_longitudinal'), 'context_version' => (string) ($context['context_version'] ?? self::ASSISTANT_PROMPT_VERSION), 'source_summary' => is_array($context['source_summary'] ?? null) ? $context['source_summary'] : [], 'source_diagnosis_ids' => is_array($context['source_diagnosis_ids'] ?? null) ? $context['source_diagnosis_ids'] : [], ]; } /** * @param array $prepared prepareAssistant() 的内部返回值 * @param callable(string):mixed $onDelta * @param callable():bool|null $shouldAbort * @return array{answer:string,model_key:string,model_label:string,model_name:string,task:string}|null */ public static function streamPreparedAssistant( array $prepared, callable $onDelta, ?callable $shouldAbort = null ): ?array { $diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0); $profile = (string) ($prepared['profile'] ?? ''); $adminId = (int) ($prepared['admin_id'] ?? 0); $deliveredDelta = false; $forwardDelta = static function (string $delta) use (&$deliveredDelta, $onDelta) { $accepted = $onDelta($delta); if ($accepted !== false) { $deliveredDelta = true; } return $accepted; }; try { $result = DifyChatService::streamChat( $profile, is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [], (string) ($prepared['query'] ?? ''), (string) ($prepared['user'] ?? ''), $forwardDelta, $shouldAbort, is_array($prepared['files'] ?? null) ? $prepared['files'] : [] ); } catch (\Throwable $e) { self::logAssistantFailure( $diagnosisId, $profile, $adminId, $e, (string) ($prepared['task'] ?? '') ); self::$assistantErrorCode = 'UPSTREAM_UNAVAILABLE'; self::setError('AI 助手暂时不可用,请稍后重试'); return null; } if (empty($result['ok'])) { self::logAssistantUpstreamError( $diagnosisId, $profile, $adminId, (string) ($prepared['task'] ?? ''), is_array($result) ? $result : [] ); // Some Dify-compatible gateways accept blocking chat but reject or // incompletely terminate streaming responses. Before any delta has // reached the doctor it is safe to make one blocking compatibility // attempt; after a delta, retrying could duplicate clinical text. $streamErrorCode = strtoupper(trim((string) ($result['error_code'] ?? ''))); if ( !$deliveredDelta && in_array( $streamErrorCode, ['UPSTREAM_REJECTED', 'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE'], true ) ) { try { $result = DifyChatService::chat( $profile, is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [], (string) ($prepared['query'] ?? ''), (string) ($prepared['user'] ?? ''), is_array($prepared['files'] ?? null) ? $prepared['files'] : [] ); } catch (\Throwable $e) { self::logAssistantFailure( $diagnosisId, $profile, $adminId, $e, (string) ($prepared['task'] ?? '') ); } if (empty($result['ok'])) { self::logAssistantUpstreamError( $diagnosisId, $profile, $adminId, (string) ($prepared['task'] ?? ''), is_array($result) ? $result : [] ); } } } return self::formatAssistantResult($prepared, $result); } /** * @param array $prepared * @param array $result * @return array{answer:string,model_key:string,model_label:string,model_name:string,task:string}|null */ private static function formatAssistantResult(array $prepared, array $result): ?array { if (empty($result['ok'])) { // 附带上游错误码,让医生反馈时管理员能直接定位是配置、体积还是上游拒绝。 $message = (string) ($result['error'] ?? 'AI 助手暂时不可用,请稍后重试'); $errorCode = trim((string) ($result['error_code'] ?? '')); self::$assistantErrorCode = self::normaliseAssistantErrorCode($errorCode); if ($errorCode !== '') { $message .= '(' . $errorCode . ')'; } self::setError($message); return null; } $content = self::cleanText($result['content'] ?? '', self::MAX_REPORT_LENGTH, true); if ($content === '') { self::$assistantErrorCode = 'EMPTY_RESPONSE'; self::setError('AI 助手未返回内容,请重试'); return null; } $payload = [ 'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0), 'answer' => $content, 'model_key' => (string) ($prepared['profile'] ?? ''), 'model_label' => (string) ($prepared['model_label'] ?? ''), 'model_name' => (string) ($prepared['model_name'] ?? ''), 'task' => (string) ($prepared['task'] ?? ''), 'context_scope' => (string) ($prepared['context_scope'] ?? 'patient_longitudinal'), 'context_version' => (string) ($prepared['context_version'] ?? self::ASSISTANT_PROMPT_VERSION), 'source_summary' => is_array($prepared['source_summary'] ?? null) ? $prepared['source_summary'] : [], 'source_diagnosis_ids' => is_array($prepared['source_diagnosis_ids'] ?? null) ? array_values(array_map('intval', $prepared['source_diagnosis_ids'])) : [], ]; if (($prepared['task'] ?? '') === 'prescription_generate') { $draft = self::parsePrescriptionDraft($content); if ($draft === null) { self::setError('AI返回的处方草稿格式不符合要求,请重试'); return null; } $payload['answer'] = (string) ($draft['rationale'] ?? '已生成处方草稿,请逐项复核并签名。'); $payload['prescription_draft'] = $draft; } return $payload; } /** Return a safe code for the current SSE terminal error event. */ public static function getAssistantErrorCode(): string { return self::normaliseAssistantErrorCode(self::$assistantErrorCode); } private static function normaliseAssistantErrorCode(string $code): string { $code = strtoupper(trim($code)); return preg_match('/^[A-Z][A-Z0-9_]{2,63}$/', $code) === 1 ? $code : 'AI_ASSISTANT_FAILED'; } /** @return array|null */ private static function parsePrescriptionDraft(string $content): ?array { $json = self::extractFirstJsonObject($content); if ($json === '') { return null; } $decoded = json_decode($json, true); if (!is_array($decoded)) { return null; } foreach (['prescription_draft', 'prescription', 'data', 'result'] as $wrapper) { if (isset($decoded[$wrapper]) && is_array($decoded[$wrapper])) { $decoded = $decoded[$wrapper]; break; } } $clinical = self::cleanText($decoded['clinical_diagnosis'] ?? '', self::MAX_PRESCRIPTION_TEXT, true); $herbs = $decoded['herbs'] ?? null; if ($clinical === '' || !is_array($herbs) || !array_is_list($herbs) || $herbs === [] || count($herbs) > self::MAX_PRESCRIPTION_HERBS) { return null; } $normalizedHerbs = []; $seen = []; foreach ($herbs as $herb) { if (!is_array($herb)) { return null; } $name = self::cleanText($herb['name'] ?? $herb['medicine_name'] ?? '', 100); $dosage = filter_var($herb['dosage'] ?? null, FILTER_VALIDATE_FLOAT); $formula = trim((string) ($herb['formula_type'] ?? '主方')); $formula = in_array(strtolower($formula), ['2', 'aux', 'auxiliary', 'secondary'], true) || $formula === '辅方' ? '辅方' : '主方'; $key = mb_strtolower(preg_replace('/\s+/u', '', $name) ?? $name, 'UTF-8'); if ($name === '' || $dosage === false || $dosage <= 0 || $dosage > 10000 || isset($seen[$key])) { return null; } $seen[$key] = true; $item = [ 'name' => $name, 'dosage' => (float) $dosage, 'formula_type' => $formula, ]; $medicineId = (int) ($herb['medicine_id'] ?? $herb['id'] ?? 0); if ($medicineId > 0) { $item['medicine_id'] = $medicineId; } $normalizedHerbs[] = $item; } $integer = static function ($value, int $default, int $min, int $max): int { $number = filter_var($value, FILTER_VALIDATE_INT); return $number === false ? $default : max($min, min($max, (int) $number)); }; $dietary = $decoded['dietary_taboo'] ?? []; if (is_string($dietary)) { $dietary = preg_split('/[,,、]/u', $dietary) ?: []; } $dietary = is_array($dietary) ? array_values(array_filter(array_map( static fn ($item): string => trim((string) $item), $dietary ), static fn (string $item): bool => $item !== '')) : []; return [ 'clinical_diagnosis' => $clinical, 'prescription_name' => self::cleanText($decoded['prescription_name'] ?? 'AI处方草稿', 100), 'prescription_type' => self::cleanText($decoded['prescription_type'] ?? '饮片', 50), 'tongue' => self::cleanText($decoded['tongue'] ?? '', 500, true), 'tongue_image' => self::cleanText($decoded['tongue_image'] ?? '', 500, true), 'pulse' => self::cleanText($decoded['pulse'] ?? '', 500, true), 'pulse_condition' => self::cleanText($decoded['pulse_condition'] ?? '', 500, true), 'herbs' => $normalizedHerbs, 'dose_count' => $integer($decoded['dose_count'] ?? 7, 7, 1, 365), 'dose_unit' => self::cleanText($decoded['dose_unit'] ?? '剂', 20), 'usage_days' => $integer($decoded['usage_days'] ?? 7, 7, 1, 365), 'times_per_day' => $integer($decoded['times_per_day'] ?? 2, 2, 1, 6), 'usage_instruction' => self::cleanText($decoded['usage_instruction'] ?? '', 200, true), 'usage_time' => self::cleanText($decoded['usage_time'] ?? '饭后', 50), 'usage_way' => self::cleanText($decoded['usage_way'] ?? '温水送服', 50), 'dietary_taboo' => array_slice($dietary, 0, 30), 'usage_notes' => self::cleanText($decoded['usage_notes'] ?? '', 200, true), 'rationale' => self::cleanText($decoded['rationale'] ?? '', 2000, true), 'risk_warnings' => self::cleanText($decoded['risk_warnings'] ?? '', 2000, true), 'requires_doctor_review' => true, 'audit_status' => 0, ]; } private static function logAssistantFailure( int $diagnosisId, string $profile, int $adminId, \Throwable $exception, string $task = '' ): void { Log::warning('diagnosis ai assistant upstream call failed ' . json_encode([ 'diagnosis_id' => $diagnosisId, 'profile' => $profile, 'task' => $task, 'admin_id' => $adminId, 'exception_class' => get_class($exception), ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)); } /** * DifyChatService 在不抛异常时把上游错误以 ok=false/error_code/error 形式返回。 * 把这两个字段也写进日志,便于运维区分 UPSTREAM_REJECTED / UPSTREAM_TIMEOUT 等具体根因。 * * @param array $result */ private static function logAssistantUpstreamError( int $diagnosisId, string $profile, int $adminId, string $task, array $result ): void { $errorCode = strtoupper(trim((string) ($result['error_code'] ?? ''))); $errorMessage = trim((string) ($result['error'] ?? '')); $latencyMs = (int) ($result['latency_ms'] ?? 0); Log::warning('diagnosis ai assistant upstream rejected ' . json_encode([ 'diagnosis_id' => $diagnosisId, 'profile' => $profile, 'task' => $task, 'admin_id' => $adminId, 'upstream_error_code' => $errorCode !== '' ? $errorCode : 'UNKNOWN', 'has_upstream_error' => $errorMessage !== '', 'latency_ms' => $latencyMs, ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)); } /** * 接诊台结构化 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, $adminId, $adminInfo); 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; } if (!self::fitContextForPrompt($context, $profile)) { return null; } try { $result = DifyChatService::chat( $profile, self::buildUpstreamInputs( $context, '诊单结构化分析', self::ANALYSIS_PROMPT_VERSION ), self::buildAnalysisPrompt($context), 'admin-diagnosis-analysis-' . $adminId, is_array($context['files'] ?? null) ? $context['files'] : [] ); } 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, [ 'diagnosis_id' => $diagnosisId, 'model_key' => $profile, 'model_label' => $modelLabel, 'model_name' => $modelName, 'generated_at' => date('Y-m-d H:i:s'), 'context_scope' => (string) ($context['context_scope'] ?? 'patient_longitudinal'), 'context_version' => (string) ($context['context_version'] ?? self::ANALYSIS_PROMPT_VERSION), 'source_summary' => is_array($context['source_summary'] ?? null) ? $context['source_summary'] : [], 'source_diagnosis_ids' => is_array($context['source_diagnosis_ids'] ?? null) ? array_values(array_map('intval', $context['source_diagnosis_ids'])) : [], ]); } /** * @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, $adminId, $adminInfo); 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, ]; $modelContext = $context; if (!self::fitContextForPrompt($modelContext, $modelKey)) { $failureCount++; $results[] = array_merge($resultBase, [ 'status' => 'error', 'error_code' => 'CONTEXT_COMPACTION_FAILED', 'error_message' => '患者纵向资料过大,AI 分片读取失败,请稍后重试', 'latency_ms' => 0, ]); continue; } try { $result = DifyChatService::chat( $modelKey, self::buildUpstreamInputs($modelContext, '病例', self::PROMPT_VERSION), self::buildPrompt($modelContext), 'admin-diagnosis-' . $adminId, is_array($modelContext['files'] ?? null) ? $modelContext['files'] : [] ); } 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, $adminId, $adminInfo); 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 $params * @return array{keyword:string,page_no:int,page_size:int}|null */ private static function normalizePatientOptionsParams(array $params): ?array { $keyword = trim((string) ($params['keyword'] ?? '')); if (mb_strlen($keyword) > self::PATIENT_OPTIONS_MAX_KEYWORD_LENGTH) { self::setError('关键词最多64个字符'); return null; } $pageNo = max(1, (int) ($params['page_no'] ?? 1)); $pageSize = (int) ($params['page_size'] ?? self::PATIENT_OPTIONS_DEFAULT_PAGE_SIZE); if ($pageSize <= 0) { $pageSize = self::PATIENT_OPTIONS_DEFAULT_PAGE_SIZE; } $pageSize = min(self::PATIENT_OPTIONS_MAX_PAGE_SIZE, $pageSize); $pageNo = min($pageNo, intdiv(PHP_INT_MAX, $pageSize)); return [ 'keyword' => $keyword, 'page_no' => $pageNo, 'page_size' => $pageSize, ]; } /** * @param array $row * @return array */ private static function formatPatientOptionRow(array $row): array { $age = $row['age'] ?? null; $gender = $row['gender'] ?? null; return [ 'diagnosis_id' => (int) ($row['diagnosis_id'] ?? 0), 'source_patient_id' => (int) ($row['source_patient_id'] ?? 0), 'patient_name' => trim((string) ($row['patient_name'] ?? '')), 'gender' => $gender === null || $gender === '' ? null : (int) $gender, 'age' => $age === null || $age === '' ? null : (int) $age, 'phone_masked' => self::maskPatientPhone((string) ($row['phone_value'] ?? '')), 'diagnosis_date' => self::formatPatientOptionDate($row['diagnosis_date'] ?? null, true), 'diagnosis_summary' => trim((string) ($row['diagnosis_summary'] ?? '')), 'last_visit_at' => self::formatPatientOptionDate($row['last_visit_at'] ?? null), 'next_appointment_at' => self::formatPatientOptionDate($row['next_appointment_at'] ?? null), ]; } private static function maskPatientPhone(string $phone): string { $digits = preg_replace('/\D+/', '', trim($phone)) ?? ''; $length = strlen($digits); if ($length === 0) { return ''; } if ($length <= 4) { return str_repeat('*', $length); } if ($length < 8) { return substr($digits, 0, 1) . str_repeat('*', $length - 2) . substr($digits, -1); } return substr($digits, 0, 3) . str_repeat('*', max(4, $length - 7)) . substr($digits, -4); } /** @param mixed $value */ private static function formatPatientOptionDate($value, bool $dateOnly = false): ?string { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { $timestamp = (int) $value; return $timestamp > 0 ? date($dateOnly ? 'Y-m-d' : 'Y-m-d H:i:s', $timestamp) : null; } $text = trim((string) $value); if ($text === '') { return null; } return $dateOnly ? substr($text, 0, 10) : $text; } /** * @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; } if (!MyPatientLogic::canAccessDiagnosis($id, $adminId, $adminInfo)) { 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, int $adminId = 0, array $adminInfo = [] ): 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; } } $files = []; $sourceSummary = [ 'diagnosis_count' => 1, 'source_record_count' => 1, 'snapshot_complete' => $adminId <= 0, 'may_be_truncated' => false, ]; $sourceDiagnosisIds = [(int) ($diagnosis['id'] ?? 0)]; $contextScope = 'diagnosis_test_fallback'; if ($adminId > 0) { $longitudinal = PatientAiReportLogic::contextForAuthorizedDiagnosis( $diagnosis, $adminId, $adminInfo ); if ($longitudinal === null) { return [ 'diagnosis_id' => (int) ($diagnosis['id'] ?? 0), 'patient_name' => '', 'demographics' => '', 'case_title' => '患者纵向病例', 'case_lines' => [], 'case_text' => '', 'case_json' => '{}', 'fingerprint' => hash('sha256', '{}'), 'diagnosis_updated_at' => (string) ($diagnosis['update_time'] ?? ''), 'context_scope' => 'patient_longitudinal', 'context_version' => self::ASSISTANT_PROMPT_VERSION, 'source_summary' => [], 'source_diagnosis_ids' => [], 'files' => [], ]; } $safeSnapshot = is_array($longitudinal['snapshot'] ?? null) ? $longitudinal['snapshot'] : []; $safeJson = json_encode( $safeSnapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ) ?: '{}'; $caseLines = ['患者纵向完整资料(服务端实时聚合,JSON):' . $safeJson]; $sourceSummary = is_array($longitudinal['source_summary'] ?? null) ? $longitudinal['source_summary'] : []; $sourceDiagnosisIds = array_values(array_map( 'intval', is_array($longitudinal['source_diagnosis_ids'] ?? null) ? $longitudinal['source_diagnosis_ids'] : [] )); $files = is_array($longitudinal['files'] ?? null) ? array_values($longitudinal['files']) : []; $contextScope = 'patient_longitudinal'; } $fingerprintPayload = [ 'gender' => $gender, 'age' => $age, 'case_lines' => $caseLines, 'source_diagnosis_ids' => $sourceDiagnosisIds, 'source_summary' => $sourceSummary, 'file_count' => count($files), ]; $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'] ?? ''), 'context_scope' => $contextScope, 'context_version' => self::ASSISTANT_PROMPT_VERSION, 'source_summary' => $sourceSummary, 'source_diagnosis_ids' => $sourceDiagnosisIds, 'files' => $files, ]; } /** * @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, 'context_scope' => (string) ($context['context_scope'] ?? 'patient_longitudinal'), 'context_version' => (string) ($context['context_version'] ?? $promptVersion), 'source_counts_json' => json_encode( is_array($context['source_summary'] ?? null) ? $context['source_summary'] : [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE ) ?: '{}', 'source_diagnosis_count' => count( is_array($context['source_diagnosis_ids'] ?? null) ? $context['source_diagnosis_ids'] : [] ), 'attachment_count' => count( is_array($context['files'] ?? null) ? $context['files'] : [] ), ]; } /** * 旧请求未提供模型键时保持 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']; return $taskConfig['profile']; } /** @param array $context */ private static function buildAnalysisPrompt(array $context): string { $caseBlock = (string) ($context['case_text'] ?? ''); $caseBlock = $caseBlock !== '' ? $caseBlock : '(病例字段为空)'; $caseBlock = self::escapeAssistantData(self::redactSensitiveIdentifiers($caseBlock)); $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 fitContextForPrompt(array &$context, string $profile): bool { $caseText = (string) ($context['case_text'] ?? ''); if ($caseText === '' || strlen($caseText) <= self::MAX_PROMPT_SOURCE_BYTES) { return true; } try { $compacted = PatientAiReportLogic::compactSourceForPrompt($profile, $caseText); } catch (\Throwable $e) { Log::warning('diagnosis ai context compaction failed', [ 'diagnosis_id' => (int) ($context['diagnosis_id'] ?? 0), 'profile' => $profile, 'source_bytes' => strlen($caseText), 'exception_class' => get_class($e), ]); self::setError('患者纵向资料过大,AI 分片读取失败,请稍后重试'); return false; } $context['case_text'] = $compacted['text']; $context['case_lines'] = [$compacted['text']]; $summary = is_array($context['source_summary'] ?? null) ? $context['source_summary'] : []; $summary['analysis_chunk_count'] = $compacted['chunk_count']; $summary['analysis_reduction_rounds'] = $compacted['reduction_rounds']; $summary['analyzed_source_bytes'] = $compacted['source_bytes']; $summary['source_compacted'] = $compacted['compacted']; $context['source_summary'] = $summary; return true; } /** @param array $context */ private static function buildAssistantPrompt(array $context, string $task, string $prompt): string { if ($task === 'prescription_generate') { return self::buildPrescriptionDraftPrompt($context, $prompt); } $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; } /** @param array $context */ private static function buildPrescriptionDraftPrompt(array $context, string $prompt): string { $caseBlock = (string) ($context['case_text'] ?? ''); $caseBlock = $caseBlock !== '' ? $caseBlock : '(患者纵向资料为空)'; $caseBlock = self::escapeAssistantData(self::redactSensitiveIdentifiers($caseBlock)); $question = self::escapeAssistantData(self::redactSensitiveIdentifiers( $prompt !== '' ? $prompt : '请生成一份处方草稿' )); return << {$caseBlock} {$question} 只输出一个 JSON 对象,不要代码块或额外文字,严格使用以下结构: {"prescription_draft":{"prescription_name":"草稿名称","clinical_diagnosis":"临床诊断与辨证", "prescription_type":"饮片","tongue":"舌象文字","tongue_image":"舌苔/舌象说明","pulse":"脉象", "pulse_condition":"脉象详情","herbs":[{"name":"药名","dosage":10,"formula_type":"主方"}], "dose_count":7,"dose_unit":"剂","usage_days":7,"times_per_day":2, "usage_instruction":"水煎服,一日二次","usage_time":"饭后","usage_way":"温服", "dietary_taboo":["辛辣食物"],"usage_notes":"其他说明","rationale":"处方依据", "risk_warnings":"风险、矛盾、缺失信息和必须复核项"}} 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), }; } }