修复deepseek无法对话的bug
This commit is contained in:
@@ -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 优先级最高)
|
||||
|
||||
Reference in New Issue
Block a user