3020 lines
134 KiB
PHP
3020 lines
134 KiB
PHP
<?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 给处方业务
|
||
* 依赖 AiAgentFactory;VIP 独立码 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=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<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;必须完整君臣佐使,一般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<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 . '」开出完整中药饮片方(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<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),'
|
||
. '约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<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'] ?? ''));
|
||
// 模型常把单位写进 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,
|
||
];
|
||
}
|
||
}
|