diff --git a/internal/service/enhancer.go b/internal/service/enhancer.go index 02fb996..4ecf30f 100644 --- a/internal/service/enhancer.go +++ b/internal/service/enhancer.go @@ -72,10 +72,11 @@ type resolveMeta struct { // NewEnhancerService 构造函数 // // 参数: -// maxkb - MaxKB 客户端(kb 关闭或 source=local 时可传 nil) -// router - 模型路由器(必填) -// fallback - 降级链(可空) -// cfg - 全局配置 +// +// maxkb - MaxKB 客户端(kb 关闭或 source=local 时可传 nil) +// router - 模型路由器(必填) +// fallback - 降级链(可空) +// cfg - 全局配置 func NewEnhancerService(maxkb *tool.MaxKBClient, router *llm.ModelRouter, fallback *llm.FallbackChain, cfg *config.Config) *EnhancerService { return &EnhancerService{ maxkb: maxkb, @@ -150,9 +151,20 @@ type EnhanceStep struct { FinishedAt int64 `json:"finished_at"` Detail string `json:"detail,omitempty"` // 备注(如命中文档数 / 失败原因) Status int `json:"status"` // 0进行中 1成功 2失败(与 xk_ai_generation.status 一致) + Hits []KbHit `json:"hits,omitempty"` // kb_retrieval 命中条目(PHP 写入 usage_json.hits,详情页展示) lastMessage *types.Message `json:"-"` // ReactLoop 内部用:缓存 LLM 返回的 Message(不入 JSON) } +// KbHit 知识库/金方检索命中(给生成记录详情展示出处与原文) +type KbHit struct { + Kind string `json:"kind"` // doc=知识库文档 formula=金方 + Title string `json:"title"` // 文档标题 / 方名 + Library string `json:"library,omitempty"` // 来源库名 + Score float64 `json:"score,omitempty"` // 检索分(可选) + Content string `json:"content"` // 注入 prompt 的正文摘要 + DocID uint `json:"doc_id,omitempty"` // 本地文档 id(排障) +} + // EnhanceSlotResult 多份生成时每个槽位的结果(成功填 Content,失败填 Error) type EnhanceSlotResult struct { Index int `json:"index"` // 0-based 槽位 @@ -242,6 +254,8 @@ func (s *EnhancerService) doEnhance(ctx context.Context, req *EnhanceRequest) (* // ===== 步骤 1:知识库检索(可选) ===== // 根据 agentcfg.KB.Source 分流到 MaxKB 或本地知识库; // 处方场景额外检索金方库(参考方剂),与药材知识分块注入 + // 跳过时也写 kb_retrieval 步骤:PHP 落 xk_ai_generation_step 后, + // 后台生成记录时间轴才能看见「没检索」而不是误以为步骤丢失 if req.KBEnabled && strings.TrimSpace(req.Context) != "" { step, kbDocs, kbFormulas, kerr := s.performKBRetrieval(ctx, req) if step != nil { @@ -251,6 +265,12 @@ func (s *EnhancerService) doEnhance(ctx context.Context, req *EnhanceRequest) (* messages = injectKBContext(messages, kbDocs, kbFormulas) } } + } else { + skipReason := "未检索:场景开关关闭" + if req.KBEnabled { + skipReason = "未检索:检索词为空" + } + resp.Steps = append(resp.Steps, skippedKBRetrievalStep(skipReason)) } // ===== 步骤 2:解析 LLM 客户端 ===== @@ -855,6 +875,23 @@ const ( kbMinScoreRatio = 0.15 // 相对分数阈值:低于最高分 15% 的命中视为弱相关噪声 ) +// skippedKBRetrievalStep 构造「未真正检索」的 kb_retrieval 步骤 +// +// 为什么必须写:PHP 只把 Go 返回的 steps 落入 xk_ai_generation_step, +// 静默跳过时后台生成记录时间轴上看不到「检索知识库」,会被当成步骤丢失。 +// status=1:跳过是预期行为,不是失败;detail 写清原因供运营排查。 +func skippedKBRetrievalStep(detail string) EnhanceStep { + now := time.Now().Unix() + return EnhanceStep{ + StepType: "kb_retrieval", + StartedAt: now, + FinishedAt: now, + DurationMs: 0, + Status: 1, + Detail: detail, + } +} + // performKBRetrieval 执行知识库检索(按 agentcfg.KB.Source 分流) // // 返回值: @@ -864,10 +901,10 @@ const ( // - err:检索失败原因(仅用于调用方决定是否阻断;step 内已经记录) // // 分流策略: -// 1. agentcfg.KB.Source == "local" → 走 LocalKB(V1 默认,多库归并) -// 2. agentcfg.KB.Source == "maxkb" → 走 MaxKB(需 maxkb 客户端可用) -// 3. 选定路径失败时不阻断主流程,调用方仍用原始 messages 调 LLM -// 4. 金方检索独立于 KB source(直查 z_xk 库),失败只降级不影响文档检索 +// 1. agentcfg.KB.Source == "local" → 走 LocalKB(V1 默认,多库归并) +// 2. agentcfg.KB.Source == "maxkb" → 走 MaxKB(需 maxkb 客户端可用) +// 3. 选定路径失败时不阻断主流程,调用方仍用原始 messages 调 LLM +// 4. 金方检索独立于 KB source(直查 z_xk 库),失败只降级不影响文档检索 func (s *EnhancerService) performKBRetrieval(ctx context.Context, req *EnhanceRequest) (*EnhanceStep, []string, []string, error) { kbCfg := agentcfg.GetKBConfig() topK := req.TopK @@ -893,28 +930,33 @@ func (s *EnhancerService) performKBRetrieval(ctx context.Context, req *EnhanceRe } var docs []string + var docHits []KbHit var docsDetail string var err error source := kbCfg.Source switch source { case "local": - docs, docsDetail, err = s.retrieveFromLocalMulti(ctx, query, topK, kbCfg.SearchMode) + docHits, docsDetail, err = s.retrieveFromLocalMulti(ctx, query, topK, kbCfg.SearchMode) + docs = formatKbDocBlocks(docHits) case "maxkb": docs, err = s.retrieveFromMaxKB(ctx, query, topK) docsDetail = fmt.Sprintf("命中 %d 条", len(docs)) + docHits = kbHitsFromPlainDocs(docs, "maxkb") default: // 未知 source 回落到本地 source = "local(fallback)" - docs, docsDetail, err = s.retrieveFromLocalMulti(ctx, query, topK, kbCfg.SearchMode) + docHits, docsDetail, err = s.retrieveFromLocalMulti(ctx, query, topK, kbCfg.SearchMode) + docs = formatKbDocBlocks(docHits) } // 金方库检索(P1):处方场景第二检索源,动态读取平台维护的方剂 // 与药材知识独立:金方失败不影响文档结果(只补一条 detail 说明) var formulas []string + var formulaHits []KbHit if req.Scene == "prescription" { var ferr error - formulas, ferr = retrieveGoldenFormulas(query, kbFormulaTopK) + formulas, formulaHits, ferr = retrieveGoldenFormulas(query, kbFormulaTopK) if ferr != nil { log.Printf("[Enhancer] 金方检索失败(不阻断): %v", ferr) detailParts = append(detailParts, "方剂检索失败:"+ferr.Error()) @@ -941,6 +983,9 @@ func (s *EnhancerService) performKBRetrieval(ctx context.Context, req *EnhanceRe detail += "|" + strings.Join(detailParts, "|") } step.Detail = detail + if len(docHits)+len(formulaHits) > 0 { + step.Hits = append(docHits, formulaHits...) + } return step, docs, formulas, nil } @@ -956,14 +1001,14 @@ type localKBHit struct { // retrieveFromLocalMulti 本地知识库多库归并检索(P1 主路径) // // 与旧版(只查第一个库)的差异: -// 1. 遍历全部 status=1 的库分别检索,按分数归并 -// 2. title 命中加权:查询词包含文档标题(如药名/方名直接出现在证候上下文里) -// 的命中 ×1.3——标题级命中的相关性远高于正文碰撞 -// 3. 相对分数阈值:低于最高分 15% 的命中丢弃(过滤 ngram 2-gram 碰撞噪声) -// 4. 按 doc 去重:同一文档多个分段命中时只保留最高分那段,避免同一味药占满 TopK +// 1. 遍历全部 status=1 的库分别检索,按分数归并 +// 2. title 命中加权:查询词包含文档标题(如药名/方名直接出现在证候上下文里) +// 的命中 ×1.3——标题级命中的相关性远高于正文碰撞 +// 3. 相对分数阈值:低于最高分 15% 的命中丢弃(过滤 ngram 2-gram 碰撞噪声) +// 4. 按 doc 去重:同一文档多个分段命中时只保留最高分那段,避免同一味药占满 TopK // -// 返回:格式化好的知识块列表 + 命中摘要(step detail 用) -func (s *EnhancerService) retrieveFromLocalMulti(ctx context.Context, query string, topK int, mode string) ([]string, string, error) { +// 返回:结构化命中(详情页展示)+ 命中摘要(step detail 用) +func (s *EnhancerService) retrieveFromLocalMulti(ctx context.Context, query string, topK int, mode string) ([]KbHit, string, error) { if s.localSearch == nil { return nil, "", fmt.Errorf("本地知识库检索器未初始化") } @@ -1009,7 +1054,7 @@ func (s *EnhancerService) retrieveFromLocalMulti(ctx context.Context, query stri } } if len(all) == 0 { - return []string{}, "命中 0 条", nil + return []KbHit{}, "命中 0 条", nil } // 按 doc 去重:保留每个文档的最高分分段 bestByDoc := make(map[uint]localKBHit, len(all)) @@ -1034,24 +1079,27 @@ func (s *EnhancerService) retrieveFromLocalMulti(ctx context.Context, query stri if len(filtered) > topK { filtered = filtered[:topK] } - // 格式化 + 总长度预算控制 - out := make([]string, 0, len(filtered)) + // 总长度预算:与注入 prompt 的截断一致,详情展示同一份正文 + out := make([]KbHit, 0, len(filtered)) usedRunes := 0 for _, h := range filtered { content := truncateRunesStr(h.content, kbDocMaxRunes) - var block string - if h.title == "" { - block = content - } else { - // 带来源标题与库名:LLM 引用时可指明出处,排障时可定位语料 - block = fmt.Sprintf("【%s|来源:%s】\n%s", h.title, h.libName, content) + blockRunes := len([]rune(content)) + if h.title != "" { + blockRunes += len([]rune(h.title)) + len([]rune(h.libName)) + 12 } - blockRunes := len([]rune(block)) if usedRunes+blockRunes > kbDocsTotalRunes && len(out) > 0 { - break // 预算用尽:保留已有条目(至少注入 1 条) + break } usedRunes += blockRunes - out = append(out, block) + out = append(out, KbHit{ + Kind: "doc", + Title: h.title, + Library: h.libName, + Score: h.score, + Content: content, + DocID: h.docID, + }) } desc := fmt.Sprintf("命中 %d 条(%s)", len(out), strings.Join(libHitDesc, ", ")) return out, desc, nil @@ -1062,15 +1110,16 @@ func (s *EnhancerService) retrieveFromLocalMulti(ctx context.Context, query stri // 两段式与 KB 检索一致:BOOLEAN 精确 → 零命中回退 NATURAL LANGUAGE。 // 输出格式固定三行:方名 / 组成(解析 drugs_json)/ 主治(优先译文), // 提示词侧告知 LLM"可在参考方基础上化裁",不强制照搬 -func retrieveGoldenFormulas(query string, topK int) ([]string, error) { +func retrieveGoldenFormulas(query string, topK int) ([]string, []KbHit, error) { hits, err := dao.GoldenFormulaSearch(query, topK, false) if (err != nil || len(hits) == 0) && len([]rune(query)) >= 3 { hits, err = dao.GoldenFormulaSearch(query, topK, true) } if err != nil { - return nil, err + return nil, nil, err } out := make([]string, 0, len(hits)) + kbHits := make([]KbHit, 0, len(hits)) for _, h := range hits { drugs := formatFormulaDrugs(h.DrugsJSON) if drugs == "" { @@ -1082,9 +1131,18 @@ func retrieveGoldenFormulas(query string, topK int) ([]string, error) { indication = strings.TrimSpace(h.IndicationOriginal) } block := fmt.Sprintf("〔%s〕组成:%s。主治:%s", h.Name, drugs, indication) - out = append(out, truncateRunesStr(block, kbFormulaMaxRunes)) + block = truncateRunesStr(block, kbFormulaMaxRunes) + out = append(out, block) + kbHits = append(kbHits, KbHit{ + Kind: "formula", + Title: h.Name, + Library: "金方库", + Score: h.Score, + Content: block, + DocID: h.ID, + }) } - return out, nil + return out, kbHits, nil } // formatFormulaDrugs 把金方 drugs_json 解析成"桂枝46.9g、芍药46.9g"形式 @@ -1144,6 +1202,37 @@ func (s *EnhancerService) retrieveFromMaxKB(ctx context.Context, query string, t return s.maxkb.Search(ctx, query, topK) } +// formatKbDocBlocks 把结构化命中格式化成注入 prompt 的知识块 +func formatKbDocBlocks(hits []KbHit) []string { + out := make([]string, 0, len(hits)) + for _, h := range hits { + if h.Title == "" { + out = append(out, h.Content) + continue + } + out = append(out, fmt.Sprintf("【%s|来源:%s】\n%s", h.Title, h.Library, h.Content)) + } + return out +} + +// kbHitsFromPlainDocs MaxKB 等只返回纯文本时,包一层结构化命中供详情展示 +func kbHitsFromPlainDocs(docs []string, library string) []KbHit { + out := make([]KbHit, 0, len(docs)) + for i, d := range docs { + text := strings.TrimSpace(d) + if text == "" { + continue + } + out = append(out, KbHit{ + Kind: "doc", + Title: fmt.Sprintf("参考 %d", i+1), + Library: library, + Content: truncateRunesStr(text, kbDocMaxRunes), + }) + } + return out +} + // listActiveLibraries 取所有启用的本地知识库(按 id 升序) // // 单独抽出来避免在 retrieveFromLocal 里直接 import dao(service 包不直接依赖 dao) @@ -1161,6 +1250,7 @@ func listActiveLibraries() ([]libraryView, error) { return out, nil } +// injectKBContext 把检索到的知识块作为独立 system 消息注入 // // 插入位置策略: // - 若 messages[0] 是 system → 在其后插入(保持业务 system 优先级最高)