205 lines
7.0 KiB
Go
205 lines
7.0 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
)
|
||
|
||
// ========================================================================
|
||
// 病历生成 Agent (EMR Generator)
|
||
// ========================================================================
|
||
// 场景说明:
|
||
// 患者提供主诉和病史 → Agent 生成符合《中医病历书写规范》的结构化病历
|
||
//
|
||
// 模型路由:
|
||
// 默认路由到 "emr-generator" → 配置中指向 DeepSeek/gpt-4o 等强推理模型
|
||
//
|
||
// 生命周期:
|
||
// ① 感知:接收患者主诉+病史
|
||
// ② 规划:检索病历模板 → 标准化术语 → 生成初稿
|
||
// ③ 检索:调 MaxKB 获取病历书写规范
|
||
// ④ 工具:查 HIS 获取既往病史
|
||
// ⑤ 反思:规则引擎质控 → 不通过则修正
|
||
// ⑥ 输出:结构化病历 JSON
|
||
// ========================================================================
|
||
|
||
// EMRGenerator 病历生成 Agent
|
||
type EMRGenerator struct {
|
||
runner *Runner
|
||
scene string // 场景名(用于模型路由)
|
||
}
|
||
|
||
// EMRRequest 病历生成请求
|
||
type EMRRequest struct {
|
||
PatientID string `json:"patient_id" binding:"required"` // 患者 ID
|
||
ChiefComplaint string `json:"chief_complaint" binding:"required"` // 主诉
|
||
HistoryNotes string `json:"history_notes"` // 病史补充
|
||
Allergies []string `json:"allergies"` // 过敏史
|
||
PastIllness []string `json:"past_illness"` // 既往病史
|
||
}
|
||
|
||
// EMRResponse 病历生成响应
|
||
type EMRResponse struct {
|
||
SessionID string `json:"session_id"`
|
||
Draft string `json:"draft"` // 病历草稿(自然语言)
|
||
Structured string `json:"structured"` // 结构化 JSON
|
||
Issues []string `json:"issues"` // 质控问题
|
||
Status string `json:"status"` // success / need_revision
|
||
}
|
||
|
||
// NewEMRGenerator 创建病历生成 Agent
|
||
//
|
||
// 参数:
|
||
// runner - Agent 引擎(提供模型路由、工具、会话管理)
|
||
// scene - 场景名(对应 config.yaml 中 routes 的 key,为空则用默认)
|
||
func NewEMRGenerator(runner *Runner, scene string) *EMRGenerator {
|
||
if scene == "" {
|
||
scene = "emr-generator" // 默认场景名
|
||
}
|
||
return &EMRGenerator{
|
||
runner: runner,
|
||
scene: scene,
|
||
}
|
||
}
|
||
|
||
// Generate 执行病历生成(完整 Agent 生命周期)
|
||
func (g *EMRGenerator) Generate(ctx context.Context, req *EMRRequest) (*EMRResponse, error) {
|
||
// ===== ① 感知阶段 =====
|
||
// 创建会话,绑定场景名(Runner 会根据场景路由到对应模型)
|
||
session := g.runner.CreateSession(req.PatientID, g.scene)
|
||
log.Printf("[病历Agent] 开始 | 患者:%s 会话:%s 场景:%s",
|
||
req.PatientID, session.ID, g.scene)
|
||
|
||
// 注入系统提示词(约束 LLM 行为)
|
||
systemPrompt := `你是一位资深的中医主治医师,擅长书写规范化的中医门诊病历。
|
||
请严格按照以下结构输出病历:
|
||
1. 主诉(Chief Complaint):简洁概括主要症状+持续时间
|
||
2. 现病史(History of Present Illness):按时间线描述病情演变
|
||
3. 既往史(Past History):既往疾病、手术、过敏
|
||
4. 舌象(Tongue):舌质、舌苔描述
|
||
5. 脉象(Pulse):脉象特征
|
||
6. 辨证分析(Pattern Differentiation):八纲辨证+脏腑辨证
|
||
7. 西医诊断(Western Diagnosis)
|
||
8. 中医诊断(TCM Diagnosis):证型
|
||
|
||
要求:术语规范,使用标准中医术语,不遗漏关键信息。`
|
||
|
||
session.History = append(session.History, Message{
|
||
Role: "system", Content: systemPrompt, Timestamp: 0,
|
||
})
|
||
|
||
// 构造用户输入
|
||
userInput := fmt.Sprintf(`患者主诉:%s
|
||
|
||
患者补充病史:%s
|
||
|
||
过敏史:%v
|
||
既往病史:%v
|
||
|
||
请生成完整中医病历。`, req.ChiefComplaint, req.HistoryNotes, req.Allergies, req.PastIllness)
|
||
|
||
// ===== ②~⑤ Agent 推理循环 =====
|
||
// Runner.Run 内部会:
|
||
// 1. 通过 ModelRouter 获取当前场景的 LLM
|
||
// 2. LLM 自主决定调用哪些工具(MaxKB 检索、HIS 查询等)
|
||
// 3. 工具结果喂回 LLM,循环直到产出最终答案
|
||
result, err := g.runner.Run(ctx, session.ID, userInput)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("[病历Agent] 执行失败: %w", err)
|
||
}
|
||
|
||
// ===== ⑥ 规则引擎质控(独立于 LLM 的硬校验) =====
|
||
issues := checkEMRQuality(result)
|
||
|
||
resp := &EMRResponse{
|
||
SessionID: session.ID,
|
||
Draft: result,
|
||
Issues: issues,
|
||
}
|
||
|
||
// ===== ⑦ 反思阶段 =====
|
||
if len(issues) > 0 {
|
||
// 有问题 → 让 Agent 基于反馈修正
|
||
log.Printf("[病历Agent] 质控发现问题,进入反思修正 | 问题:%v", issues)
|
||
revisionInput := fmt.Sprintf("请修正以下病历中的问题:\n%s\n\n原病历:\n%s",
|
||
strings.Join(issues, "\n"), result)
|
||
revised, err := g.runner.Run(ctx, session.ID, revisionInput)
|
||
if err == nil {
|
||
resp.Draft = revised
|
||
resp.Issues = checkEMRQuality(revised)
|
||
}
|
||
resp.Status = "need_revision"
|
||
} else {
|
||
resp.Status = "success"
|
||
}
|
||
|
||
// 提取结构化字段
|
||
resp.Structured = extractStructuredEMR(resp.Draft)
|
||
|
||
log.Printf("[病历Agent] 完成 | 会话:%s 状态:%s 问题数:%d",
|
||
session.ID, resp.Status, len(resp.Issues))
|
||
return resp, nil
|
||
}
|
||
|
||
// ========================================================================
|
||
// 内部辅助函数
|
||
// ========================================================================
|
||
|
||
// checkEMRQuality 病历质控检查
|
||
//
|
||
// 注意:这里只做简单的文本检查。
|
||
// 生产环境应调用 rule 包的 EMRQualityChecker(更完整的规则集)。
|
||
func checkEMRQuality(emrText string) []string {
|
||
issues := make([]string, 0)
|
||
|
||
requiredFields := []string{"主诉", "舌", "脉", "诊断"}
|
||
for _, field := range requiredFields {
|
||
if !strings.Contains(emrText, field) {
|
||
issues = append(issues, fmt.Sprintf("【缺失】病历缺少「%s」字段", field))
|
||
}
|
||
}
|
||
|
||
// 口语术语检查
|
||
slangMap := map[string]string{
|
||
"胃不舒服": "胃脘不适", "头晕": "眩晕", "心慌": "心悸",
|
||
"睡不着": "失眠", "吃不下": "纳差", "拉肚子": "泄泻",
|
||
}
|
||
for slang, standard := range slangMap {
|
||
if strings.Contains(emrText, slang) {
|
||
issues = append(issues, fmt.Sprintf("【术语】建议将「%s」改为「%s」", slang, standard))
|
||
}
|
||
}
|
||
|
||
return issues
|
||
}
|
||
|
||
// extractStructuredEMR 从自然语言病历中提取结构化字段
|
||
func extractStructuredEMR(draft string) string {
|
||
structured := map[string]string{
|
||
"chief_complaint": extractField(draft, "主诉"),
|
||
"tongue": extractField(draft, "舌象"),
|
||
"pulse": extractField(draft, "脉象"),
|
||
"diagnosis_tcm": extractField(draft, "中医诊断"),
|
||
"diagnosis_wm": extractField(draft, "西医诊断"),
|
||
}
|
||
b, _ := json.Marshal(structured)
|
||
return string(b)
|
||
}
|
||
|
||
// extractField 简单提取字段值(生产环境建议用 LLM + NER)
|
||
func extractField(text, fieldName string) string {
|
||
lines := strings.Split(text, "\n")
|
||
for _, line := range lines {
|
||
if strings.Contains(line, fieldName) {
|
||
parts := strings.SplitN(line, ":", 2)
|
||
if len(parts) == 2 {
|
||
return strings.TrimSpace(parts[1])
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|