*/ private function resolveMrOutputFields(): array { return AiRuntimeConfigService::getInstance()->getMedicalRecordOutputFields(); } /** * 中医三典 prompt 约束:只提示按「中医临床诊疗术语」填写,不塞字典样本(省 token) * 治法限定为中药内服相关,避免模型返回针灸/推拿等外治名称 * 后端仍用本地字典校正名称并映射 diseases_id / method_id / syndrome_id * * @param list $needAiFields */ private function buildTcmDictPromptHint(array $needAiFields): string { $need = array_intersect(['tcm_disease', 'tcm_syndrome', 'tcm_method'], $needAiFields); if ($need === []) { return ''; } $parts = []; if (in_array('tcm_syndrome', $need, true)) { $parts[] = 'tcm_syndrome(中医证候)'; } if (in_array('tcm_method', $need, true)) { $parts[] = 'tcm_method(中医治法)'; } if (in_array('tcm_disease', $need, true)) { $parts[] = 'tcm_disease(中医疾病)'; } $hint = '以下字段必须严格采用国家标准《中医临床诊疗术语》中的证候、治法、疾病标准名称填写(可多项用中文逗号分隔,禁止自造俗称):' . implode('、', $parts) . '。'; // 本系统用于中药开方场景:治法须是药物内治,不能填针灸推拿等外治 if (in_array('tcm_method', $need, true)) { $hint .= 'tcm_method 只能填中药内服相关治法(如消食导滞、健脾益气、温阳利水、活血化瘀、疏肝和胃等),' . '且必须与本次 tcm_disease、tcm_syndrome 辨证一致,禁止随便套用与证候无关的治法(如食积胃痛却写疏风清热);' . '禁止填写针灸疗法、针刺、艾灸、推拿、按摩、刮痧、拔罐、理疗、外敷、熏洗等非药物/外治名称。'; } return $hint; } /** * 临床诊断 prompt:强制从 ICD-10 医保版 2.0 / 国临版标准库取病名 * 不塞全量字典(省 token);后端再用 yii_disease 校正为系统标准名 * 额外强调:诊断须紧扣主诉,禁止把既往慢病/无关病种写成首诊、禁止臆造分型 * * @param list $needAiFields */ private function buildIcdDiagnosisPromptHint(array $needAiFields): string { if (! in_array('diagnosis', $needAiFields, true)) { return ''; } return 'diagnosis(临床诊断)必须从以下两个国家标准疾病诊断库中选取正式病名(可多项,用中文逗号分隔),' . '须与主诉、现病史及患者年龄性别相符,禁止自造俗称、口语或非标准缩写:' . '① ICD-10 医保版 2.0(国家医保局《医保疾病诊断分类与代码》,医院 HIS、医保结算单实际使用);' . '② ICD-10 国临版(原国家卫健委《疾病分类与代码》GB/T 14396 体系,国标版 2016,含 x001 这类扩展后缀编码)。' . '推荐写法:每项「ICD编码 标准病名」(编码与名称之间一个空格,例如 J06.900x001 急性上呼吸道感染);' . '编码优先写医保版或国临版中与临床最贴切的一条;若同一诊断两库名称略有差异,以标准病名为准并带上对应编码。' . '禁止只写症状描述代替诊断,禁止输出未收录于上述两库的随意名称。' . '诊断排序与取舍必须谨慎:第一诊断必须直接解释本次主诉;' . '现病史/既往史中已明确的慢病可作后续诊断,但不得替代、覆盖与主诉无关的首诊;' . '证据不足以分型时禁止擅自改写分型(如把「2型糖尿病」写成「1型糖尿病」),禁止臆造主诉未支持的病种。'; } /** * 写病历整体谨慎约束:避免诊断、治法、医嘱与主诉脱节或互相矛盾 * 针对模型易把病史慢病当成本次主诊、中医三典自相矛盾等问题 * * @param list $needAiFields */ private function buildMedicalRecordCautionPromptHint(array $needAiFields): string { $need = array_flip($needAiFields); $parts = [ '【临床谨慎原则】本次生成是医生草稿辅助,必须保守、可复核,禁止脑补与主诉无关的重病或跨系统诊断。', '信息优先级:主诉 > 现病史中与主诉相关的描述 > 体征/辅助检查 > 既往史/个人史/家族史(后者仅作背景,不得喧宾夺主)。', ]; if (isset($need['diagnosis']) || isset($need['tcm_disease']) || isset($need['tcm_syndrome']) || isset($need['tcm_method'])) { $parts[] = '中西医诊断均须紧扣主诉对应的本次就诊问题;病史里的慢病若需写入诊断,只能作为次要/伴随诊断,且原文分型、病名不得擅自改写。'; } if (isset($need['tcm_disease']) || isset($need['tcm_syndrome']) || isset($need['tcm_method'])) { $parts[] = '中医病、证、治三者必须同一辨证体系内自洽:' . 'tcm_disease 填病名、tcm_syndrome 填证候、tcm_method 填与该证候对应的内治法;' . '禁止病/证/治错位或互不相关(例如食积类证候却配疏风清热)。'; } if (isset($need['treatment_advice']) || isset($need['doctor_order'])) { $parts[] = 'treatment_advice、doctor_order 必须围绕「主诉相关的第一诊断/证候」给出,' . '禁止只围绕无关慢病写降糖、复查血糖等与本次主诉脱节的医嘱。'; } $parts[] = '证据不足时选最贴近主诉的常见、保守表述,明确不确定处用审慎措辞;禁止为凑字数编造阳性检查或未提及的症状。'; return implode('', $parts); } /** * 读取模型原始 created_at 时间戳 * BaseModel 访问器会把 int 格式化成「Y-m-d H:i:s」,再 (int) 会变成年份(如 2026),故必须读 raw */ private function rawCreatedAtTimestamp(object $row): int { if (method_exists($row, 'getRawOriginal')) { $raw = $row->getRawOriginal('created_at'); if ($raw !== null && $raw !== '') { return (int) $raw; } } $attrs = method_exists($row, 'getAttributes') ? $row->getAttributes() : []; return (int) ($attrs['created_at'] ?? 0); } /** * 将 AI 返回的中医三典名称校正为字典标准名,并映射开方旧字段 id * 映射与开方 UI 一致:证候→diseases_id,疾病→syndrome_id,治法→method_id * * @param array $fields * @return array{fields: array, tcm_ids: array{diseases_id:int|null,method_id:int|null,syndrome_id:int|null}} */ private function normalizeTcmFieldsAgainstDict(array $fields): array { $dict = DoctorReceptionService::getInstance()->getTraditionalChineseMedicineAll(); $diseases = $dict['diseases'] ?? []; $method = $dict['method'] ?? []; $syndrome = $dict['syndrome'] ?? []; $tcmIds = [ 'diseases_id' => null, 'method_id' => null, 'syndrome_id' => null, ]; if (!empty($fields['tcm_syndrome'])) { // 证候 → diseases 表(开方 diseases_id) $hit = $this->matchTcmDictRow($diseases, (string) $fields['tcm_syndrome']); if ($hit) { $fields['tcm_syndrome'] = (string) ($hit['name'] ?? $fields['tcm_syndrome']); $tcmIds['diseases_id'] = isset($hit['id']) ? (int) $hit['id'] : null; } } if (!empty($fields['tcm_method'])) { $hit = $this->matchTcmDictRow($method, (string) $fields['tcm_method']); if ($hit) { $fields['tcm_method'] = (string) ($hit['name'] ?? $fields['tcm_method']); $tcmIds['method_id'] = isset($hit['id']) ? (int) $hit['id'] : null; } } if (!empty($fields['tcm_disease'])) { // 疾病 → syndrome 表(开方 syndrome_id) $hit = $this->matchTcmDictRow($syndrome, (string) $fields['tcm_disease']); if ($hit) { $fields['tcm_disease'] = (string) ($hit['name'] ?? $fields['tcm_disease']); $tcmIds['syndrome_id'] = isset($hit['id']) ? (int) $hit['id'] : null; } } return ['fields' => $fields, 'tcm_ids' => $tcmIds]; } /** * 名称/别名匹配字典行(取逗号分隔第一项) * * @param list $rows */ private function matchTcmDictRow(array $rows, string $raw): ?array { $first = trim(explode(',', str_replace(',', ',', $raw))[0] ?? ''); if ($first === '') { return null; } foreach ($rows as $row) { $name = trim((string) ($row['name'] ?? '')); if ($name !== '' && $name === $first) { return $row; } } foreach ($rows as $row) { $alias = (string) ($row['alias'] ?? ''); if ($alias === '') { continue; } $parts = preg_split('/[,,]/', $alias) ?: []; foreach ($parts as $p) { if (trim((string) $p) === $first) { return $row; } } } foreach ($rows as $row) { $name = trim((string) ($row['name'] ?? '')); if ($name !== '' && (str_contains($name, $first) || str_contains($first, $name))) { return $row; } } return null; } /** * 将 AI 返回的临床诊断校正为本系统疾病字典(yii_disease)标准名 * 字典收录 ICD-10 医保版 / 国临版编码与病名;匹配不上则去掉编码只留病名 * * @param array $fields * @return array{fields: array, diagnosis_ids: list} */ private function normalizeDiagnosisAgainstDict(array $fields): array { $raw = trim((string) ($fields['diagnosis'] ?? '')); if ($raw === '') { return ['fields' => $fields, 'diagnosis_ids' => []]; } $parts = preg_split('/[,,、;;]+/u', $raw) ?: []; $names = []; $ids = []; foreach ($parts as $part) { $part = trim((string) $part); if ($part === '') { continue; } [$code, $name] = $this->parseIcdDiagnosisToken($part); $hit = $this->matchDiseaseDictRow($code, $name); if ($hit !== null) { $stdName = trim((string) ($hit['name'] ?? '')); if ($stdName !== '' && ! in_array($stdName, $names, true)) { $names[] = $stdName; } $hid = (int) ($hit['id'] ?? 0); if ($hid > 0 && ! in_array($hid, $ids, true)) { $ids[] = $hid; } continue; } // 未命中字典:去掉编码,保留病名文本,避免把乱码编码带进开方诊断 $fallback = $name !== '' ? $name : $part; if ($fallback !== '' && ! in_array($fallback, $names, true)) { $names[] = $fallback; } } if ($names !== []) { $fields['diagnosis'] = implode(',', $names); } return ['fields' => $fields, 'diagnosis_ids' => $ids]; } /** * 解析「编码 名称」或纯名称/纯编码 * * @return array{0:string,1:string} [code, name] */ private function parseIcdDiagnosisToken(string $token): array { $token = trim($token); // ICD-10:如 J06.900 / J06.900x001 / A01.000x005+J17.0* $pattern = '/^([A-Za-z]\d{2}(?:\.\d{1,4})?(?:x\d{3})?(?:\+[A-Za-z]\d{2}(?:\.\d+)?\*?)?)\s+(.+)$/u'; if (preg_match($pattern, $token, $m)) { return [strtoupper($m[1]), trim($m[2])]; } if (preg_match('/^([A-Za-z]\d{2}(?:\.\d{1,4})?(?:x\d{3})?(?:\+[A-Za-z]\d{2}(?:\.\d+)?\*?)?)$/u', $token, $m)) { return [strtoupper($m[1]), '']; } return ['', $token]; } /** * 在 yii_disease 中按编码/名称匹配一条标准诊断 * * @return array|null */ private function matchDiseaseDictRow(string $code, string $name): ?array { $code = strtoupper(trim($code)); $name = trim($name); if ($code === '' && $name === '') { return null; } // 1) 编码精确匹配(diagnose_code / major_number / ref_number,覆盖医保版与国临版编号位) if ($code !== '') { $byCode = DiseaseModel::query() ->where('is_delete', 0) ->where(function ($q) use ($code) { $q->where('diagnose_code', $code) ->orWhere('major_number', $code) ->orWhere('ref_number', $code); }) ->orderBy('id') ->first(['id', 'name', 'diagnose_code', 'major_number', 'ref_number']); if ($byCode !== null) { return $byCode->toArray(); } } // 2) 名称精确匹配 if ($name !== '') { $byName = DiseaseModel::query() ->where('is_delete', 0) ->where('name', $name) ->orderBy('id') ->first(['id', 'name', 'diagnose_code', 'major_number', 'ref_number']); if ($byName !== null) { return $byName->toArray(); } // 3) 名称模糊:优先「字典名包含 AI 名」或「AI 名包含字典名」,取最短更具体者 $candidates = DiseaseModel::query() ->where('is_delete', 0) ->where(function ($q) use ($name) { $q->where('name', 'like', '%' . $name . '%') ->orWhere('name', 'like', $name . '%'); }) ->orderByRaw('CHAR_LENGTH(name) asc') ->limit(8) ->get(['id', 'name', 'diagnose_code', 'major_number', 'ref_number']); if ($candidates->isNotEmpty()) { $best = null; $bestScore = -1; foreach ($candidates as $row) { $dn = trim((string) $row->name); if ($dn === '') { continue; } $score = 0; if ($dn === $name) { $score = 100; } elseif (str_starts_with($dn, $name) || str_starts_with($name, $dn)) { $score = 80; } elseif (str_contains($dn, $name) || str_contains($name, $dn)) { $score = 60; } // 同名更短更具体(避免过宽父类) $score += max(0, 40 - mb_strlen($dn)); if ($score > $bestScore) { $bestScore = $score; $best = $row; } } if ($best !== null && $bestScore >= 60) { return $best->toArray(); } } } return null; } /** * 生成前可选上下文字段白名单(主诉外非必填) * * @return list */ private function mrContextFieldCodes(): array { return [ 'present_illness', 'past_history', 'allergy_history', 'menstrual_history', 'family_history', 'marital_history', 'personal_history', 'physical_exam', 'auxiliary_exam', 'tongue', 'pulse', ]; } /** * 规范化可选上下文:前端非空优先,否则用库内已有非空值 * * @return array */ private function normalizeMrContextFields(array $fromFront, array $fromDb): array { $out = []; foreach ($this->mrContextFieldCodes() as $code) { $front = isset($fromFront[$code]) && is_scalar($fromFront[$code]) ? trim((string) $fromFront[$code]) : ''; $db = isset($fromDb[$code]) && is_scalar($fromDb[$code]) ? trim((string) $fromDb[$code]) : ''; $val = $front !== '' ? $front : $db; if ($val !== '') { $out[$code] = $val; } } return $out; } /** * 把 AI 可能返回的中文键名映射回配置编码(如「医嘱」→ doctor_order) * * @param array $parsed * @return array */ private function normalizeMrAiParsedKeys(array $parsed): array { if ($parsed === []) { return []; } $labelToCode = []; foreach (AiRuntimeConfigService::medicalRecordFieldWhitelist() as $code) { $labelToCode[AiRuntimeConfigService::medicalRecordFieldLabel($code)] = $code; } // 常见别名 $labelToCode['医嘱建议'] = 'doctor_order'; $labelToCode['临床诊断'] = 'diagnosis'; $labelToCode['诊断'] = 'diagnosis'; $out = []; foreach ($parsed as $key => $val) { if (!is_string($key) && !is_int($key)) { continue; } $k = trim((string) $key); if ($k === '') { continue; } if (isset($labelToCode[$k])) { $k = $labelToCode[$k]; } // 已有英文键优先,避免中文键覆盖 if (isset($out[$k]) && is_scalar($out[$k]) && trim((string) $out[$k]) !== '') { continue; } $out[$k] = $val; } return $out; } /** * VIP 校验 */ public function assertVip(int $storeId, string $code): void { if ($storeId <= 0) { $this->utils->errorThrow('无法识别门店,无法使用 AI 功能'); } if (!VipGateService::getInstance()->hasPermission($storeId, $code)) { $label = $code === self::VIP_MR ? 'AI写病历' : 'AI辅助出方'; $this->utils->errorThrow('当前门店未开通' . $label . 'VIP功能'); } } /** * AI 写病历:校验主诉 → 请求前落库 → 调模型 → 成败回写 → 返回草稿字段(不自动保存病历) * 主诉优先用前端传入;AI/运行时异常软失败(ok=false),校验仍硬抛 * * @param int $registerId 挂号 ID * @param int $storeId 门店 * @param int $doctorId 医生 * @param string|null $provider 可选覆盖供应商 * @param string $chiefComplaint 前端表单主诉 * @param array $contextFields 生成前可选填字段(现病史/既往史等,非必填) */ public function generateMedicalRecord( int $registerId, int $storeId, int $doctorId = 0, ?string $provider = null, string $chiefComplaint = '', array $contextFields = [] ): array { $this->assertVip($storeId, self::VIP_MR); if ($registerId <= 0) { $this->utils->errorThrow('挂号ID无效'); } $ctx = $this->loadPatientContext($registerId); // 年龄/性别缺失时生成易偏离临床,必须先完善就诊人再写病历 $this->assertPatientDemographicsForAi($ctx); $mr = MedicalRecordModel::where('register_id', $registerId)->where('deleted_at', 0)->first(); // 优先用前端传入的表单主诉,避免为生成而强制先保存 $chief = trim($chiefComplaint); if ($chief === '') { $chief = trim((string) ($mr->chief_complaint ?? '')); } if ($chief === '') { $this->utils->errorThrow('请先填写主诉后再生成'); } // 规范化可选上下文:仅白名单内非空字段(前端优先,库值兜底) $context = $this->normalizeMrContextFields($contextFields, $mr ? $mr->toArray() : []); $inputSnapshot = [ 'age' => $ctx['age'], // 快照存中文性别,避免下游/排障看到库内枚举 1/2 'sex' => (string) ($ctx['sex_label'] ?? $this->formatSexLabel($ctx['sex'] ?? 0)), 'chief_complaint' => $chief, 'patient_name' => $ctx['name'], 'context_fields' => $context, ]; // 校验通过后再 begin:请求前落库,保证失败也可追溯 $row = $this->beginGeneration( $storeId, $registerId, $doctorId, self::SCENE_MR, 0, $provider, $inputSnapshot ); $chat = null; try { $sexLabel = (string) ($ctx['sex_label'] ?? $this->formatSexLabel($ctx['sex'] ?? 0)); // 仅要求系统配置勾选的字段,减少无关输出、加快生成 $outputFields = $this->resolveMrOutputFields(); if (empty($outputFields)) { throw new \RuntimeException('未配置 AI 写病历输出字段'); } // 医生已填的配置字段不再要求 AI 生成,最终结果优先保留医生原文 $needAiFields = []; foreach ($outputFields as $field) { if (!isset($context[$field]) || $context[$field] === '') { $needAiFields[] = $field; } } $fields = []; foreach ($outputFields as $field) { $fields[$field] = $context[$field] ?? ''; } if (empty($needAiFields)) { $chat = new AiChatResult( '{}', [], (string) ($row->provider ?? ''), (string) ($row->model ?? ''), [], (int) ($row->api_key_id ?? 0) ); } else { // 提示词带中文名,避免模型漏填 doctor_order 等编码键 $fieldList = implode(',', array_map(static function (string $code) { $label = AiRuntimeConfigService::medicalRecordFieldLabel($code); return $code === $label ? $code : ($code . '(' . $label . ')'); }, $needAiFields)); $fieldKeysOnly = implode(',', $needAiFields); $system = '你是一名中医临床助手。必须结合患者真实年龄、性别与主诉、病史生成规范病历草稿,禁止忽略人口学信息按通用模板套写。' . $this->buildDemographicsPromptHint($ctx) . $this->buildMedicalRecordCautionPromptHint($needAiFields) . '必须只输出一个 JSON 对象,不要 markdown,不要解释。' . 'JSON 必须包含且仅包含以下全部键(键名用英文编码,值为字符串):' . $fieldKeysOnly . '。' . '字段含义:' . $fieldList . '。' . '硬性要求:上述每一个键都必须出现,且每一个值都必须是非空有临床意义的内容;' . '禁止省略任何键,禁止任何键填空字符串 "",禁止输出未列出的键。' . '不要输出 chief_complaint 字段(主诉已由医生提供)。' . '医生已填写的字段已作为参考,请与之保持一致、互补,不要无故矛盾;' . '若医生填写内容与主诉明显无关,仍以主诉为本次生成核心,病史字段仅作背景参考。' . $this->buildIcdDiagnosisPromptHint($needAiFields) . (in_array('doctor_order', $needAiFields, true) ? 'doctor_order(医嘱)必须填写具体医嘱建议(如饮食禁忌、复诊、用药注意事项等),且须对应主诉相关诊断,禁止留空。' : '') . (in_array('menstrual_history', $needAiFields, true) && (int) ($ctx['sex'] ?? 0) === 1 ? '患者为男性时 menstrual_history 填「无」。' : '') . $this->buildTcmDictPromptHint($needAiFields) . '语言简洁专业。'; $userLines = [ '【必读】患者人口学:' . ($ctx['age'] !== '' ? $ctx['age'] . '岁' : '未知') . ' / ' . $sexLabel . '(生成时必须体现该年龄段与性别特征,禁止按未知处理)', '年龄:' . ($ctx['age'] !== '' ? $ctx['age'] . '岁' : '未知'), '性别:' . $sexLabel, '主诉(本次生成核心,诊断与医嘱必须围绕它):' . $chief, ]; foreach ($context as $code => $text) { $userLines[] = AiRuntimeConfigService::medicalRecordFieldLabel($code) . ':' . $text; } $userLines[] = '请输出包含全部键(' . $fieldKeysOnly . ')的完整病历 JSON,每个键都必须有非空内容;' . '第一诊断/证候/治法/治疗意见/医嘱须解释本次主诉,勿把病史慢病写成唯一或首要结论。'; $user = implode("\n", $userLines); $agent = AiAgentFactory::getInstance()->make($provider); $messages = [ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $user], ]; // 温度略降:写病历更保守,减少跳跃诊断与矛盾治法 $parsedPack = $this->chatAndParseJsonObject($agent, $messages, ['temperature' => 0.25]); $chat = $parsedPack['chat']; $parsed = $this->normalizeMrAiParsedKeys($parsedPack['data']); // 勾选字段缺键或为空:全部纳入补全重试(不是只补核心字段) $needRetry = []; foreach ($needAiFields as $field) { if (!array_key_exists($field, $parsed)) { $needRetry[] = $field; continue; } $v = is_scalar($parsed[$field]) ? trim((string) $parsed[$field]) : ''; if ($v === '') { $needRetry[] = $field; } } if (!empty($needRetry)) { $retryMessages = array_merge($messages, [ ['role' => 'assistant', 'content' => $chat->content], [ 'role' => 'user', 'content' => '以下键缺失或内容为空:' . implode(',', $needRetry) . '。请重新只输出一个 JSON 对象,必须包含全部键(' . $fieldKeysOnly . '),且每个键都必须是非空临床内容(禁止 "");其它键可沿用上次合理内容。不要输出其它键。', ], ]); $chat2 = $agent->chatCompletions($retryMessages, ['temperature' => 0.3]); $parsed2 = $this->normalizeMrAiParsedKeys($this->tryParseJsonObject($chat2->content) ?? []); if ($parsed2 !== []) { $chat = $chat2; // 合并:补全空缺,保留已有非空 foreach ($needAiFields as $field) { $newVal = isset($parsed2[$field]) && is_scalar($parsed2[$field]) ? trim((string) $parsed2[$field]) : ''; $oldVal = isset($parsed[$field]) && is_scalar($parsed[$field]) ? trim((string) $parsed[$field]) : ''; $parsed[$field] = $newVal !== '' ? $newVal : $oldVal; } } } // 仅回填本次需 AI 生成的字段;医生已填字段保持原值 foreach ($needAiFields as $field) { $val = $parsed[$field] ?? ''; $fields[$field] = is_scalar($val) ? trim((string) $val) : ''; } // 仍有空字段:再针对空缺键补一轮,确保「勾选的必须全部有内容」 $stillEmpty = []; foreach ($needAiFields as $field) { if (trim((string) ($fields[$field] ?? '')) === '') { $stillEmpty[] = $field; } } if (!empty($stillEmpty)) { $emptyLabels = array_map(static function (string $code) { $label = AiRuntimeConfigService::medicalRecordFieldLabel($code); return $code === $label ? $code : ($code . '(' . $label . ')'); }, $stillEmpty); $fillMessages = array_merge($messages, [ ['role' => 'assistant', 'content' => $chat->content], [ 'role' => 'user', 'content' => '仍有字段为空:' . implode(',', $emptyLabels) . '。请只输出一个 JSON 对象,必须包含键 ' . implode(',', $stillEmpty) . ',且每个值都必须是非空有临床意义的内容,禁止空字符串。', ], ]); $chat3 = $agent->chatCompletions($fillMessages, [ 'temperature' => 0.2, 'max_tokens' => max(512, count($stillEmpty) * 256), ]); $parsed3 = $this->normalizeMrAiParsedKeys($this->tryParseJsonObject($chat3->content) ?? []); $filledAny = false; foreach ($stillEmpty as $field) { $v = isset($parsed3[$field]) && is_scalar($parsed3[$field]) ? trim((string) $parsed3[$field]) : ''; if ($v !== '') { $fields[$field] = $v; $filledAny = true; } } if ($filledAny) { $chat = $chat3; } } } // end needAiFields // 中医三典:按字典库校正名称,并给出开方用旧字段 id(监管上传取 code) $tcmResolved = $this->normalizeTcmFieldsAgainstDict($fields); $fields = $tcmResolved['fields']; // 临床诊断:按 ICD(医保版/国临版)约束后,再对齐本系统 yii_disease 标准病名 $diagnosisResolved = $this->normalizeDiagnosisAgainstDict($fields); $fields = $diagnosisResolved['fields']; // 列表展示名:优先临床诊断,否则 AI病历 $listName = trim((string) ($fields['diagnosis'] ?? '')); if ($listName === '') { $listName = 'AI病历'; } $fields['name'] = mb_substr($listName, 0, 128); // name 仅用于列表展示;tcm_ids / diagnosis_ids 一并落库,历史导入可回填 $resultFields = $fields; unset($resultFields['name']); $storePayload = $fields; $storePayload['tcm_ids'] = $tcmResolved['tcm_ids']; $storePayload['diagnosis_ids'] = $diagnosisResolved['diagnosis_ids']; $row = $this->finishGenerationSuccess($row, $chat, $storePayload); return [ 'ok' => true, 'generation_id' => (int) $row->id, 'fields' => $resultFields, // 与开方提交字段名对齐:diseases_id=证候,syndrome_id=疾病,method_id=治法 'tcm_ids' => $tcmResolved['tcm_ids'], 'diagnosis_ids' => $diagnosisResolved['diagnosis_ids'], 'provider' => $chat->provider, 'model' => $chat->model, 'duration_ms' => (int) $row->duration_ms, ]; } catch (\Throwable $e) { $row = $this->finishGenerationFail($row, $e->getMessage(), $chat); return [ 'ok' => false, 'generation_id' => (int) $row->id, 'duration_ms' => (int) $row->duration_ms, 'message' => 'AI生成未完成,请稍后重试', 'fields' => [], ]; } } /** * AI 给处方:确认主诉后按病历 + 处方类型生成药名,落库并返回对照预览 * 中药 / 西药使用独立专业提示词;中药额外走十八反十九畏规则检测 * 中药可委托调剂:processOptions.use_entrusted_process + process_rule_id 时要求 AI 选煎法 * AI/运行时异常软失败(ok=false),VIP/参数校验仍硬抛 * * @param int $registerId 挂号 * @param int $storeId 门店 * @param int $prescriptionType ProductTypeEnum * @param int $doctorId 医生 * @param string|null $provider 供应商覆盖 * @param string $chiefComplaint 前端确认过的主诉(可编辑) * @param array $medicalRecordOverride 前端病历表单快照(可未保存),有内容字段覆盖库值 * @param array $processOptions keys: use_entrusted_process (bool), process_rule_id (int) */ public function generatePrescription( int $registerId, int $storeId, int $prescriptionType, int $doctorId = 0, ?string $provider = null, string $chiefComplaint = '', array $medicalRecordOverride = [], array $processOptions = [] ): array { $this->assertVip($storeId, self::VIP_RX); if ($registerId <= 0) { $this->utils->errorThrow('挂号ID无效'); } $typeEnum = ProductTypeEnum::tryFrom($prescriptionType); if ($typeEnum === null || $prescriptionType <= 0) { $this->utils->errorThrow('处方类型无效'); } $ctx = $this->loadPatientContext($registerId); // 年龄/性别缺失时出方易出现剂量/禁忌错误,必须先完善就诊人 $this->assertPatientDemographicsForAi($ctx); $mr = MedicalRecordModel::where('register_id', $registerId)->where('deleted_at', 0)->first(); $mrArr = $mr ? $mr->toArray() : []; // 前端当前病历表单优先(可能尚未点保存),再与库内记录合并 $mrArr = $this->mergeMedicalRecordForAi($mrArr, $medicalRecordOverride); // 主诉优先用确认步可编辑值 $chief = trim($chiefComplaint); if ($chief === '') { $chief = trim((string) ($mrArr['chief_complaint'] ?? '')); } if ($chief === '') { $this->utils->errorThrow('请先填写主诉后再生成'); } $mrArr['chief_complaint'] = $chief; $typeLabel = $typeEnum->description(); $isTcm = $prescriptionType === ProductTypeEnum::CHINA_MEDICINE->value; $useEntrusted = $isTcm && !empty($processOptions['use_entrusted_process']); $processRuleId = $useEntrusted ? (int) ($processOptions['process_rule_id'] ?? 0) : 0; // 委托时:是否同时出推荐剂数/每日次数/二级详细制剂规则(前端本地记忆) $returnDosageProcessDetail = $useEntrusted && (!array_key_exists('return_dosage_process_detail', $processOptions) || !empty($processOptions['return_dosage_process_detail'])); // 勾选委托但未选制剂要求:硬校验,避免空列表误导 AI if ($useEntrusted && $processRuleId <= 0) { $this->utils->errorThrow('请选择制剂要求后再生成'); } // 委托调剂:加载子煎法 + 备注供 AI 选择;未勾选则明确禁止输出加工字段 $processCtx = $this->loadEntrustedProcessContext($useEntrusted, $processRuleId); $processCtx['return_detail'] = $returnDosageProcessDetail; $inputSnapshot = [ 'age' => $ctx['age'], // 快照存中文性别,避免下游/排障看到库内枚举 1/2 'sex' => (string) ($ctx['sex_label'] ?? $this->formatSexLabel($ctx['sex'] ?? 0)), 'patient_name' => $ctx['name'], 'prescription_type' => $prescriptionType, 'type_label' => $typeLabel, 'medical_record' => $this->pickMedicalRecordSnapshot($mrArr), 'use_entrusted_process' => $useEntrusted ? 1 : 0, 'process_rule_id' => $processRuleId, 'return_dosage_process_detail' => $returnDosageProcessDetail ? 1 : 0, ]; $row = $this->beginGeneration( $storeId, $registerId, $doctorId, self::SCENE_RX, $prescriptionType, $provider, $inputSnapshot ); $chat = null; try { // 中药先煎/后下等必须来自 yii_drug_use_way,与接诊开方词典一致,避免 AI 自造无法对照 $useWayNames = $isTcm ? $this->loadDrugUseWayNames() : []; $system = $this->buildPrescriptionSystemPrompt($prescriptionType, $typeLabel, $useWayNames, $processCtx); $user = $this->buildPrescriptionUserPrompt($ctx, $mrArr, $typeLabel, $isTcm, $useWayNames, $processCtx); $processHint = $isTcm ? $this->buildEntrustedProcessPromptHint($processCtx) : ''; if ($processHint !== '') { $system .= $processHint; $user .= "\n" . $processHint; } $agent = AiAgentFactory::getInstance()->make($provider); $messages = [ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $user], ]; // 中药:解析完整方对象;西药等:解析药品数组 $parsedPack = $isTcm ? $this->chatAndParseTcmPrescription($agent, $messages, ['temperature' => 0.3, 'max_tokens' => 4096], $useWayNames) : $this->chatAndParseWestPrescription($agent, $messages, ['temperature' => 0.3, 'max_tokens' => 2048]); $chat = $parsedPack['chat']; $items = $this->normalizePrescriptionItems($parsedPack['items'], $isTcm ? $useWayNames : []); $dosage = (int) ($parsedPack['dosage'] ?? 0); $dayDosage = (int) ($parsedPack['day_dosage'] ?? 0); $reason = trim((string) ($parsedPack['reason'] ?? '')); $basis = trim((string) ($parsedPack['basis'] ?? '')); $prescriptionName = trim((string) ($parsedPack['prescription_name'] ?? '')); // 中药剂数/每日次数必须有可用默认值,否则前端预览为「—」且导入剂数缺失 if ($isTcm && $dosage <= 0) { $dosage = 7; } if ($isTcm && $dayDosage <= 0) { $dayDosage = 2; } if (empty($items)) { // 已是合法 JSON,但没有有效药名——当作 AI 软失败 throw new \RuntimeException('AI 未生成有效药品'); } // 中药:服务端再跑一遍十八反/十九畏/有毒检测,给医生预览警示 $conflict = ['is_exist' => false, 'messages' => []]; if ($isTcm) { $conflict = $this->detectChineseMedicineConflict($items); } // 从原始响应解析委托调剂字段并映射到规则 ID $processFields = $this->resolveEntrustedProcessFromChat( $chat->content, $processCtx ); $resultJson = [ 'items' => $items, 'dosage' => $dosage, 'day_dosage' => $dayDosage, 'reason' => $reason, 'basis' => $basis, 'prescription_name' => $prescriptionName, 'conflict' => $conflict, ]; if (!empty($processFields)) { $resultJson = array_merge($resultJson, $processFields); } $row = $this->finishGenerationSuccess($row, $chat, $resultJson); $match = $this->matchPrescriptionDrugs($items, $storeId, $prescriptionType, $registerId); return array_merge([ 'ok' => true, 'generation_id' => (int) $row->id, 'items' => $items, 'dosage' => $dosage, 'day_dosage' => $dayDosage, 'reason' => $reason, 'basis' => $basis, 'prescription_name' => $prescriptionName, 'prescription_type' => $prescriptionType, 'prescription_type_label' => $typeLabel, 'match' => $match, 'conflict' => $conflict, 'provider' => $chat->provider, 'model' => $chat->model, 'created_at' => $this->rawCreatedAtTimestamp($row), 'duration_ms' => (int) $row->duration_ms, ], $processFields); } catch (\Throwable $e) { $row = $this->finishGenerationFail($row, $e->getMessage(), $chat); return [ 'ok' => false, 'generation_id' => (int) $row->id, 'duration_ms' => (int) $row->duration_ms, 'message' => 'AI生成未完成,请稍后重试', 'items' => [], 'dosage' => 0, 'day_dosage' => 0, 'reason' => '', 'basis' => '', 'prescription_name' => '', 'prescription_type' => $prescriptionType, 'prescription_type_label' => $typeLabel, 'match' => ['matched' => [], 'unmatched' => []], 'conflict' => ['is_exist' => false, 'messages' => []], ]; } } /** * 本挂号 AI 生成历史列表(轻量:不含 result_json / 原始响应,详情点选后再取) * 接诊端只展示成功记录(status=1),失败不进历史抽屉 * * @param int $registerId 挂号 * @param string $scene medical_record|prescription|空=全部 * @param int $limit 条数 */ public function listGenerations(int $registerId, string $scene = '', int $limit = 30): array { if ($registerId <= 0) { $this->utils->errorThrow('挂号ID无效'); } $limit = max(1, min(100, $limit)); $q = AiGenerationModel::query() ->select([ 'id', 'scene', 'prescription_type', 'name', 'provider', 'model', 'status', 'duration_ms', 'created_at', // 仅取味数,避免把整份 result_json 拉回 PHP DB::raw( 'IFNULL(JSON_LENGTH(JSON_EXTRACT(result_json, \'$.items\')), 0) as item_count' ), ]) ->where('register_id', $registerId) ->where('deleted_at', 0) // 接诊历史只看成功,失败记录留给后台排障 ->where('status', 1) ->orderByDesc('id') ->limit($limit); if ($scene !== '') { $q->where('scene', $scene); } return $q->get()->map(function ($row) { $type = (int) ($row->prescription_type ?? 0); $typeEnum = ProductTypeEnum::tryFrom($type); $name = trim((string) ($row->name ?? '')); return [ 'id' => (int) $row->id, 'scene' => (string) ($row->scene ?? ''), 'prescription_type' => $type, 'prescription_type_label' => $typeEnum ? $typeEnum->description() : ($type > 0 ? '类型' . $type : ''), 'name' => $name, 'prescription_name' => $name, 'provider' => (string) ($row->provider ?? ''), 'model' => (string) ($row->model ?? ''), 'status' => (int) ($row->status ?? 0), 'duration_ms' => (int) ($row->duration_ms ?? 0), 'item_count' => (int) ($row->item_count ?? 0), // 必须读 raw:访问器已格式化成日期串,(int) 会截成年份 'created_at' => $this->rawCreatedAtTimestamp($row), ]; })->values()->all(); } /** * AI 生成详情(点选历史时拉取完整结果,不含 raw_response) */ public function getGenerationDetail(int $generationId): array { if ($generationId <= 0) { $this->utils->errorThrow('记录ID无效'); } $row = AiGenerationModel::where('id', $generationId)->where('deleted_at', 0)->first(); if (!$row) { $this->utils->errorThrow('AI 生成记录不存在'); } $result = is_array($row->result_json) ? $row->result_json : []; $type = (int) $row->prescription_type; $typeEnum = ProductTypeEnum::tryFrom($type); $name = trim((string) ($row->name ?? '')); if ($name === '') { $name = trim((string) ($result['prescription_name'] ?? $result['name'] ?? '')); } $items = is_array($result['items'] ?? null) ? $result['items'] : []; // 病历场景:按结果里实际有的白名单字段回传(兼容历史全量记录) $fields = []; $tcmIds = null; if ((string) $row->scene === self::SCENE_MR) { $allow = array_flip(self::MR_OUTPUT_FIELDS); foreach ($result as $field => $val) { if (!is_string($field) || !isset($allow[$field])) { continue; } $fields[$field] = is_scalar($val) ? trim((string) $val) : ''; } // 优先读落库的 tcm_ids;老记录没有则按名称再匹配一次,保证历史导入也能带三项 id if (isset($result['tcm_ids']) && is_array($result['tcm_ids'])) { $tcmIds = [ 'diseases_id' => isset($result['tcm_ids']['diseases_id']) ? (int) $result['tcm_ids']['diseases_id'] ?: null : null, 'method_id' => isset($result['tcm_ids']['method_id']) ? (int) $result['tcm_ids']['method_id'] ?: null : null, 'syndrome_id' => isset($result['tcm_ids']['syndrome_id']) ? (int) $result['tcm_ids']['syndrome_id'] ?: null : null, ]; } else { $tcmIds = $this->normalizeTcmFieldsAgainstDict($fields)['tcm_ids']; } } return [ 'id' => (int) $row->id, 'scene' => (string) $row->scene, 'prescription_type' => $type, 'prescription_type_label' => $typeEnum ? $typeEnum->description() : '', 'name' => $name, 'prescription_name' => $name, 'provider' => (string) $row->provider, 'model' => (string) $row->model, 'status' => (int) ($row->status ?? 0), 'duration_ms' => (int) ($row->duration_ms ?? 0), 'item_count' => count($items), 'items' => $items, 'fields' => $fields, 'tcm_ids' => $tcmIds, 'dosage' => (int) ($result['dosage'] ?? 0), 'day_dosage' => (int) ($result['day_dosage'] ?? 0), 'reason' => trim((string) ($result['reason'] ?? '')), 'basis' => trim((string) ($result['basis'] ?? '')), 'conflict' => is_array($result['conflict'] ?? null) ? $result['conflict'] : ['is_exist' => false, 'messages' => []], // 委托调剂字段:导入中药配置时用 'process_rule_id' => (int) ($result['process_rule_id'] ?? 0), 'process_rule_name' => trim((string) ($result['process_rule_name'] ?? '')), 'child_process_rule_id' => (int) ($result['child_process_rule_id'] ?? 0), 'child_process_rule_name' => trim((string) ($result['child_process_rule_name'] ?? '')), 'process_rule_note_id' => (int) ($result['process_rule_note_id'] ?? 0), 'process_rule_note' => trim((string) ($result['process_rule_note'] ?? '')), 'input_snapshot' => is_array($row->input_snapshot) ? $row->input_snapshot : [], 'created_at' => $this->rawCreatedAtTimestamp($row), ]; } /** * 对某次生成或临时 items 做药名模糊对照 * * @param int $storeId 门店 * @param int $prescriptionType 类型 * @param int $registerId 挂号(取价) * @param int $generationId 历史 ID(优先) * @param array $items 临时 items(无 generationId 时) */ public function matchPrescriptionDrugsByRequest( int $storeId, int $prescriptionType, int $registerId = 0, int $generationId = 0, array $items = [] ): array { $this->assertVip($storeId, self::VIP_RX); $savedConflict = null; $dosage = 0; $dayDosage = 0; $reason = ''; $basis = ''; $prescriptionName = ''; $processFields = []; $durationMs = 0; if ($generationId > 0) { $row = AiGenerationModel::where('id', $generationId) ->where('deleted_at', 0) ->first(); if (!$row || $row->scene !== self::SCENE_RX) { $this->utils->errorThrow('AI 出方记录不存在'); } $result = is_array($row->result_json) ? $row->result_json : []; $items = is_array($result['items'] ?? null) ? $result['items'] : []; $dosage = (int) ($result['dosage'] ?? 0); $dayDosage = (int) ($result['day_dosage'] ?? 0); $reason = trim((string) ($result['reason'] ?? '')); $basis = trim((string) ($result['basis'] ?? '')); $prescriptionName = trim((string) ($row->name ?? '')); if ($prescriptionName === '') { $prescriptionName = trim((string) ($result['prescription_name'] ?? $result['name'] ?? '')); } if (isset($result['conflict']) && is_array($result['conflict'])) { $savedConflict = $result['conflict']; } $durationMs = (int) ($row->duration_ms ?? 0); foreach ([ 'process_rule_id', 'process_rule_name', 'child_process_rule_id', 'child_process_rule_name', 'process_rule_note_id', 'process_rule_note', ] as $pk) { if (array_key_exists($pk, $result)) { $processFields[$pk] = $result[$pk]; } } if ($prescriptionType <= 0) { $prescriptionType = (int) $row->prescription_type; } if ($registerId <= 0) { $registerId = (int) $row->register_id; } } // 历史对照时中药 usage 也按词典规范化,保证先煎后下能映射 way_id $useWayNames = $prescriptionType === ProductTypeEnum::CHINA_MEDICINE->value ? $this->loadDrugUseWayNames() : []; $items = $this->normalizePrescriptionItems($items, $useWayNames); if (empty($items)) { $this->utils->errorThrow('没有可对照的药品'); } if ($prescriptionType <= 0) { $this->utils->errorThrow('处方类型无效'); } $match = $this->matchPrescriptionDrugs($items, $storeId, $prescriptionType, $registerId); // 历史记录优先带出当时落库的相冲结果;老数据则现场重检 $conflict = ['is_exist' => false, 'messages' => []]; if (is_array($savedConflict)) { $conflict = [ 'is_exist' => !empty($savedConflict['is_exist']), 'messages' => array_values(array_filter(array_map( static fn ($x) => trim((string) $x), is_array($savedConflict['messages'] ?? null) ? $savedConflict['messages'] : [] ))), ]; } elseif ( $generationId > 0 && $prescriptionType === ProductTypeEnum::CHINA_MEDICINE->value ) { $conflict = $this->detectChineseMedicineConflict($items); } $typeEnum = ProductTypeEnum::tryFrom($prescriptionType); return array_merge($match, [ 'conflict' => $conflict, 'dosage' => $dosage, 'day_dosage' => $dayDosage, 'reason' => $reason, 'basis' => $basis, 'prescription_name' => $prescriptionName, 'prescription_type' => $prescriptionType, 'prescription_type_label' => $typeEnum ? $typeEnum->description() : '', 'duration_ms' => $durationMs, ], $processFields); } /** * 药名模糊对照:复用接诊 productList * * @return array{matched: array, unmatched: array} */ public function matchPrescriptionDrugs(array $items, int $storeId, int $prescriptionType, int $registerId = 0): array { $matched = []; $unmatched = []; $recv = DoctorReceptionService::getInstance(); // 西药检索含中成药 type=4,与开方页一致 $types = [$prescriptionType]; if ($prescriptionType === ProductTypeEnum::WESTERN_MEDICINE->value) { $types = [2, 4]; } foreach ($items as $itemIndex => $item) { $name = trim((string) ($item['name'] ?? '')); if ($name === '') { continue; } $list = $recv->productList($storeId, $prescriptionType, $name, $registerId, $types); $candidates = []; foreach ($list as $row) { $arr = is_object($row) ? (array) $row : (array) $row; // Collection 可能是 stdClass / Model if (is_object($row) && method_exists($row, 'toArray')) { $arr = $row->toArray(); } elseif (is_object($row)) { $arr = json_decode(json_encode($row), true) ?: []; } $drugId = (int) ($arr['drug_id'] ?? $arr['id'] ?? 0); if ($drugId <= 0) { continue; } $drug = $arr['drug'] ?? []; if (is_object($drug)) { $drug = json_decode(json_encode($drug), true) ?: []; } $candidates[] = [ 'id' => (int) ($arr['id'] ?? $drugId), 'drug_id' => $drugId, 'drug_name' => (string) ($arr['drug_name'] ?? $drug['drug_name'] ?? ''), 'price' => (float) ($arr['price'] ?? 0), 'specification' => (string) ($arr['specification'] ?? $drug['specification'] ?? ''), 'image' => (string) ($arr['image'] ?? $drug['image'] ?? ''), 'unit' => $arr['unit'] ?? ($drug['unit'] ?? null), 'time_id' => (int) ($drug['time_id'] ?? 0), 'type_id' => (int) ($drug['type_id'] ?? 0), 'frequency_id' => (int) ($drug['frequency_id'] ?? 0), 'unit_id' => (int) ($drug['unit_id'] ?? 0), 'number' => $drug['number'] ?? 1, 'drug' => $drug, ]; // 单味药候选上限,避免一次返回过大 if (count($candidates) >= 8) { break; } } $payload = [ 'ai_name' => $name, 'dose' => (string) ($item['dose'] ?? ''), 'unit' => (string) ($item['unit'] ?? ''), 'usage' => (string) ($item['usage'] ?? ''), 'frequency' => (string) ($item['frequency'] ?? ''), // 回传落库改选标记,前端展示「已改」;item_index 对应 result_json.items 下标 'is_modified' => !empty($item['is_modified']) ? 1 : 0, 'item_index' => (int) $itemIndex, ]; if (empty($candidates)) { $unmatched[] = $payload; continue; } $payload['candidates'] = $candidates; // 优先还原医生已保存的 selected_drug_id;不在候选里则回退第一项 $savedId = (int) ($item['selected_drug_id'] ?? 0); $pickId = (int) $candidates[0]['drug_id']; if ($savedId > 0) { foreach ($candidates as $c) { if ((int) ($c['drug_id'] ?? 0) === $savedId) { $pickId = $savedId; break; } } } $payload['selected_drug_id'] = $pickId; if ($savedId > 0 && $pickId === $savedId) { $payload['is_modified'] = !empty($item['is_modified']) ? 1 : 0; } $matched[] = $payload; } return [ 'matched' => $matched, 'unmatched' => $unmatched, ]; } /** * 挂号患者上下文:年龄、性别、姓名 */ private function loadPatientContext(int $registerId): array { $reg = RegisterOrderModel::with(['userPatient:id,name,age,sex']) ->where('id', $registerId) ->first(['id', 'user_patient_id', 'store_id']); if (!$reg) { $this->utils->errorThrow('挂号不存在'); } $p = $reg->userPatient; $sex = $p ? (int) ($p->sex ?? 0) : 0; return [ 'age' => $p ? (string) ($p->age ?? '') : '', 'sex' => $sex, // 预计算中文,拼 prompt / 快照时直接用,避免漏映射 'sex_label' => $this->formatSexLabel($sex), 'name' => $p ? (string) ($p->name ?? '') : '', 'user_patient_id' => (int) ($reg->user_patient_id ?? 0), 'store_id' => (int) ($reg->store_id ?? 0), ]; } /** * AI 生成前校验年龄/性别:缺失时模型会按「未知成人」套写,易与预期不符 * * @param array{age?:string,sex?:int} $ctx */ private function assertPatientDemographicsForAi(array $ctx): void { $age = (int) ($ctx['age'] ?? 0); $sex = (int) ($ctx['sex'] ?? 0); if ($age <= 0) { $this->utils->errorThrow('患者年龄缺失,请先完善就诊人信息后再生成'); } if ($sex !== 1 && $sex !== 2) { $this->utils->errorThrow('患者性别缺失,请先完善就诊人信息后再生成'); } } /** * 按年龄/性别拼人口学硬性约束,避免小儿/老年/男女辨证用药串台 * * @param array{age?:string,sex?:int,sex_label?:string} $ctx */ private function buildDemographicsPromptHint(array $ctx): string { $age = (int) ($ctx['age'] ?? 0); $sex = (int) ($ctx['sex'] ?? 0); $sexLabel = (string) ($ctx['sex_label'] ?? $this->formatSexLabel($sex)); $parts = [ '【人口学硬性约束】必须结合患者年龄(' . ($age > 0 ? $age . '岁' : '未知') . ')与性别(' . $sexLabel . ')生成内容,禁止按「未知」或通用成人模板敷衍。', ]; if ($sex === 1) { $parts[] = '患者为男性:月经史相关内容填「无」;勿按妇科思路辨证用药。'; } elseif ($sex === 2) { $parts[] = '患者为女性:辨证与用药须考虑月经/孕产可能;育龄期慎用妊娠禁忌药(除非病历已排除)。'; } if ($age > 0 && $age < 14) { $parts[] = '患者为小儿:药味、剂量、禁忌须按儿科规范,避免成人剂量与峻烈药。'; } elseif ($age >= 65) { $parts[] = '患者为老年:注意体虚、肝肾功能与减量、配伍安全性。'; } return implode('', $parts); } /** * 就诊人性别枚举转中文(yii_user_patient.sex:0默认 1男 2女) * AI prompt / 生成快照必须用中文,禁止把 1/2 原样塞给模型 */ private function formatSexLabel(mixed $sex): string { if (is_string($sex)) { $trim = trim($sex); if ($trim === '男' || $trim === '女' || $trim === '未知') { return $trim; } } $n = (int) $sex; return match ($n) { 1 => '男', 2 => '女', default => '未知', }; } /** * 按处方类型组装 system prompt(中药 / 西药 / 其他分开,避免混用) * 中药委托调剂时按一级制剂要求(汤剂/丸剂/颗粒等)约束,不写死汤剂 * * @param list $useWayNames 中药煎法词典(yii_drug_use_way.name) * @param array{enabled?:bool,process_rule_id?:int,process_rule_name?:string} $processCtx */ private function buildPrescriptionSystemPrompt( int $prescriptionType, string $typeLabel, array $useWayNames = [], array $processCtx = [] ): string { $usageHint = !empty($useWayNames) ? ('只能从词典中选填:' . implode('、', $useWayNames) . ';普通煎服填空字符串') : '仅写煎煮特殊要求;普通煎服填空字符串'; $formName = trim((string) ($processCtx['process_rule_name'] ?? '')); $entrusted = !empty($processCtx['enabled']) && $formName !== ''; // 中药:完整方对象;含药方名 + reason/basis $sampleTcm = '{' . '"prescription_name":"金匮肾气丸加减",' . '"reason":"肾阴亏虚夹寒,宜温补肾阳、滋阴填精,用金匮肾气丸化裁",' . '"basis":"主诉腰膝酸软畏寒;辨证肾阳虚;参考《金匮要略》肾气丸与《本草纲目》附子干姜温阳配伍",' . '"dosage":7,' . '"day_dosage":2,' . '"drug":[' . '{"name":"熟地黄","dose":"15","unit":"g","usage":""},' . '{"name":"山茱萸","dose":"12","unit":"g","usage":""},' . '{"name":"山药","dose":"15","unit":"g","usage":""},' . '{"name":"泽泻","dose":"10","unit":"g","usage":""},' . '{"name":"茯苓","dose":"15","unit":"g","usage":""},' . '{"name":"牡丹皮","dose":"10","unit":"g","usage":""},' . '{"name":"附子","dose":"9","unit":"g","usage":"先煎"},' . '{"name":"干姜","dose":"6","unit":"g","usage":""},' . '{"name":"炙甘草","dose":"6","unit":"g","usage":""},' . '{"name":"桂枝","dose":"9","unit":"g","usage":""},' . '{"name":"白芍","dose":"12","unit":"g","usage":""},' . '{"name":"薄荷","dose":"6","unit":"g","usage":"后下"}' . ']}'; $sampleWest = '{' . '"prescription_name":"上感对症方案",' . '"reason":"上呼吸道感染伴发热疼痛,予抗感染与解热镇痛对症",' . '"basis":"主诉发热咽痛;无青霉素过敏史;参考常见社区获得性感染经验用药",' . '"drug":[' . '{"name":"阿莫西林胶囊","dose":"0.5","unit":"g","usage":"口服","frequency":"一日三次"},' . '{"name":"布洛芬缓释胶囊","dose":"0.3","unit":"g","usage":"口服","frequency":"一日两次"}' . ']}'; $jsonRuleTcm = '必须只输出一个 JSON 对象,不要 markdown,不要解释。' . '字段:prescription_name(药方名字,如经典方名或「某某方加减」,字符串)、' . 'reason(为什么出这个方,辨证立法与选方思路,字符串)、' . 'basis(根据哪些,列出依据的病历要点/证候/典籍或指南,字符串)、' . 'dosage(开多少剂,顶层整数,常用7,禁止写成对象)、' . 'day_dosage(每天几次,顶层整数,常用2,禁止嵌套进 dosage,禁止写「每日2-3次」文案)、' . 'drug(药品数组,必须在顶层且不能省略,禁止写进 dosage;必须完整君臣佐使,一般10~18味,不得只给2~5味)。' . 'drug 元素字段:name(药名)、dose(克数数字,不要带单位)、unit(固定g)、' . 'usage(' . $usageHint . ';禁止写每日1剂)。' . '禁止在药品上输出 frequency 字段。prescription_name、reason、basis、dosage、day_dosage、drug 必须全部是顶层字段。' . 'reason 与 basis 各控制在80字以内,优先保证 drug 数组完整输出。' . '正确样例:' . $sampleTcm . '。错误示例(禁止):漏掉 drug、dosage 写成对象并把 drug 嵌进去、day_dosage 写成「每日2次」、只输出药品数组 []、单个药对象、用药名当 key、药品里写 frequency。'; $jsonRuleWest = '必须只输出一个 JSON 对象,不要 markdown,不要解释。' . '字段:prescription_name(方案/处方简称)、reason(为什么开这些药)、basis(根据哪些:症状/诊断/禁忌与指南要点)、' . 'drug(药品数组)。' . 'drug 元素字段:name, dose, unit, usage(给药途径), frequency(频次)。' . '正确样例:' . $sampleWest . '。错误示例(禁止):最外层直接是数组、用药名当 key。'; $jsonRuleOther = '必须只输出一个 JSON 对象,含 prescription_name、reason、basis、drug 数组。' . '正确样例:{"prescription_name":"...","reason":"...","basis":"...","drug":[{"name":"商品名","dose":"1","unit":"盒","usage":"","frequency":""}]}。'; if ($prescriptionType === ProductTypeEnum::CHINA_MEDICINE->value) { $formLine = $entrusted ? ('本方委托调剂一级制剂要求为「' . $formName . '」(汤剂/丸剂/颗粒/膏方等一级均可能),' . '组方、药味多少与剂量须符合「' . $formName . '」的临床常用习惯,不要擅自改成其他剂型。') : '开出一副完整中药饮片方(君臣佐使齐全);未指定委托制剂时按临床常用饮片配伍出方,不限于汤剂一种剂型表述。'; return '你是资深中医师与方剂学专家,擅长辨证论治与经典方化裁。' . '必须综合患者年龄、性别与完整病历(主诉、现病史、四诊、中医病案/证候/治法、诊断、过敏史、既往史、体征与辅助检查等)辨证论治,' . '不得仅凭主诉开方,也不得忽略年龄性别按成人通用方套写;有内容的字段都要纳入依据,basis 中写明用到了哪些病历要点与人口学要点。' . '组方时须参考《伤寒论》《金匮要略》《本草纲目》《温病条辨》《神农本草经》等典籍的方义、性味归经与配伍原则,' . $formLine . '必须严格自检并规避「十八反」「十九畏」配伍禁忌,以及孕妇、小儿、老年体弱等特殊人群禁忌药;' . '若某药与方中他药相反相畏,须剔除或改用安全替代药,不得输出禁忌药对。' . '处方类型固定为中药饮片(' . $typeLabel . '):drug 只能是中药饮片药名,' . '禁止输出针灸、针刺、艾灸、穴位、推拿、理疗、外治手法等非饮片内容,也禁止把「针灸疗法」等当作药名或方名。' . $jsonRuleTcm; } if ($prescriptionType === ProductTypeEnum::WESTERN_MEDICINE->value) { return '你是临床药学与全科处方助手,擅长西药与中成药合理用药。' . '必须综合患者年龄、性别与完整病历(主诉、现病史、诊断、过敏史、既往史、体征检查、辅助检查、生命体征等)给出用药建议,' . '不得仅凭主诉开药,也不得忽略年龄性别;basis 中写明依据的病历要点与人口学要点。' . '依据适应证、禁忌证、常用剂量、肝肾功能与药物相互作用给出建议,语言专业、剂量规范。' . '须按年龄体重调整剂量,结合性别相关禁忌(如育龄女性慎用致畸药),并规避过敏史与明显相互作用、重复用药。' . '处方类型为西药/中成药(' . $typeLabel . ')。不要输出中药饮片生药名。' . $jsonRuleWest; } return '你是医疗机构处方助手。根据患者年龄、性别、完整病历与处方类型「' . $typeLabel . '」给出可开具的商品/药品建议。' . '必须参考主诉以外的现病史、诊断、过敏史等已填字段,不得仅凭主诉,也不得忽略年龄性别。' . '剂量与单位须符合该品类常见开方习惯及该年龄段剂量习惯。' . $jsonRuleOther; } /** * 组装处方 user prompt:输出完整病历(有内容的字段),要求综合参考而非只看主诉 * * @param list $useWayNames 中药煎法词典 * @param array{enabled?:bool,process_rule_name?:string} $processCtx */ private function buildPrescriptionUserPrompt( array $ctx, array $mr, string $typeLabel, bool $isTcm, array $useWayNames = [], array $processCtx = [] ): string { $sexLabel = (string) ($ctx['sex_label'] ?? $this->formatSexLabel($ctx['sex'] ?? 0)); $ageText = $ctx['age'] !== '' ? $ctx['age'] . '岁' : '未知'; $lines = [ '请根据下列「完整病历」与患者年龄/性别综合出方(不得只看主诉,不得忽略人口学信息)。', '处方类型:' . $typeLabel, '患者姓名:' . (trim((string) ($ctx['name'] ?? '')) !== '' ? $ctx['name'] : '未知'), '【必读】患者人口学:' . $ageText . ' / ' . $sexLabel . '(剂量、禁忌、辨证须与之匹配)', '年龄:' . $ageText, '性别:' . $sexLabel, $this->buildDemographicsPromptHint($ctx), ]; $formName = trim((string) ($processCtx['process_rule_name'] ?? '')); if ($isTcm && !empty($processCtx['enabled']) && $formName !== '') { $lines[] = '委托调剂一级制剂要求:' . $formName . '(须按此剂型出方,勿改成其他一级制剂)'; } foreach ($this->formatMedicalRecordPromptLines($mr) as $line) { $lines[] = $line; } if ($isTcm) { $usageRule = !empty($useWayNames) ? ('药品 usage 只能从词典选填:' . implode('、', $useWayNames) . ';无需特殊煎法填空') : '药品 usage 只填先煎/后下等;无需特殊煎法填空'; $formReq = ($formName !== '' && !empty($processCtx['enabled'])) ? ('按委托一级制剂「' . $formName . '」开出完整中药饮片方(10~18味,君臣佐使齐全)') : '开出一副完整中药饮片方(10~18味,君臣佐使齐全;剂型不限于汤剂)'; $lines[] = '要求:' . $formReq . ',填写 prescription_name(药方名字),' . '并给出剂数 dosage(常用7)、每日次数 day_dosage(常用2);' . '必须单独填写 reason(为什么出这个方,须点明年龄/性别相关考量,≤80字)' . '与 basis(根据哪些病历要点、人口学要点与典籍,≤80字);' . 'drug 数组不能省略,且只能是中药饮片;禁止针灸/推拿/理疗等非饮片方案;' . $usageRule . ';输出前再次核查十八反、十九畏及特殊人群禁忌。'; } else { $lines[] = '要求:给出临床常用用药清单,填写 prescription_name,并单独填写 reason、basis' . '(写明依据的病历字段与年龄/性别相关剂量或禁忌);注意相互作用与禁忌。'; } return implode("\n", $lines); } /** * 病历字段中文标签(出方 prompt / 快照共用) * * @return array */ private function medicalRecordFieldLabels(): array { return [ 'chief_complaint' => '主诉', 'present_illness' => '现病史', 'tongue' => '舌象', 'pulse' => '脉象', 'tcm_case' => '中医病案', 'tcm_disease' => '中医疾病', 'tcm_syndrome' => '中医证候', 'tcm_method' => '中医治法', 'diagnosis' => '临床诊断', 'treatment_advice' => '治疗意见', 'doctor_order' => '医嘱', 'allergy_history' => '过敏史', 'past_history' => '既往史', 'family_history' => '家族史', 'epidemic_history' => '流行病学史', 'personal_history' => '个人史', 'menstrual_history' => '月经史', 'marital_history' => '婚育史', 'physical_exam' => '体征检查', 'auxiliary_exam' => '辅助检查', ]; } /** * 将病历格式化为 prompt 行;空字段跳过,生命体征单独拼一行 * * @return list */ private function formatMedicalRecordPromptLines(array $mr): array { $lines = []; foreach ($this->medicalRecordFieldLabels() as $key => $label) { $val = trim((string) ($mr[$key] ?? '')); if ($val === '') { continue; } $lines[] = $label . ':' . $val; } $vitals = []; $temp = (float) ($mr['temperature'] ?? 0); if ($temp > 0) { $vitals[] = '体温' . $temp . '℃'; } $height = (float) ($mr['height'] ?? 0); if ($height > 0) { $vitals[] = '身高' . $height . 'cm'; } $weight = (float) ($mr['weight'] ?? 0); if ($weight > 0) { $vitals[] = '体重' . $weight . 'kg'; } $rr = (int) ($mr['respiratory_rate'] ?? 0); if ($rr > 0) { $vitals[] = '呼吸' . $rr . '次/分'; } $sys = (int) ($mr['bp_systolic'] ?? 0); $dia = (int) ($mr['bp_diastolic'] ?? 0); if ($sys > 0 || $dia > 0) { $vitals[] = '血压' . ($sys > 0 ? (string) $sys : '—') . '/' . ($dia > 0 ? (string) $dia : '—') . 'mmHg'; } if (!empty($vitals)) { $lines[] = '生命体征:' . implode(',', $vitals); } return $lines; } /** * 合并库内病历与前端未保存表单:前端有内容的字段覆盖库值 */ private function mergeMedicalRecordForAi(array $dbMr, array $override): array { if (empty($override)) { return $dbMr; } $keys = array_merge( array_keys($this->medicalRecordFieldLabels()), ['temperature', 'height', 'weight', 'respiratory_rate', 'bp_systolic', 'bp_diastolic'] ); foreach ($keys as $key) { if (!array_key_exists($key, $override)) { continue; } $raw = $override[$key]; if (is_array($raw) || is_object($raw)) { continue; } if (is_string($raw)) { $trimmed = trim($raw); if ($trimmed !== '') { $dbMr[$key] = $trimmed; } continue; } if (is_numeric($raw) && (float) $raw != 0.0) { $dbMr[$key] = $raw; } } return $dbMr; } /** * 生成快照:只保留有内容的病历字段,便于追溯出方依据 */ private function pickMedicalRecordSnapshot(array $mr): array { $out = []; foreach (array_keys($this->medicalRecordFieldLabels()) as $key) { $val = trim((string) ($mr[$key] ?? '')); if ($val !== '') { $out[$key] = $val; } } foreach (['temperature', 'height', 'weight', 'respiratory_rate', 'bp_systolic', 'bp_diastolic'] as $key) { $n = (float) ($mr[$key] ?? 0); if ($n != 0.0) { $out[$key] = $mr[$key]; } } return $out; } /** * 中药十八反/十九畏/有毒检测(复用开方相冲规则) * * @return array{is_exist: bool, messages: string[]} */ private function detectChineseMedicineConflict(array $items): array { $names = []; foreach ($items as $item) { $n = trim((string) ($item['name'] ?? '')); if ($n !== '') { $names[] = $n; } } if (empty($names)) { return ['is_exist' => false, 'messages' => []]; } $raw = PrescriptionService::getInstance()->checkChineseMedicineConflict($names); $messages = []; $msgBag = $raw['message'] ?? null; if (is_array($msgBag)) { foreach ($msgBag as $group) { if (!is_array($group)) { continue; } foreach ($group as $line) { $line = trim((string) $line); if ($line !== '') { $messages[] = $line; } } } } elseif (is_string($msgBag) && trim($msgBag) !== '') { $messages[] = trim($msgBag); } return [ 'is_exist' => !empty($raw['is_exist']), 'messages' => $messages, ]; } /** * 中药:解析完整方对象;失败则按结构问题定向重试(最多补 2 次) * 若仅缺 drug,第三次只补药品数组并与上次元数据合并 * * @param list $useWayNames 煎法词典,写入纠正提示 * @return array{chat: AiChatResult, items: array, dosage: int, day_dosage: int, reason: string, basis: string, prescription_name: string} */ private function chatAndParseTcmPrescription($agent, array $messages, array $options = [], array $useWayNames = []): array { $sample = '{' . '"prescription_name":"金匮肾气丸加减",' . '"reason":"辨证立法说明",' . '"basis":"依据病历要点与典籍",' . '"dosage":7,"day_dosage":2,"drug":[' . '{"name":"熟地黄","dose":"15","unit":"g","usage":""},' . '{"name":"山茱萸","dose":"12","unit":"g","usage":""},' . '{"name":"茯苓","dose":"15","unit":"g","usage":""},' . '{"name":"附子","dose":"9","unit":"g","usage":"先煎"},' . '{"name":"薄荷","dose":"6","unit":"g","usage":"后下"},' . '{"name":"炙甘草","dose":"6","unit":"g","usage":""},' . '{"name":"桂枝","dose":"9","unit":"g","usage":""},' . '{"name":"白芍","dose":"12","unit":"g","usage":""},' . '{"name":"干姜","dose":"6","unit":"g","usage":""},' . '{"name":"泽泻","dose":"10","unit":"g","usage":""}' . ']}'; $usageHint = !empty($useWayNames) ? ('usage 只能从词典选:' . implode('、', $useWayNames) . ';普通煎服填空。') : 'usage 填先煎/后下等,普通煎服填空。'; $chat = $agent->chatCompletions($messages, $options); $parsed = $this->tryParseTcmPrescriptionPayload($chat->content); if ($parsed !== null) { return array_merge(['chat' => $chat], $parsed); } // 第2次:按第1次结构问题定向纠正 $hint1 = $this->diagnoseTcmPrescriptionStructure($chat->content); $retryUser = $this->buildTcmFormatRetryPrompt($hint1, $sample, $usageHint); $retryMessages = array_merge($messages, [ ['role' => 'assistant', 'content' => $chat->content], ['role' => 'user', 'content' => $retryUser], ]); $chat2 = $agent->chatCompletions($retryMessages, $options); $parsed2 = $this->tryParseTcmPrescriptionPayload($chat2->content); if ($parsed2 !== null) { return array_merge(['chat' => $chat2], $parsed2); } // 第3次:若已有方名/理由等但缺 drug,只补药品数组再合并 $meta = $this->extractTcmMetaWithoutDrug($chat2->content) ?: $this->extractTcmMetaWithoutDrug($chat->content); $hint2 = $this->diagnoseTcmPrescriptionStructure($chat2->content); if ($meta !== null && $this->isMissingDrugOnly($chat2->content)) { $drugOnlyPrompt = '你上次几乎正确,但漏了最关键的顶层 drug 药品数组。' . '现在只输出一个 JSON 数组(不要对象、不要 markdown),' . '约10~18味中药,元素字段 name/dose/unit/usage;' . $usageHint . '样例:[{"name":"桂枝","dose":"9","unit":"g","usage":"先煎"},{"name":"白芍","dose":"15","unit":"g","usage":""}]'; $retry3 = array_merge($retryMessages, [ ['role' => 'assistant', 'content' => $chat2->content], ['role' => 'user', 'content' => $drugOnlyPrompt], ]); $chat3 = $agent->chatCompletions($retry3, $options); $drugs = $this->tryParseJsonArray($chat3->content); if ($drugs === null) { // 也兼容又包了一层对象 $pack = $this->tryParseTcmPrescriptionPayload($chat3->content); if ($pack !== null) { return array_merge(['chat' => $chat3], $pack); } } else { $normalized = $this->tryNormalizeDrugList($drugs); if ($normalized !== null && !empty($normalized)) { return [ 'chat' => $chat3, 'items' => $normalized, 'dosage' => $meta['dosage'] > 0 ? $meta['dosage'] : 7, 'day_dosage' => $meta['day_dosage'] > 0 ? $meta['day_dosage'] : 2, 'reason' => $meta['reason'], 'basis' => $meta['basis'], 'prescription_name' => $meta['prescription_name'], ]; } } $raw = "【第1次】\n" . $chat->content . "\n\n【第2次】\n" . $chat2->content . "\n\n【第3次】\n" . $chat3->content; $hint3 = $this->diagnoseTcmPrescriptionStructure($chat3->content); $structureHint = '第1次:' . $hint1 . ';第2次:' . $hint2 . '(已修正部分字段但仍缺药品);第3次补 drug 仍失败:' . ($hint3 !== '' ? $hint3 : '未能得到有效药品数组'); $this->throwAiFormatError( '中药处方 JSON 对象{prescription_name,reason,basis,dosage,day_dosage,drug}', $raw, $structureHint ); } $raw = "【第1次】\n" . $chat->content . "\n\n【第2次】\n" . $chat2->content; $structureHint = '第1次:' . $hint1; if ($hint2 !== '' && $hint2 !== $hint1) { $structureHint .= ';第2次:' . $hint2; } $this->throwAiFormatError( '中药处方 JSON 对象{prescription_name,reason,basis,dosage,day_dosage,drug}', $raw, $structureHint ); return [ 'chat' => $chat2, 'items' => [], 'dosage' => 7, 'day_dosage' => 2, 'reason' => '', 'basis' => '', 'prescription_name' => '', ]; } /** * 根据诊断结果生成定向纠正提示(比泛泛的「格式错误」更有效) */ private function buildTcmFormatRetryPrompt(string $structureHint, string $sample, string $usageHint): string { $parts = ['格式错误,请按下列要求重输出完整 JSON(不要 markdown)。']; if ($structureHint !== '') { $parts[] = '当前问题:' . $structureHint . '。'; } $parts[] = '强制规则:' . '1) dosage 必须是顶层整数(开几剂,如7),禁止对象/文案;' . '2) day_dosage 必须是顶层整数(每天几次,如2),禁止「每日2-3次」这类字符串;' . '3) drug 必须是顶层数组,约10~18味,每味含 name/dose/unit/usage,' . $usageHint . '禁止漏掉 drug;' . '4) reason/basis 各控制在80字以内,优先保证 drug 完整输出。' . '正确样例:' . $sample; return implode('', $parts); } /** * 是否「其它字段大致有了,主要缺 drug」 */ private function isMissingDrugOnly(string $content): bool { $data = $this->decodeJsonPayloadQuiet($content); if (!is_array($data) || array_is_list($data)) { return false; } $data = $this->flattenMisnestedTcmPrescription($data); foreach (['drug', 'drugs', 'items', 'medicines', 'list'] as $k) { if (isset($data[$k]) && is_array($data[$k]) && array_is_list($data[$k]) && !empty($data[$k])) { return false; } } // 至少有方名或理由之一,说明模型在出方语境中,只是漏了药品 $name = trim((string) ($data['prescription_name'] ?? $data['formula_name'] ?? '')); $reason = trim((string) ($data['reason'] ?? '')); return $name !== '' || $reason !== ''; } /** * 从半成品 JSON 提取药方元数据(无 drug 时仍可用于合并) * * @return array{prescription_name:string,reason:string,basis:string,dosage:int,day_dosage:int}|null */ private function extractTcmMetaWithoutDrug(string $content): ?array { $data = $this->decodeJsonPayloadQuiet($content); if (!is_array($data) || array_is_list($data)) { return null; } $data = $this->flattenMisnestedTcmPrescription($data); $prescriptionName = trim((string) ( $data['prescription_name'] ?? $data['formula_name'] ?? $data['方名'] ?? $data['药方名字'] ?? '' )); $reason = trim((string) ($data['reason'] ?? $data['why'] ?? '')); $basis = trim((string) ($data['basis'] ?? $data['according'] ?? '')); if ($prescriptionName === '' && $reason === '' && $basis === '') { return null; } return [ 'prescription_name' => $prescriptionName, 'reason' => $reason, 'basis' => $basis, 'dosage' => $this->toPositiveInt($data['dosage'] ?? $data['dose_count'] ?? $data['剂数'] ?? 0), 'day_dosage' => $this->parseDayDosageValue( $data['day_dosage'] ?? $data['day_times'] ?? $data['consumption'] ?? $data['每天几次'] ?? 0 ), ]; } /** * 西药等:解析 {prescription_name,reason,basis,drug} 对象(兼容旧版纯数组) * * @return array{chat: AiChatResult, items: array, dosage: int, day_dosage: int, reason: string, basis: string, prescription_name: string} */ private function chatAndParseWestPrescription($agent, array $messages, array $options = []): array { $sample = '{' . '"prescription_name":"上感对症方案",' . '"reason":"对症抗感染与解热",' . '"basis":"发热咽痛,无过敏史",' . '"drug":[{"name":"阿莫西林胶囊","dose":"0.5","unit":"g","usage":"口服","frequency":"一日三次"}]' . '}'; $chat = $agent->chatCompletions($messages, $options); $parsed = $this->tryParseWestPrescriptionPayload($chat->content); if ($parsed !== null) { return array_merge(['chat' => $chat], $parsed); } $retryMessages = array_merge($messages, [ ['role' => 'assistant', 'content' => $chat->content], [ 'role' => 'user', 'content' => '格式错误。必须输出 JSON 对象,含 prescription_name、reason、basis、drug 数组。样例:' . $sample, ], ]); $chat2 = $agent->chatCompletions($retryMessages, $options); $parsed2 = $this->tryParseWestPrescriptionPayload($chat2->content); if ($parsed2 !== null) { return array_merge(['chat' => $chat2], $parsed2); } $raw = "【第1次】\n" . $chat->content . "\n\n【第2次】\n" . $chat2->content; $this->throwAiFormatError('西药处方 JSON 对象{prescription_name,reason,basis,drug}', $raw); return [ 'chat' => $chat2, 'items' => [], 'dosage' => 0, 'day_dosage' => 0, 'reason' => '', 'basis' => '', 'prescription_name' => '', ]; } /** * 解析中药完整方:优先对象,兼容旧版纯数组与常见嵌套误结构 * * @return array{items: array, dosage: int, day_dosage: int, reason: string, basis: string, prescription_name: string}|null */ private function tryParseTcmPrescriptionPayload(string $content): ?array { $data = $this->decodeJsonPayloadQuiet($content); if (!is_array($data)) { return null; } $reason = ''; $basis = ''; $prescriptionName = ''; $dosage = 0; $dayDosage = 0; $items = null; if (array_is_list($data)) { $items = $data; $dosage = 7; $dayDosage = 2; } else { // 兼容 dosage:{day_dosage,drug} 等嵌套误结构,先拍平再取字段 $data = $this->flattenMisnestedTcmPrescription($data); $prescriptionName = trim((string) ( $data['prescription_name'] ?? $data['formula_name'] ?? $data['方名'] ?? $data['药方名字'] ?? '' )); // 部分模型用顶层 name 当方名;仅在已有 drug 数组且非单味药对象时采用 if ( $prescriptionName === '' && isset($data['name'], $data['drug']) && is_string($data['name']) && is_array($data['drug']) && !$this->looksLikeDrugItem($data) ) { $prescriptionName = trim($data['name']); } $reason = trim((string) ($data['reason'] ?? $data['why'] ?? $data['出方理由'] ?? '')); $basis = trim((string) ($data['basis'] ?? $data['according'] ?? $data['依据'] ?? '')); $dosage = $this->toPositiveInt($data['dosage'] ?? $data['dose_count'] ?? $data['剂数'] ?? 0); $dayDosage = $this->parseDayDosageValue( $data['day_dosage'] ?? $data['day_times'] ?? $data['consumption'] ?? $data['每天几次'] ?? 0 ); foreach (['drug', 'drugs', 'items', 'medicines', 'list'] as $key) { if (isset($data[$key]) && is_array($data[$key])) { $list = $data[$key]; if (array_is_list($list)) { $items = $list; break; } } } if ($items === null && $this->looksLikeDrugItem($data)) { $items = [$data]; } } if ($items === null) { return null; } $normalizedList = $this->tryNormalizeDrugList($items); if ($normalizedList === null) { return null; } return [ 'items' => $normalizedList, 'dosage' => $dosage > 0 ? $dosage : 7, 'day_dosage' => $dayDosage > 0 ? $dayDosage : 2, 'reason' => $reason, 'basis' => $basis, 'prescription_name' => $prescriptionName, ]; } /** * 拍平常见误结构:dosage 被写成对象,把 day_dosage/drug 嵌在里面 * 例:{"dosage":{"day_dosage":7,"drug":[...]}} → 顶层 dosage/day_dosage/drug * 例:{"dosage":{"dose":"10-15g","unit":"g"}} → 丢弃非法 dosage,留给默认值 */ private function flattenMisnestedTcmPrescription(array $data): array { if (!isset($data['dosage']) || !is_array($data['dosage']) || array_is_list($data['dosage'])) { return $data; } $nested = $data['dosage']; // dosage 被误写成「单味药剂量」对象(含 dose/unit,无 drug)——直接丢弃 if ( (isset($nested['dose']) || isset($nested['unit'])) && !isset($nested['drug']) && !isset($nested['drugs']) && !isset($nested['day_dosage']) && !isset($nested['dosage']) ) { unset($data['dosage']); return $data; } $hadTopDayDosage = array_key_exists('day_dosage', $data) || array_key_exists('day_times', $data) || array_key_exists('consumption', $data); $hadTopDrug = false; foreach (['drug', 'drugs', 'items', 'medicines', 'list'] as $k) { if (isset($data[$k]) && is_array($data[$k]) && array_is_list($data[$k])) { $hadTopDrug = true; break; } } // 把嵌套里的药品/每日次数提升到顶层(顶层已有则不覆盖) foreach (['drug', 'drugs', 'items', 'medicines', 'list'] as $k) { if (!$hadTopDrug && isset($nested[$k]) && is_array($nested[$k]) && array_is_list($nested[$k])) { $data[$k] = $nested[$k]; $hadTopDrug = true; } } if (!$hadTopDayDosage) { foreach (['day_dosage', 'day_times', 'consumption', '每天几次'] as $k) { if (isset($nested[$k]) && (is_numeric($nested[$k]) || is_int($nested[$k]) || is_string($nested[$k]))) { $parsedDay = $this->parseDayDosageValue($nested[$k]); // 常见误写:dosage:{day_dosage:7, drug:[]} —— 这里的 7 多半是「剂数」不是「每天几次」 if ( isset($nested['drug']) && is_array($nested['drug']) && !isset($nested['dosage']) && !isset($nested['剂数']) && is_numeric($nested[$k]) ) { $data['dosage'] = (int) $nested[$k]; } elseif ($parsedDay > 0) { $data['day_dosage'] = $parsedDay; } break; } } } // 嵌套内若另有真正的剂数 if (isset($nested['dosage']) && (is_numeric($nested['dosage']) || is_int($nested['dosage']))) { $data['dosage'] = (int) $nested['dosage']; } elseif (isset($nested['剂数']) && is_numeric($nested['剂数'])) { $data['dosage'] = (int) $nested['剂数']; } elseif (isset($data['dosage']) && is_array($data['dosage'])) { // 对象形态的 dosage 已处理完,去掉非法对象,留给后续默认值 unset($data['dosage']); } return $data; } /** * 诊断中药处方 JSON 结构问题,给医生可读说明 */ private function diagnoseTcmPrescriptionStructure(string $content): string { $data = $this->decodeJsonPayloadQuiet($content); if ($data === null) { return '无法解析为合法 JSON(可能夹杂 markdown/解释文字,或括号不完整)'; } if (!is_array($data)) { return '解析结果不是 JSON 对象/数组'; } if (array_is_list($data)) { return '最外层是数组[],期望对象{},且需含 prescription_name、reason、basis、dosage、day_dosage、drug'; } $problems = []; if (isset($data['dosage']) && is_array($data['dosage']) && !array_is_list($data['dosage'])) { if (isset($data['dosage']['dose']) || isset($data['dosage']['unit'])) { $problems[] = 'dosage 被误写成单味药剂量对象(含 dose/unit),它必须是整数「开几剂」如 7'; } else { $problems[] = 'dosage 应为顶层整数(开几剂),不能写成对象;你把字段嵌进了 dosage 里' . (isset($data['dosage']['drug']) ? '(drug 应在顶层)' : '') . (isset($data['dosage']['day_dosage']) ? '(day_dosage 应在顶层)' : ''); } } elseif (isset($data['dosage']) && !is_numeric($data['dosage']) && !is_int($data['dosage'])) { $problems[] = 'dosage 类型错误,应为整数,当前为 ' . gettype($data['dosage']); } $hasDrug = false; foreach (['drug', 'drugs', 'items', 'medicines', 'list'] as $k) { if (isset($data[$k]) && is_array($data[$k]) && array_is_list($data[$k]) && count($data[$k]) > 0) { $hasDrug = true; break; } } if (!$hasDrug) { if (isset($data['dosage']['drug']) && is_array($data['dosage']['drug'])) { $problems[] = '缺少顶层 drug 数组:药品写在了 dosage.drug 内,应提升为顶层 drug'; } else { $problems[] = '缺少顶层 drug 药品数组(这是处方主体,不能省略)'; } } if (isset($data['day_dosage']) && is_array($data['day_dosage'])) { $problems[] = 'day_dosage 应为整数(每天几次),不能是对象/数组'; } elseif ( isset($data['day_dosage']) && is_string($data['day_dosage']) && $this->parseDayDosageValue($data['day_dosage']) <= 0 ) { $problems[] = 'day_dosage 应为整数如 2,不能写「' . mb_substr(trim($data['day_dosage']), 0, 20) . '」这类文案'; } if (empty($problems)) { if ($hasDrug) { $drug = $data['drug'] ?? $data['drugs'] ?? $data['items'] ?? []; if (is_array($drug) && array_is_list($drug) && empty($drug)) { $problems[] = 'drug 数组为空'; } elseif (is_array($drug) && !$this->tryNormalizeDrugList($drug)) { $problems[] = 'drug 数组内药品字段不完整(每味需有 name)'; } } if (empty($problems)) { $problems[] = '字段齐全性或类型不符合约定,请按样例输出扁平结构'; } } return implode(';', $problems); } /** 宽松转正整数,对象/数组视为 0 */ private function toPositiveInt(mixed $value): int { if (is_int($value)) { return $value > 0 ? $value : 0; } if (is_float($value) || (is_string($value) && is_numeric($value))) { $n = (int) $value; return $n > 0 ? $n : 0; } return 0; } /** * 解析每日次数:支持整数,或「每日2次」「一日2-3次」等文案取下限 */ private function parseDayDosageValue(mixed $value): int { $n = $this->toPositiveInt($value); if ($n > 0) { return $n; } if (!is_string($value)) { return 0; } $text = trim($value); if ($text === '') { return 0; } if (preg_match('/(\d+)\s*[-~~到至]\s*(\d+)/u', $text, $m)) { return max(1, (int) $m[1]); } if (preg_match('/(\d+)\s*次/u', $text, $m)) { return max(1, (int) $m[1]); } if (preg_match('/(\d+)/u', $text, $m)) { return max(1, (int) $m[1]); } return 0; } /** * 解析西药处方对象(兼容旧版数组) * * @return array{items: array, dosage: int, day_dosage: int, reason: string, basis: string, prescription_name: string}|null */ private function tryParseWestPrescriptionPayload(string $content): ?array { $data = $this->decodeJsonPayloadQuiet($content); if (!is_array($data)) { return null; } if (array_is_list($data)) { $list = $this->tryNormalizeDrugList($data); if ($list === null) { return null; } return [ 'items' => $list, 'dosage' => 0, 'day_dosage' => 0, 'reason' => '', 'basis' => '', 'prescription_name' => '', ]; } $prescriptionName = trim((string) ( $data['prescription_name'] ?? $data['formula_name'] ?? $data['方名'] ?? $data['药方名字'] ?? '' )); $reason = trim((string) ($data['reason'] ?? $data['why'] ?? '')); $basis = trim((string) ($data['basis'] ?? $data['according'] ?? '')); $items = null; foreach (['drug', 'drugs', 'items', 'medicines', 'list'] as $key) { if (isset($data[$key]) && is_array($data[$key]) && array_is_list($data[$key])) { $items = $data[$key]; break; } } if ($items === null) { return null; } $normalizedList = $this->tryNormalizeDrugList($items); if ($normalizedList === null) { return null; } return [ 'items' => $normalizedList, 'dosage' => 0, 'day_dosage' => 0, 'reason' => $reason, 'basis' => $basis, 'prescription_name' => $prescriptionName, ]; } /** * 将候选结构规范为药品列表(失败返回 null) */ private function tryNormalizeDrugList(array $data): ?array { if (!array_is_list($data)) { if ($this->looksLikeDrugItem($data)) { return [$data]; } $values = array_values($data); $allItems = true; foreach ($values as $v) { if (!is_array($v) || !$this->looksLikeDrugItem($v)) { $allItems = false; break; } } if ($allItems && !empty($values)) { return $values; } return null; } return $data; } /** * 调用模型并解析 JSON 数组:先宽松读取,失败则纠正提示重试一次 * * @return array{chat: AiChatResult, data: array} */ private function chatAndParseJsonArray($agent, array $messages, array $options = []): array { $chat = $agent->chatCompletions($messages, $options); $data = $this->tryParseJsonArray($chat->content); if ($data !== null) { return ['chat' => $chat, 'data' => $data]; } $sample = '[{"name":"阿莫西林胶囊","dose":"0.5","unit":"g","usage":"口服","frequency":"一日三次"},' . '{"name":"布洛芬缓释胶囊","dose":"0.3","unit":"g","usage":"口服","frequency":"一日两次"}]'; $retryMessages = array_merge($messages, [ ['role' => 'assistant', 'content' => $chat->content], [ 'role' => 'user', 'content' => '格式错误:最外层必须是 JSON 数组 []。请严格按样例只输出 JSON 数组,不要 markdown:' . $sample, ], ]); $chat2 = $agent->chatCompletions($retryMessages, $options); $data2 = $this->tryParseJsonArray($chat2->content); if ($data2 !== null) { return ['chat' => $chat2, 'data' => $data2]; } $raw = "【第1次】\n" . $chat->content . "\n\n【第2次】\n" . $chat2->content; $this->throwAiFormatError('JSON 数组', $raw); return ['chat' => $chat2, 'data' => []]; } /** * 调用模型并解析 JSON 对象:先宽松读取,失败则纠正提示重试一次 * * @return array{chat: AiChatResult, data: array} */ private function chatAndParseJsonObject($agent, array $messages, array $options = []): array { $chat = $agent->chatCompletions($messages, $options); $data = $this->tryParseJsonObject($chat->content); if ($data !== null) { return ['chat' => $chat, 'data' => $data]; } $retryMessages = array_merge($messages, [ ['role' => 'assistant', 'content' => $chat->content], [ 'role' => 'user', 'content' => '你上次的输出无法解析为 JSON 对象。请严格只输出一个 JSON 对象,' . '不要 markdown、不要解释、不要前后缀文字。', ], ]); $chat2 = $agent->chatCompletions($retryMessages, $options); $data2 = $this->tryParseJsonObject($chat2->content); if ($data2 !== null) { return ['chat' => $chat2, 'data' => $data2]; } $raw = "【第1次】\n" . $chat->content . "\n\n【第2次】\n" . $chat2->content; $this->throwAiFormatError('JSON 对象', $raw); return ['chat' => $chat2, 'data' => []]; } /** * 尝试解析为 JSON 对象(失败返回 null,不抛异常) */ private function tryParseJsonObject(string $content): ?array { $data = $this->decodeJsonPayloadQuiet($content); if (!is_array($data) || array_is_list($data)) { return null; } return $data; } /** * 尝试解析为 JSON 数组(失败返回 null) * 兼容:{items:[]}、单个药品对象、{"药名":{...}} 映射结构 */ private function tryParseJsonArray(string $content): ?array { $data = $this->decodeJsonPayloadQuiet($content); if (!is_array($data)) { return null; } if (!array_is_list($data)) { foreach (['items', 'drugs', 'medicines', 'list', 'data', 'prescription'] as $key) { if (isset($data[$key]) && is_array($data[$key]) && array_is_list($data[$key])) { $data = $data[$key]; break; } } } // 模型常返回单个药品对象:{"name":"熟地黄",...} → 包成数组 if (!array_is_list($data) && $this->looksLikeDrugItem($data)) { $data = [$data]; } // 模型返回 {"熟地黄":{"name":"熟地黄",...}, "茯苓":{...}} → 取 values if (!array_is_list($data)) { $values = array_values($data); $allItems = true; foreach ($values as $v) { if (!is_array($v) || !$this->looksLikeDrugItem($v)) { $allItems = false; break; } } if ($allItems && !empty($values)) { $data = $values; } } if (!array_is_list($data)) { return null; } return $data; } /** * 判断是否像一条药品 JSON(含 name / drug_name) */ private function looksLikeDrugItem(array $row): bool { $name = trim((string) ($row['name'] ?? $row['drug_name'] ?? '')); return $name !== ''; } /** * 宽松解码:剥 markdown、截取括号、修常见脏字符;失败返回 null */ private function decodeJsonPayloadQuiet(string $content): mixed { $text = trim($content); if ($text === '') { return null; } if (preg_match('/```(?:json)?\s*([\s\S]*?)```/i', $text, $m)) { $text = trim($m[1]); } $startObj = strpos($text, '{'); $startArr = strpos($text, '['); $start = false; if ($startObj === false) { $start = $startArr; } elseif ($startArr === false) { $start = $startObj; } else { $start = min($startObj, $startArr); } if ($start !== false && $start > 0) { $text = substr($text, $start); } // 按括号配对截到闭合处,去掉尾部解释文字 $extracted = $this->extractBalancedJson($text); if ($extracted !== null) { $text = $extracted; } $candidates = [$text, $this->sanitizeJsonText($text)]; foreach ($candidates as $candidate) { $decoded = json_decode($candidate, true); if (json_last_error() === JSON_ERROR_NONE) { return $decoded; } } return null; } /** * 从文本中按括号深度截取完整 JSON 片段 */ private function extractBalancedJson(string $text): ?string { $text = trim($text); if ($text === '') { return null; } $open = $text[0]; $close = $open === '[' ? ']' : ($open === '{' ? '}' : ''); if ($close === '') { return null; } $depth = 0; $inStr = false; $escape = false; $len = strlen($text); for ($i = 0; $i < $len; $i++) { $ch = $text[$i]; if ($inStr) { if ($escape) { $escape = false; continue; } if ($ch === '\\') { $escape = true; continue; } if ($ch === '"') { $inStr = false; } continue; } if ($ch === '"') { $inStr = true; continue; } if ($ch === $open) { $depth++; } elseif ($ch === $close) { $depth--; if ($depth === 0) { return substr($text, 0, $i + 1); } } } return null; } /** * 修常见脏 JSON:中文引号、尾逗号 */ private function sanitizeJsonText(string $text): string { $text = str_replace(["\u{201c}", "\u{201d}", '「', '」', '『', '』'], '"', $text); $text = preg_replace('/,\s*([}\]])/', '$1', $text) ?? $text; return $text; } /** * 格式错误抛错:优先说明具体结构问题;超管(role_id=1)附带原始返回便于排障 * * @param string $structureHint 诊断出的结构问题说明(可空) */ private function throwAiFormatError(string $expectLabel, string $rawContent, string $structureHint = ''): void { $msg = 'AI 返回格式错误,期望 ' . $expectLabel; if ($structureHint !== '') { $msg .= '。结构问题:' . $structureHint; } if ($this->isSuperAdminViewer()) { $snippet = trim($rawContent); if (mb_strlen($snippet, 'UTF-8') > 4000) { $snippet = mb_substr($snippet, 0, 4000, 'UTF-8') . '…(已截断)'; } $msg .= '。原始返回:' . ($snippet !== '' ? $snippet : '(空)'); } else { $msg .= ',请重试'; } $this->utils->errorThrow($msg); } /** * 当前登录是否超管(可看 AI 原始失败返回) */ private function isSuperAdminViewer(): bool { return (int) $this->roleId === RoleEnum::SUPPER_ADMIN->value; } /** * @deprecated 保留兼容;请用 tryParse + chatAndParse */ private function parseJsonObject(string $content): array { $data = $this->tryParseJsonObject($content); if ($data === null) { $this->throwAiFormatError('JSON 对象', $content); } return $data; } /** * @deprecated 保留兼容;请用 tryParse + chatAndParse */ private function parseJsonArray(string $content): array { $data = $this->tryParseJsonArray($content); if ($data === null) { $this->throwAiFormatError('JSON 数组', $content); } return $data; } /** * AI 请求前落库:status=0 进行中;provider/model/api_key_id 来自运行时配置 * started_at 存 unix 秒;毫秒起点挂在模型动态属性供 finish 算 duration_ms * * @param int $storeId 门店 * @param int $registerId 挂号 * @param int $doctorId 医生 * @param string $scene medical_record|prescription * @param int $prescriptionType 处方类型(病历为 0) * @param string|null $provider 供应商覆盖 * @param array $inputSnapshot 输入快照 */ private function beginGeneration( int $storeId, int $registerId, int $doctorId, string $scene, int $prescriptionType, ?string $provider, array $inputSnapshot ): AiGenerationModel { $now = time(); $startedMs = (int) (microtime(true) * 1000); $cfg = AiRuntimeConfigService::getInstance()->resolve($provider); $row = new AiGenerationModel(); $row->fill([ 'store_id' => $storeId, 'register_id' => $registerId, 'doctor_id' => $doctorId, 'scene' => $scene, 'prescription_type' => $prescriptionType, 'name' => '', 'provider' => (string) ($cfg['provider'] ?? ''), 'model' => (string) ($cfg['model'] ?? ''), 'status' => 0, 'api_key_id' => (int) ($cfg['api_key_id'] ?? 0), 'prompt_tokens' => 0, 'completion_tokens' => 0, 'total_tokens' => 0, 'usage_json' => null, 'input_snapshot' => $inputSnapshot, 'result_json' => null, 'raw_response' => '', 'started_at' => $now, 'finished_at' => 0, 'duration_ms' => 0, 'error_msg' => '', 'created_at' => $now, 'updated_at' => 0, 'deleted_at' => 0, ]); $row->save(); // 仅内存:用模型已声明属性,避免 Eloquent 当列落库 $row->runtimeStartedMs = $startedMs; return $row; } /** * AI 成功回写:status=1、tokens、结果、finished_at、duration_ms * * @param AiGenerationModel $row begin 返回的行 * @param AiChatResult $chat 对话结果 * @param array $resultJson 业务结果 JSON */ private function finishGenerationSuccess( AiGenerationModel $row, AiChatResult $chat, array $resultJson ): AiGenerationModel { $now = time(); $usage = is_array($chat->usage) ? $chat->usage : []; $promptTokens = (int) ($usage['prompt_tokens'] ?? 0); $completionTokens = (int) ($usage['completion_tokens'] ?? 0); $totalTokens = (int) ($usage['total_tokens'] ?? ($promptTokens + $completionTokens)); $row->fill([ 'name' => mb_substr(trim((string) ( $resultJson['prescription_name'] ?? $resultJson['name'] ?? $resultJson['diagnosis'] ?? '' )), 0, 128), 'provider' => $chat->provider !== '' ? $chat->provider : (string) $row->provider, 'model' => $chat->model !== '' ? $chat->model : (string) $row->model, 'status' => 1, 'api_key_id' => $chat->apiKeyId > 0 ? $chat->apiKeyId : (int) $row->api_key_id, 'prompt_tokens' => $promptTokens, 'completion_tokens' => $completionTokens, 'total_tokens' => $totalTokens, 'usage_json' => $usage ?: null, 'result_json' => $resultJson, 'raw_response' => $chat->content, 'finished_at' => $now, 'duration_ms' => $this->calcGenerationDurationMs($row), 'error_msg' => '', 'updated_at' => $now, ]); $row->save(); return $row; } /** * AI 失败回写:status=2、error_msg(截断 1000)、finished_at、duration_ms;有 chat 则尽量落 raw * * @param AiGenerationModel $row begin 返回的行 * @param string $errorMsg 失败原因 * @param AiChatResult|null $chat 若已有部分响应用于排障 */ private function finishGenerationFail( AiGenerationModel $row, string $errorMsg, ?AiChatResult $chat = null ): AiGenerationModel { $now = time(); $patch = [ 'status' => 2, 'finished_at' => $now, 'duration_ms' => $this->calcGenerationDurationMs($row), 'error_msg' => mb_substr(trim($errorMsg), 0, 1000), 'updated_at' => $now, ]; if ($chat) { $usage = is_array($chat->usage) ? $chat->usage : []; $promptTokens = (int) ($usage['prompt_tokens'] ?? 0); $completionTokens = (int) ($usage['completion_tokens'] ?? 0); $totalTokens = (int) ($usage['total_tokens'] ?? ($promptTokens + $completionTokens)); $patch['provider'] = $chat->provider !== '' ? $chat->provider : (string) $row->provider; $patch['model'] = $chat->model !== '' ? $chat->model : (string) $row->model; $patch['api_key_id'] = $chat->apiKeyId > 0 ? $chat->apiKeyId : (int) $row->api_key_id; $patch['prompt_tokens'] = $promptTokens; $patch['completion_tokens'] = $completionTokens; $patch['total_tokens'] = $totalTokens; $patch['usage_json'] = $usage ?: null; $patch['raw_response'] = $chat->content; } $row->fill($patch); $row->save(); return $row; } /** * 计算生成耗时(毫秒):优先用 begin 时挂载的 runtimeStartedMs,否则回落 started_at 秒 */ private function calcGenerationDurationMs(AiGenerationModel $row): int { $startedMs = (int) ($row->runtimeStartedMs ?? 0); // 兼容旧写法误入 attributes 的 _started_ms(若有则读完后剔除,避免 save 报列不存在) if ($startedMs <= 0 && array_key_exists('_started_ms', $row->getAttributes())) { $startedMs = (int) $row->getAttribute('_started_ms'); } if (array_key_exists('_started_ms', $row->getAttributes())) { $row->offsetUnset('_started_ms'); } $nowMs = (int) (microtime(true) * 1000); if ($startedMs > 0) { return max(0, $nowMs - $startedMs); } $startedAt = (int) ($row->started_at ?? 0); if ($startedAt > 0) { return max(0, $nowMs - $startedAt * 1000); } return 0; } /** * 加载委托调剂上下文:父规则 + 子煎法列表 + 备注 * * @return array{enabled:bool,process_rule_id:int,process_rule_name:string,children:list,notes:list} */ private function loadEntrustedProcessContext(bool $useEntrusted, int $processRuleId): array { $empty = [ 'enabled' => false, 'process_rule_id' => 0, 'process_rule_name' => '', 'children' => [], 'notes' => [], ]; if (!$useEntrusted || $processRuleId <= 0) { return $empty; } $parent = ProcessRuleModel::where('id', $processRuleId)->first(['id', 'name', 'pid']); if (!$parent) { return $empty; } $children = ProcessRuleModel::where('pid', $processRuleId) ->orderBy('id') ->get(['id', 'name']) ->map(static fn ($r) => [ 'id' => (int) $r->id, 'name' => trim((string) $r->name), ]) ->filter(static fn ($r) => $r['name'] !== '') ->values() ->all(); $childIds = array_column($children, 'id'); $notes = []; if (!empty($childIds)) { $notes = ProcessRuleNoteModel::whereIn('rule_id', $childIds) ->orderBy('id') ->get(['id', 'rule_id', 'note']) ->map(static fn ($n) => [ 'id' => (int) $n->id, 'rule_id' => (int) $n->rule_id, 'note' => trim((string) $n->note), ]) ->filter(static fn ($n) => $n['note'] !== '') ->values() ->all(); } return [ 'enabled' => true, 'process_rule_id' => (int) $parent->id, 'process_rule_name' => trim((string) $parent->name), 'children' => $children, 'notes' => $notes, ]; } /** * 组装委托调剂 / 非委托 的提示词约束 * return_detail=false 时:只限定一级制剂,禁止输出剂数/每日次数/二级详细规则 */ private function buildEntrustedProcessPromptHint(array $processCtx): string { if (empty($processCtx['enabled'])) { return '禁止输出 child_process_rule_name、process_rule_note、child_process_rule_id、process_rule_id 等加工/煎法字段。'; } $formName = (string) ($processCtx['process_rule_name'] ?: ('ID' . ($processCtx['process_rule_id'] ?? 0))); $returnDetail = !array_key_exists('return_detail', $processCtx) || !empty($processCtx['return_detail']); if (!$returnDetail) { // 不需要详细规则:仍须出剂数/每日次数供导入;仅禁止二级制剂字段 return '本方启用「委托调剂」,一级制剂要求为「' . $formName . '」,' . '组方须符合该剂型;仍须输出 dosage、day_dosage(常用 7 与 2);' . '禁止输出 child_process_rule_name、process_rule_note、child_process_rule_id、process_rule_id 等详细制剂字段。'; } $names = array_column($processCtx['children'] ?? [], 'name'); if (empty($names)) { return '当前制剂要求下无可用煎法说明,不要输出 child_process_rule_name。' . '仍须输出 dosage、day_dosage。'; } $hint = '本方启用「委托调剂」,一级制剂要求为「' . $formName . '」(须严格按该一级剂型出方,如汤剂/丸剂/颗粒/膏方等均以医生所选为准)。' . '必须额外输出顶层字段 child_process_rule_name:只能从下列二级说明中选且仅选一个——' . implode('、', $names) . '。'; // 备注挂在二级煎法上:按煎法分组列出,有备注时强制 AI 选一个,避免导入丢「附加备注」 $notesByChild = []; foreach ($processCtx['notes'] ?? [] as $note) { $rid = (int) ($note['rule_id'] ?? 0); $text = trim((string) ($note['note'] ?? '')); if ($rid <= 0 || $text === '') { continue; } $notesByChild[$rid][] = $text; } $noteHintParts = []; foreach ($processCtx['children'] ?? [] as $child) { $cid = (int) ($child['id'] ?? 0); $cname = (string) ($child['name'] ?? ''); if ($cid <= 0 || $cname === '' || empty($notesByChild[$cid])) { continue; } $noteHintParts[] = '「' . $cname . '」→ ' . implode('、', array_values(array_unique($notesByChild[$cid]))); } if (!empty($noteHintParts)) { $hint .= '必须额外输出顶层字段 process_rule_note:须与所选 child_process_rule_name 对应,' . '只能从该煎法下列备注中选且仅选一个——' . implode(';', $noteHintParts) . '。禁止留空。'; } else { $hint .= '当前二级煎法下无附加备注,process_rule_note 输出空字符串。'; } return $hint; } /** * 从 AI 响应解析委托调剂字段,并映射到 process_rule_id / child_process_rule_id / note_id * return_detail=false 时只回传一级制剂 id/name * * @return array */ private function resolveEntrustedProcessFromChat(string $content, array $processCtx): array { if (empty($processCtx['enabled'])) { return []; } $base = [ 'process_rule_id' => (int) ($processCtx['process_rule_id'] ?? 0), 'process_rule_name' => (string) ($processCtx['process_rule_name'] ?? ''), ]; $returnDetail = !array_key_exists('return_detail', $processCtx) || !empty($processCtx['return_detail']); if (!$returnDetail) { return array_merge($base, [ 'child_process_rule_id' => 0, 'child_process_rule_name' => '', 'process_rule_note_id' => 0, 'process_rule_note' => '', ]); } $data = $this->decodeJsonPayloadQuiet($content); if (!is_array($data) || array_is_list($data)) { return array_merge($base, [ 'child_process_rule_id' => 0, 'child_process_rule_name' => '', 'process_rule_note_id' => 0, 'process_rule_note' => '', ]); } $childName = trim((string) ( $data['child_process_rule_name'] ?? $data['煎法说明'] ?? $data['煎法'] ?? '' )); $noteText = trim((string) ( $data['process_rule_note'] ?? $data['加工备注'] ?? $data['附加备注'] ?? $data['备注'] ?? '' )); $childId = 0; $matchedChildName = ''; foreach ($processCtx['children'] as $child) { $n = (string) ($child['name'] ?? ''); if ($n !== '' && ($n === $childName || mb_strpos($childName, $n) !== false || mb_strpos($n, $childName) !== false)) { $childId = (int) $child['id']; $matchedChildName = $n; break; } } // 二级未匹配到时,尽量用列表第一项兜底,保证导入能级联出备注 if ($childId <= 0 && !empty($processCtx['children'][0])) { $childId = (int) ($processCtx['children'][0]['id'] ?? 0); $matchedChildName = (string) ($processCtx['children'][0]['name'] ?? ''); } $noteId = 0; $matchedNote = ''; $childNotes = []; foreach ($processCtx['notes'] as $note) { if ((int) ($note['rule_id'] ?? 0) !== $childId) { continue; } $n = (string) ($note['note'] ?? ''); if ($n === '') { continue; } $childNotes[] = $note; if ($noteText !== '' && ($n === $noteText || mb_strpos($noteText, $n) !== false || mb_strpos($n, $noteText) !== false)) { $noteId = (int) $note['id']; $matchedNote = $n; } } // AI 漏写/写错备注时:取该二级煎法下第一条备注,避免导入后「附加备注」空白 if ($noteId <= 0 && !empty($childNotes[0])) { $noteId = (int) ($childNotes[0]['id'] ?? 0); $matchedNote = (string) ($childNotes[0]['note'] ?? ''); } return array_merge($base, [ 'child_process_rule_id' => $childId, 'child_process_rule_name' => $matchedChildName !== '' ? $matchedChildName : $childName, 'process_rule_note_id' => $noteId, 'process_rule_note' => $matchedNote !== '' ? $matchedNote : $noteText, ]); } /** * 从 yii_drug_use_way 读取煎法 name 列表,供 AI 约束 usage 与落库对照 * * @return list */ private function loadDrugUseWayNames(): array { return DrugUseWayModel::query() ->orderBy('id') ->pluck('name') ->map(static fn ($n) => trim((string) $n)) ->filter(static fn ($n) => $n !== '') ->unique() ->values() ->all(); } /** * 将 AI 返回的 usage 对齐到词典 name(精确或包含匹配);对不上则清空,避免无法映射 way_id * * @param list $allowedNames */ private function normalizeUsageAgainstDict(string $usage, array $allowedNames): string { $usage = trim($usage); if ($usage === '' || empty($allowedNames)) { return $usage; } if (in_array($usage, $allowedNames, true)) { return $usage; } // 长短优先:先匹配更长的词典项,减少「先煎」被「先」误伤 $sorted = $allowedNames; usort($sorted, static fn ($a, $b) => mb_strlen($b) <=> mb_strlen($a)); foreach ($sorted as $name) { if ($name !== '' && mb_strpos($usage, $name) !== false) { return $name; } } return ''; } /** * 规范化处方 items * dose 去掉尾部单位;中药 usage 对齐 yii_drug_use_way;不再依赖 frequency * * @param list $useWayNames 中药煎法词典;非空时对 usage 做对齐 */ private function normalizePrescriptionItems(array $items, array $useWayNames = []): array { $out = []; foreach ($items as $row) { if (!is_array($row)) { continue; } $name = trim((string) ($row['name'] ?? $row['drug_name'] ?? '')); if ($name === '') { continue; } $dose = trim((string) ($row['dose'] ?? $row['number'] ?? '')); // 模型常把单位写进 dose:15g / 15克 $dose = preg_replace('/\s*(g|克|G)\s*$/u', '', $dose) ?? $dose; $unit = trim((string) ($row['unit'] ?? '')); if ($unit === '' && preg_match('/g|克/u', (string) ($row['dose'] ?? ''))) { $unit = 'g'; } $usage = trim((string) ($row['usage'] ?? $row['decoct'] ?? $row['way'] ?? '')); // 过滤无意义的频次文案误入 usage if (preg_match('/每日\s*\d+\s*剂|一天\s*\d+\s*剂/u', $usage)) { $usage = ''; } if (in_array($usage, ['煎服', '常规', '普通', '水煎服', '煎汤服'], true)) { $usage = ''; } if (!empty($useWayNames)) { $usage = $this->normalizeUsageAgainstDict($usage, $useWayNames); } $item = [ 'name' => $name, 'dose' => $dose, 'unit' => $unit !== '' ? $unit : 'g', 'usage' => $usage, 'frequency' => trim((string) ($row['frequency'] ?? '')), ]; // 保留医生改选落库字段,供历史对照还原 selected_drug_id $savedDrugId = (int) ($row['selected_drug_id'] ?? 0); if ($savedDrugId > 0) { $item['selected_drug_id'] = $savedDrugId; $item['selected_drug_name'] = trim((string) ($row['selected_drug_name'] ?? '')); $item['is_modified'] = !empty($row['is_modified']) ? 1 : 0; } $out[] = $item; } return $out; } /** * 医生在 AI 出方对照中改选药品:写回 result_json 并标记已修改 * 为什么:对照结果原先只在前端内存,历史重开会丢回默认候选;落库后可还原 * * @param int $generationId AI 生成记录 ID * @param int $itemIndex items 下标 * @param int $selectedDrugId 选中的药品 ID * @param string $selectedDrugName 选中药名(展示用) * @return array{ok: bool, item: array, has_manual_modify: int} */ public function savePrescriptionDrugSelection( int $generationId, int $itemIndex, int $selectedDrugId, string $selectedDrugName = '' ): array { if ($generationId <= 0) { $this->utils->errorThrow('generation_id 无效'); } if ($itemIndex < 0) { $this->utils->errorThrow('item_index 无效'); } if ($selectedDrugId <= 0) { $this->utils->errorThrow('请选择药品'); } $row = AiGenerationModel::where('id', $generationId) ->where('deleted_at', 0) ->first(); if (!$row || $row->scene !== self::SCENE_RX) { $this->utils->errorThrow('AI 出方记录不存在'); } $result = is_array($row->result_json) ? $row->result_json : []; $items = is_array($result['items'] ?? null) ? $result['items'] : []; if (!isset($items[$itemIndex]) || !is_array($items[$itemIndex])) { $this->utils->errorThrow('药品行不存在'); } $prevId = (int) ($items[$itemIndex]['selected_drug_id'] ?? 0); $items[$itemIndex]['selected_drug_id'] = $selectedDrugId; $items[$itemIndex]['selected_drug_name'] = trim($selectedDrugName); // 相对「从未改选」或「改成了别的药」均标记;首次写入也视为医生确认过选择 $items[$itemIndex]['is_modified'] = 1; $result['items'] = $items; $result['has_manual_modify'] = 1; $row->result_json = $result; $row->updated_at = time(); $row->save(); return [ 'ok' => true, 'item' => $items[$itemIndex], 'has_manual_modify' => 1, 'prev_selected_drug_id' => $prevId, ]; } }