Files
nl-admin-api/app/Service/common/ai/AiMedicalAssistService.php
2026-08-10 15:51:00 +08:00

3020 lines
134 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use App\Enum\product\ProductTypeEnum;
use App\Enum\RoleEnum;
use App\Models\new\ai\AiGenerationModel;
use App\Models\new\medicalRecord\MedicalRecordModel;
use App\Models\old\doctor\DiseaseModel;
use App\Models\old\drug\DrugUseWayModel;
use App\Models\old\drug\ProcessRuleModel;
use App\Models\old\drug\ProcessRuleNoteModel;
use App\Models\old\order\RegisterOrderModel;
use App\Service\admin\business\doctor\DoctorReceptionService;
use App\Service\admin\business\prescription\PrescriptionService;
use App\Service\common\vip\VipGateService;
use Illuminate\Support\Facades\DB;
/**
* AI 写病历 / AI 给处方业务
* 依赖 AiAgentFactoryVIP 独立码 ai_medical_record / ai_prescription
*/
class AiMedicalAssistService extends BaseService
{
public const VIP_MR = 'ai_medical_record';
public const VIP_RX = 'ai_prescription';
public const SCENE_MR = 'medical_record';
public const SCENE_RX = 'prescription';
/** AI 写病历可输出字段白名单(实际生成字段以系统配置为准) */
private const MR_OUTPUT_FIELDS = [
'present_illness', 'tongue', 'pulse', 'tcm_case',
'tcm_disease', 'tcm_syndrome', 'tcm_method',
'diagnosis', 'treatment_advice', 'doctor_order',
'family_history', 'epidemic_history', 'past_history',
'menstrual_history', 'marital_history', 'personal_history',
'allergy_history', 'physical_exam', 'auxiliary_exam',
];
/**
* 当前配置要求 AI 输出的病历字段(已校验白名单)
* 严格按后台勾选:勾了才生成,没勾不生成
*
* @return list<string>
*/
private function resolveMrOutputFields(): array
{
return AiRuntimeConfigService::getInstance()->getMedicalRecordOutputFields();
}
/**
* 中医三典 prompt 约束:只提示按「中医临床诊疗术语」填写,不塞字典样本(省 token
* 治法限定为中药内服相关,避免模型返回针灸/推拿等外治名称
* 后端仍用本地字典校正名称并映射 diseases_id / method_id / syndrome_id
*
* @param list<string> $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<string> $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<string> $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<string,string> $fields
* @return array{fields: array<string,string>, 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<array{id?:int|string,name?:string,alias?:string}> $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<string,string> $fields
* @return array{fields: array<string,string>, diagnosis_ids: list<int>}
*/
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<string,mixed>|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<string>
*/
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<string, string>
*/
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<mixed> $parsed
* @return array<string, mixed>
*/
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=falseVIP/参数校验仍硬抛
*
* @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.sex0默认 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<string> $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必须完整君臣佐使一般1018味不得只给25味)。'
. '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<string> $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 . '」开出完整中药饮片方1018味君臣佐使齐全')
: '开出一副完整中药饮片方1018味君臣佐使齐全剂型不限于汤剂';
$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<string, string>
*/
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<string>
*/
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<string> $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'
. '约1018味中药元素字段 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 必须是顶层数组约1018味每味含 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<array{id:int,name:string}>,notes:list<array{id:int,rule_id:int,note:string}>}
*/
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<string, mixed>
*/
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<string>
*/
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<string> $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<string> $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'] ?? ''));
// 模型常把单位写进 dose15g / 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,
];
}
}