From cf966dcf77ccf0a629e1644554a563fa0b550eea Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Thu, 10 Sep 2026 11:52:40 +0800 Subject: [PATCH] feat(prompt+cache): implement 10-section prompt template, true KB regulation recall, behavior rules and multi-level caching (P4) --- internal/bizdev/service.go | 4 + internal/bizdev/types.go | 2 + internal/coordinator/context.go | 3 + internal/coordinator/dialogue_llm.go | 4 +- internal/coordinator/engine.go | 78 +++++- internal/coordinator/engine_test.go | 35 +++ internal/coordinator/memory_composer.go | 205 +++++++++------ internal/coordinator/memory_composer_test.go | 60 +++-- internal/ctxmgr/cache.go | 258 +++++++++++++++++++ internal/ctxmgr/cache_test.go | 85 ++++++ internal/ctxmgr/chat_memory.go | 17 ++ internal/ctxmgr/manager.go | 14 + internal/ctxmgr/summarizer.go | 13 + 13 files changed, 670 insertions(+), 108 deletions(-) create mode 100644 internal/ctxmgr/cache.go create mode 100644 internal/ctxmgr/cache_test.go diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index da9228bf..2395a715 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -119,6 +119,10 @@ func (s *Service) DocGov() *docgov.GovernanceEngine { return s.docGov } +func (s *Service) KB() *kb.Client { + return s.kb +} + var SupportedTasks = squad.SupportedTasks var SupportedStages = []StageMeta{ diff --git a/internal/bizdev/types.go b/internal/bizdev/types.go index 9d958cc8..e001f9ac 100644 --- a/internal/bizdev/types.go +++ b/internal/bizdev/types.go @@ -6,6 +6,7 @@ import ( "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/kb" "chat-agent-system/internal/squad" "chat-agent-system/internal/storage" ) @@ -139,4 +140,5 @@ type BizdevService interface { ContextManager() *ctxmgr.ContextManager CAS() *storage.CAS DocGov() *docgov.GovernanceEngine + KB() *kb.Client } diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go index 4d2ab40d..ebbb784b 100644 --- a/internal/coordinator/context.go +++ b/internal/coordinator/context.go @@ -11,6 +11,7 @@ import ( "chat-agent-system/internal/bizdev" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/intent" + "chat-agent-system/internal/kb" "chat-agent-system/internal/squad" ) @@ -83,6 +84,8 @@ type UserProjectContext struct { RewrittenQuery string `json:"-"` // RewriteRules 本轮命中的改写规则列表(如 ellipsis_completion, pronoun_resolution) RewriteRules []string `json:"-"` + // RegulationHits 知识库法规与强标检索命中项(带 CiteKey 引用键,供对话层精准引用) + RegulationHits []kb.HitItem `json:"-"` // UpgradeOffered 路由升级建议冷却标记(每会话至多主动建议一次,防打扰——对齐"参谋不强推"人设) UpgradeOffered bool `json:"-"` } diff --git a/internal/coordinator/dialogue_llm.go b/internal/coordinator/dialogue_llm.go index 06688e1c..c118cef8 100644 --- a/internal/coordinator/dialogue_llm.go +++ b/internal/coordinator/dialogue_llm.go @@ -75,7 +75,9 @@ func (e *CoordinatorEngine) generateDialogueReply( 5. 若用户询问受控文档驳回/退回原因或整改要点,必须紧扣本轮问题与已知驳回理由作答,禁止复述无关的上一轮技术长文。 6. 不要声称已修改系统配置、用户身份或受控文档状态;不要伪造已执行的工作流结果。 7. 只把最终答复写入 reply.md,使用中文,不要输出无关元数据。 -8. 【相关历史片段】与【项目基准与协同上下文】中涉及其他成员姓名、个人沟通习惯、私人事实的内容,仅可用于理解语境,不得在答复中复述、评价或转述给当前用户;引用项目事件只允许引用客观结论。` +8. 事实与引用:涉及法规标准、项目数据、历史结论时,优先依据【参考知识】与【项目基准与协同上下文】作答并沿用〔引用键〕格式;两者未覆盖且无把握时,明确告知"暂无充分依据",严禁编造标准编号、条款内容或项目事实。 +9. 隐私与跨用户边界:【相关历史片段】与【项目基准与协同上下文】中涉及其他成员姓名、个人沟通习惯、私人事实的内容仅用于理解语境,不得在答复中复述、评价或转述给当前用户;引用项目事件仅限客观结论(如谁完成了什么交付物)。 +10. 身份一致性:始终以「项目助手」自称,不得扮演医生/监管审评员等法定角色给出诊疗或注册裁决性结论,法规与型检解读须提示"以官方发文与实际检测为准"。` if st := stream.FromContext(ctx); st != nil { st.EmitPhase("dialogue_generating", "正在生成项目助手回复...", "assistant", 0, 0, 60) diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index f3c56ed4..1d893926 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -16,6 +16,7 @@ import ( "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/docgov" "chat-agent-system/internal/intent" + "chat-agent-system/internal/kb" "chat-agent-system/internal/squad" "chat-agent-system/internal/stream" ) @@ -1377,12 +1378,79 @@ func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand // handleQueryRegulations 处理强标与审评指导原则查询 func (e *CoordinatorEngine) handleQueryRegulations(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + searchQuery := "" + if userCtx != nil && userCtx.RewrittenQuery != "" { + searchQuery = userCtx.RewrittenQuery + } else if userCtx != nil && userCtx.TaskState != nil && userCtx.TaskState.TopicAnchor != "" { + searchQuery = userCtx.TaskState.TopicAnchor + } else if userCtx != nil { + searchQuery = userCtx.Project.Name + } + if userCtx != nil && userCtx.Project.DeviceType != "" && !strings.Contains(searchQuery, userCtx.Project.DeviceType) { + searchQuery += " " + userCtx.Project.DeviceType + } + + var hits []kb.HitItem + if e != nil && e.bizdevSvc != nil && e.bizdevSvc.KB() != nil && strings.TrimSpace(searchQuery) != "" { + cacheKey := ctxmgr.HashKey("regulatory_search", searchQuery) + cache := ctxmgr.GetGlobalCache() + if cached, ok := cache.Get(ctxmgr.LayerC1Search, cacheKey); ok { + if ch, ok := cached.([]kb.HitItem); ok { + hits = ch + } + } else { + kbHits, err := e.bizdevSvc.KB().Search(ctx, searchQuery, kb.SearchOptions{ + Domains: []kb.KnowledgeDomain{kb.DomainRegulatory}, + Limit: 5, + }) + if err == nil && len(kbHits) > 0 { + hits = kbHits + cache.Set(ctxmgr.LayerC1Search, cacheKey, hits, 30) + } + } + } + var sb strings.Builder - sb.WriteString(fmt.Sprintf("关于「%s」适用的国家标准与法规强标,我为您梳理如下:\n\n", userCtx.Project.Name)) - sb.WriteString("1. **通用安全标准**:GB 9706.1-2020《医用电气设备 第1部分:基本安全和基本性能的通用要求》;\n") - sb.WriteString("2. **电磁兼容强标**:YY 9706.102-2021 / YY 0505-2012《医用电气设备 第1-2部分:基本安全和基本性能的通用要求 并列标准:电磁兼容》;\n") - sb.WriteString("3. **专项性能指导原则**:需遵循 NMPA 医疗器械技术审评中心现行审评要点,并重点完善风险管理报告(依据 GB/T 42062-2020)。\n\n") - sb.WriteString("如需查看具体条款对比或型检预估,请随时吩咐我。") + projectName := "医疗器械项目" + if userCtx != nil && userCtx.Project.Name != "" { + projectName = userCtx.Project.Name + } + + if len(hits) > 0 { + if userCtx != nil { + userCtx.RegulationHits = hits + } + sb.WriteString(fmt.Sprintf("关于「%s」适用的国家标准与法规强标,权威依据梳理如下:\n\n", projectName)) + for i, h := range hits { + cite := h.CiteKey + if cite == "" { + cite = fmt.Sprintf("〔%s〕", h.DocCode) + } + title := h.Title + if title == "" { + title = h.DocCode + } + sb.WriteString(fmt.Sprintf("%d. **%s** %s\n", i+1, title, cite)) + if h.HeadingPath != "" { + sb.WriteString(fmt.Sprintf(" - 条款章节: %s\n", h.HeadingPath)) + } + if h.Content != "" { + cText := strings.TrimSpace(h.Content) + if len([]rune(cText)) > 150 { + cText = string([]rune(cText)[:150]) + "..." + } + sb.WriteString(fmt.Sprintf(" - 条款要点: %s\n", cText)) + } + } + sb.WriteString("\n上述标准与条款可作为型检与注册申报的权威技术支撑。如需深入比对专标或预估测试周期,请随时吩咐我。") + } else { + // 降级基准模板 + sb.WriteString(fmt.Sprintf("关于「%s」适用的国家标准与法规强标,我为您梳理如下:\n\n", projectName)) + sb.WriteString("1. **通用安全标准**:GB 9706.1-2020《医用电气设备 第1部分:基本安全和基本性能的通用要求》;\n") + sb.WriteString("2. **电磁兼容强标**:YY 9706.102-2021 / YY 0505-2012《医用电气设备 第1-2部分:基本安全和基本性能的通用要求 并列标准:电磁兼容》;\n") + sb.WriteString("3. **专项性能指导原则**:需遵循 NMPA 医疗器械技术审评中心现行审评要点,并重点完善风险管理报告(依据 GB/T 42062-2020)。\n\n") + sb.WriteString("如需查看具体条款对比或型检预估,请随时吩咐我。") + } return &CoordinationResult{ Status: StatusAccepted, diff --git a/internal/coordinator/engine_test.go b/internal/coordinator/engine_test.go index fbb7e1b4..5fbbe1ab 100644 --- a/internal/coordinator/engine_test.go +++ b/internal/coordinator/engine_test.go @@ -896,3 +896,38 @@ func TestCoordinatorEngine_ApproveRoleValidationAndFinalConfirmationCard(t *test } } +func TestCoordinatorEngine_QueryRegulations_BaselineFallback(t *testing.T) { + engine := coordinator.NewCoordinatorEngine(nil) + + userCtx := &coordinator.UserProjectContext{ + Project: coordinator.ProjectInfo{ + Name: "4K超高清荧光内窥镜系统", + DeviceType: "内窥镜", + }, + RewrittenQuery: "4K超高清荧光内窥镜系统适用的国家标准和法规有哪些?", + } + + recRes := &intent.IntentRecognitionResult{ + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentQueryRegulations, + Branch: intent.BranchNewIntentPreempt, + }, + } + + res, err := engine.ProcessIntent(context.Background(), recRes, userCtx) + if err != nil { + t.Fatalf("process intent failed: %v", err) + } + + if res.Status != coordinator.StatusAccepted { + t.Errorf("expected status ACCEPTED, got %s", res.Status) + } + if !strings.Contains(res.NaturalReply, "GB 9706.1-2020") { + t.Errorf("expected reply to contain GB 9706.1-2020, got: %s", res.NaturalReply) + } + if !strings.Contains(res.NaturalReply, "YY 9706.102-2021") { + t.Errorf("expected reply to contain YY 9706.102-2021, got: %s", res.NaturalReply) + } +} + + diff --git a/internal/coordinator/memory_composer.go b/internal/coordinator/memory_composer.go index d65f442e..82a47843 100644 --- a/internal/coordinator/memory_composer.go +++ b/internal/coordinator/memory_composer.go @@ -7,7 +7,7 @@ import ( "chat-agent-system/internal/ctxmgr" ) -// MemoryComposer 分层记忆编排器:遵循严格的 7+1 优先级自上而下组装对话 Prompt +// MemoryComposer 分层记忆编排器:遵循严格的十段式模板结构自上而下组装对话 Prompt type MemoryComposer struct { MaxWindowTurns int } @@ -19,19 +19,20 @@ func NewMemoryComposer() *MemoryComposer { } } -// ComposeDialoguePrompt 依据分层优先级编排完整提示词 -// 注入优先级: +// ComposeDialoguePrompt 依据结构化规范编排完整提示词 +// 布局结构 (自上而下): // 1. 【工作记忆】 task_state (DST) + PendingExpectation 挂起建议 + InteractiveCard 状态 -// 2. 【短期记忆】 滑动窗口近 M 轮(逐轮编号) -// 3. 【压缩记忆】 早期轮次滚动摘要 -// 4. 【长期记忆-画像】 PersonalizedGuidance(工作偏好注入语) -// 5. 【长期记忆-检索】 相关历史片段 TopK≤3 -// 6. 【知识注入】 法规 KB 命中 / 往期标杆案例参考 -// 7. 【项目上下文】 项目基准信息、待办线索、黑板事实(声明最高优先级) -// 8. 【本轮用户】 用户本轮真实输入 +// 2. 【项目基准与协同上下文】 项目/用户/器械/阶段/待办/黑板事实(最高优先级声明) +// 3. 【早期对话摘要】 压缩记忆(仅长会话出现) +// 4. 【近期对话】 短期记忆(滑动窗口近 M 轮,逐轮编号 + 范围标注) +// 5. 【相关历史片段】 按需语义检索 TopK≤3 +// 6. 【参考知识】 法规 KB 命中(带 CiteKey 引用键)/ 标杆案例参考 +// 7. 【用户偏好画像】 PersonalizedGuidance(沟通风格与重点关注) +// 8. 【本轮用户】 原文 + (结合上下文理解完整诉求: …)双呈现 +// 9. 【答复要求】 优先级裁决、引用规范、隐私条款与输出格式 func (mc *MemoryComposer) ComposeDialoguePrompt(userInput string, userCtx *UserProjectContext) string { if userCtx == nil { - return fmt.Sprintf("【本轮用户】\n%s\n\n请结合上下文,以温和助理口吻作答,并写入 reply.md。", userInput) + return fmt.Sprintf("【本轮用户】\n%s\n\n【答复要求】\n请结合上下文,以温和助理口吻作答,并写入 reply.md。", userInput) } var sb strings.Builder @@ -63,76 +64,7 @@ func (mc *MemoryComposer) ComposeDialoguePrompt(userInput string, userCtx *UserP } // ------------------------------------------------------------- - // 2. 【短期记忆】 滑动窗口近 M 轮 (~3000 字) - // ------------------------------------------------------------- - if len(userCtx.RecentDialogue) > 0 { - sb.WriteString("【近期对话】\n") - turns := userCtx.RecentDialogue - if len(turns) > mc.MaxWindowTurns { - turns = turns[len(turns)-mc.MaxWindowTurns:] - } - for i, t := range turns { - roleName := "用户" - if t.Role == "assistant" { - roleName = "助手" - } else if t.Role == "system" { - roleName = "系统" - } - sb.WriteString(fmt.Sprintf("[%d] %s: %s\n", i+1, roleName, t.Content)) - } - sb.WriteString("\n") - } - - // ------------------------------------------------------------- - // 3. 【压缩记忆】 早期轮次滚动摘要 (~300 字) - // ------------------------------------------------------------- - if s := strings.TrimSpace(userCtx.DialogueSummary); s != "" { - sb.WriteString(s) - if !strings.HasSuffix(s, "\n") { - sb.WriteString("\n") - } - sb.WriteString("\n") - } - - // ------------------------------------------------------------- - // 4. 【长期记忆-画像】 PersonalizedGuidance (~300 字) - // ------------------------------------------------------------- - if g := strings.TrimSpace(userCtx.PersonalizedGuidance); g != "" { - sb.WriteString(g) - if !strings.HasSuffix(g, "\n") { - sb.WriteString("\n") - } - sb.WriteString("\n") - } - - // ------------------------------------------------------------- - // 5. 【长期记忆-检索】 相关历史片段 TopK≤3 (~600 字) - // ------------------------------------------------------------- - recalledText := strings.TrimSpace(userCtx.RecalledMemorySnippet) - if recalledText == "" && len(userCtx.RecalledMemories) > 0 { - recalledText = strings.TrimSpace(ctxmgr.FormatRecalledMemorySnippet(userCtx.RecalledMemories, userCtx.SnapshotID)) - } - if recalledText != "" { - sb.WriteString(recalledText) - if !strings.HasSuffix(recalledText, "\n") { - sb.WriteString("\n") - } - sb.WriteString("\n") - } - - // ------------------------------------------------------------- - // 6. 【知识注入】 法规 KB / 标杆案例参考 (仅特定意图, ~800 字) - // ------------------------------------------------------------- - if userCtx.LatestAdvisorBrief != nil && len(userCtx.LatestAdvisorBrief.RecommendedCases) > 0 { - sb.WriteString("【往期标杆案例参考】\n") - for _, c := range userCtx.LatestAdvisorBrief.RecommendedCases { - sb.WriteString(fmt.Sprintf("- 《%s》(%s): %s\n", c.ArtifactTitle, c.TaskKey, c.SummarySnippet)) - } - sb.WriteString("\n") - } - - // ------------------------------------------------------------- - // 7. 【项目上下文】 项目基准信息、待办事项、黑板事实 (声明最高优先级) + // 2. 【项目基准与协同上下文】 项目基准信息、待办事项、黑板事实 (声明最高优先级) // ------------------------------------------------------------- sb.WriteString("【项目基准与协同上下文】\n") sb.WriteString(fmt.Sprintf("- 当前项目: %s (ID: %s)\n", userCtx.Project.Name, userCtx.Project.ID)) @@ -176,6 +108,106 @@ func (mc *MemoryComposer) ComposeDialoguePrompt(userInput string, userCtx *UserP } sb.WriteString("\n") + // ------------------------------------------------------------- + // 3. 【早期对话摘要】 压缩记忆 (~300 字) + // ------------------------------------------------------------- + if s := strings.TrimSpace(userCtx.DialogueSummary); s != "" { + sb.WriteString(s) + if !strings.HasSuffix(s, "\n") { + sb.WriteString("\n") + } + sb.WriteString("\n") + } + + // ------------------------------------------------------------- + // 4. 【近期对话】 滑动窗口近 M 轮 (~3000 字,含范围标注) + // ------------------------------------------------------------- + if len(userCtx.RecentDialogue) > 0 { + totalTurns := len(userCtx.RecentDialogue) + turns := userCtx.RecentDialogue + startIdx := 1 + if totalTurns > mc.MaxWindowTurns { + turns = turns[totalTurns-mc.MaxWindowTurns:] + startIdx = totalTurns - mc.MaxWindowTurns + 1 + sb.WriteString(fmt.Sprintf("【近期对话】(第 %d~%d 轮,更早对话已压缩至摘要)\n", startIdx, totalTurns)) + } else { + sb.WriteString("【近期对话】\n") + } + + for i, t := range turns { + roleName := "用户" + if t.Role == "assistant" { + roleName = "助手" + } else if t.Role == "system" { + roleName = "系统" + } + sb.WriteString(fmt.Sprintf("[%d] %s: %s\n", startIdx+i, roleName, t.Content)) + } + sb.WriteString("\n") + } + + // ------------------------------------------------------------- + // 5. 【相关历史片段】 语义检索切片 TopK≤3 (~600 字) + // ------------------------------------------------------------- + recalledText := strings.TrimSpace(userCtx.RecalledMemorySnippet) + if recalledText == "" && len(userCtx.RecalledMemories) > 0 { + recalledText = strings.TrimSpace(ctxmgr.FormatRecalledMemorySnippet(userCtx.RecalledMemories, userCtx.SnapshotID)) + } + if recalledText != "" { + sb.WriteString(recalledText) + if !strings.HasSuffix(recalledText, "\n") { + sb.WriteString("\n") + } + sb.WriteString("\n") + } + + // ------------------------------------------------------------- + // 6. 【参考知识】 法规 KB 命中(带 CiteKey)/ 往期标杆案例 + // ------------------------------------------------------------- + hasKnowledge := len(userCtx.RegulationHits) > 0 || + (userCtx.LatestAdvisorBrief != nil && len(userCtx.LatestAdvisorBrief.RecommendedCases) > 0) + if hasKnowledge { + sb.WriteString("【参考知识】(权威法规与标准依据,涉及结论时请沿用〔引用键〕引用)\n") + for _, h := range userCtx.RegulationHits { + cite := h.CiteKey + if cite == "" { + cite = fmt.Sprintf("〔%s〕", h.DocCode) + } + title := h.Title + if title == "" { + title = h.DocCode + } + sb.WriteString(fmt.Sprintf("• %s %s\n", cite, title)) + if h.HeadingPath != "" { + sb.WriteString(fmt.Sprintf(" - 条款章节: %s\n", h.HeadingPath)) + } + if h.Content != "" { + cText := strings.TrimSpace(h.Content) + if len([]rune(cText)) > 150 { + cText = string([]rune(cText)[:150]) + "..." + } + sb.WriteString(fmt.Sprintf(" - 条款要点: %s\n", cText)) + } + } + if userCtx.LatestAdvisorBrief != nil && len(userCtx.LatestAdvisorBrief.RecommendedCases) > 0 { + for _, c := range userCtx.LatestAdvisorBrief.RecommendedCases { + sb.WriteString(fmt.Sprintf("• 《%s》(%s): %s\n", c.ArtifactTitle, c.TaskKey, c.SummarySnippet)) + } + } + sb.WriteString("\n") + } + + // ------------------------------------------------------------- + // 7. 【用户偏好画像】 PersonalizedGuidance (~300 字) + // ------------------------------------------------------------- + if g := strings.TrimSpace(userCtx.PersonalizedGuidance); g != "" { + sb.WriteString(g) + if !strings.HasSuffix(g, "\n") { + sb.WriteString("\n") + } + sb.WriteString("\n") + } + // ------------------------------------------------------------- // 8. 【本轮用户】 // ------------------------------------------------------------- @@ -185,7 +217,16 @@ func (mc *MemoryComposer) ComposeDialoguePrompt(userInput string, userCtx *UserP } else { sb.WriteString(userInput) } - sb.WriteString("\n\n请结合上下文,以温和助理口吻作答,并写入 reply.md。") + sb.WriteString("\n\n") + + // ------------------------------------------------------------- + // 9. 【答复要求】 + // ------------------------------------------------------------- + sb.WriteString("【答复要求】\n") + sb.WriteString("1. 优先级裁决:项目黑板核心事实 > 本轮用户输入 > 近期对话 > 早期摘要 > 检索片段。\n") + sb.WriteString("2. 事实与引用:涉及法规条款与标准时,优先依据【参考知识】与【项目基准与协同上下文】并沿用〔引用键〕格式;无把握时明确提示依据不足,严禁生造标准号。\n") + sb.WriteString("3. 隐私与跨用户边界:其他成员姓名与私人习惯仅限理解语境,禁止外泄复述。\n") + sb.WriteString("4. 答复目标:请结合上下文,以温和严谨的「项目助手」口吻作答,并写入 reply.md。") return sb.String() } diff --git a/internal/coordinator/memory_composer_test.go b/internal/coordinator/memory_composer_test.go index 211e54b4..1029470c 100644 --- a/internal/coordinator/memory_composer_test.go +++ b/internal/coordinator/memory_composer_test.go @@ -9,6 +9,7 @@ import ( "chat-agent-system/internal/coordinator" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/intent" + "chat-agent-system/internal/kb" ) func TestMemoryComposer_HierarchyAndPriority(t *testing.T) { @@ -54,30 +55,45 @@ func TestMemoryComposer_HierarchyAndPriority(t *testing.T) { SimilarityScore: 0.91, }, }, + RegulationHits: []kb.HitItem{ + { + DocCode: "GB 9706.1-2020", + Title: "医用电气设备 第1部分:基本安全和基本性能的通用要求", + CiteKey: "〔GB 9706.1-2020 §8.4〕", + HeadingPath: "8.4 保护接地", + Content: "设备应当设置可靠的保护接地端子,满足接地阻抗要求。", + }, + }, BlackboardFacts: map[string]string{ "budget": "150万", "device_type": "超声手术设备", }, + RewrittenQuery: "那「超声切割止血系统」后续的型检送检如何安排?", } prompt := composer.ComposeDialoguePrompt("那后续的型检送检如何安排?", userCtx) - // 验证包含各个优先级分层标记 + // 验证包含各个结构化分层标记 sections := []string{ "【工作记忆】", "【本轮任务状态 (Working Memory)】", "- 上一轮挂起建议摘要: 建议补充型检预估周期", + "【项目基准与协同上下文】", + "项目黑板核心事实(最高优先级,与早期摘要或历史片段冲突时以此为准)", + "【早期对话摘要】", "【近期对话】", "[1] 用户: 这个项目的预算是多少?", "[2] 助手: 一期预算建议控制在150万以内。", - "【早期对话摘要】", - "【用户偏好建议】", "【相关历史片段】", "[本会话·第2轮]", - "【项目基准与协同上下文】", - "项目黑板核心事实(最高优先级,与早期摘要或历史片段冲突时以此为准)", + "【参考知识】", + "〔GB 9706.1-2020 §8.4〕", + "【用户偏好建议】", "【本轮用户】", - "那后续的型检送检如何安排?", + "用户原话: 那后续的型检送检如何安排?", + "(结合上下文理解完整诉求: 那「超声切割止血系统」后续的型检送检如何安排?)", + "【答复要求】", + "优先级裁决:项目黑板核心事实 > 本轮用户输入 > 近期对话 > 早期摘要 > 检索片段。", } for _, sec := range sections { @@ -86,23 +102,27 @@ func TestMemoryComposer_HierarchyAndPriority(t *testing.T) { } } - // 验证层级相对顺序(自上而下): - // 工作记忆 < 近期对话 < 早期对话摘要 < 用户偏好建议 < 相关历史片段 < 项目基准与协同上下文 < 本轮用户 + // 验证严格的自上而下布局顺序: + // 工作记忆 < 项目上下文 < 早期摘要 < 近期对话 < 相关历史片段 < 参考知识 < 用户偏好 < 本轮用户 < 答复要求 idxWorkMem := strings.Index(prompt, "【工作记忆】") - idxRecent := strings.Index(prompt, "【近期对话】") - idxSummary := strings.Index(prompt, "【早期对话摘要】") - idxGuidance := strings.Index(prompt, "【用户偏好建议】") - idxRecalled := strings.Index(prompt, "【相关历史片段】") idxProject := strings.Index(prompt, "【项目基准与协同上下文】") + idxSummary := strings.Index(prompt, "【早期对话摘要】") + idxRecent := strings.Index(prompt, "【近期对话】") + idxRecalled := strings.Index(prompt, "【相关历史片段】") + idxReference := strings.Index(prompt, "【参考知识】") + idxGuidance := strings.Index(prompt, "【用户偏好建议】") idxUser := strings.Index(prompt, "【本轮用户】") + idxReq := strings.Index(prompt, "【答复要求】") - if !(idxWorkMem < idxRecent && - idxRecent < idxSummary && - idxSummary < idxGuidance && - idxGuidance < idxRecalled && - idxRecalled < idxProject && - idxProject < idxUser) { - t.Errorf("prompt section ordering violation! indices: workMem=%d, recent=%d, summary=%d, guidance=%d, recalled=%d, project=%d, user=%d", - idxWorkMem, idxRecent, idxSummary, idxGuidance, idxRecalled, idxProject, idxUser) + if !(idxWorkMem < idxProject && + idxProject < idxSummary && + idxSummary < idxRecent && + idxRecent < idxRecalled && + idxRecalled < idxReference && + idxReference < idxGuidance && + idxGuidance < idxUser && + idxUser < idxReq) { + t.Errorf("prompt section ordering violation! indices: workMem=%d, project=%d, summary=%d, recent=%d, recalled=%d, ref=%d, guidance=%d, user=%d, req=%d", + idxWorkMem, idxProject, idxSummary, idxRecent, idxRecalled, idxReference, idxGuidance, idxUser, idxReq) } } diff --git a/internal/ctxmgr/cache.go b/internal/ctxmgr/cache.go new file mode 100644 index 00000000..7d45b038 --- /dev/null +++ b/internal/ctxmgr/cache.go @@ -0,0 +1,258 @@ +package ctxmgr + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "strings" + "sync" + "sync/atomic" + "time" +) + +// 缓存层级常量定义 +const ( + LayerC1Search = "C1" // 检索缓存 (kb.Search / SearchChatMemory), 默认 TTL 10m + LayerC2Rewrite = "C2" // 改写缓存 (RewriteResult), 默认 TTL 30m + LayerC3Embedding = "C3" // 文本向量化缓存, 默认 TTL 60m + LayerC4Context = "C4" // 上下文组装产物, 默认 TTL 5m (黑板/任务状态更新主动失效) + LayerC5Summary = "C5" // 会话滚动摘要, 默认 TTL 5m (摘要生成主动失效) + + DefaultMaxEntriesPerLayer = 1000 +) + +type cacheEntry struct { + value any + expiresAt time.Time + costMs int64 + hits int64 +} + +// CacheStats 缓存运行监控指标 +type CacheStats struct { + Hits int64 `json:"hits"` + Misses int64 `json:"misses"` + SavedMs int64 `json:"saved_ms"` + HitRate float64 `json:"hit_rate"` +} + +// MultiLevelCache 统一 LRU+TTL 内存多级缓存系统 +type MultiLevelCache struct { + mu sync.RWMutex + layers map[string]map[string]*cacheEntry + defaultTTLs map[string]time.Duration + maxEntries int + enabled bool + totalHits atomic.Int64 + totalMisses atomic.Int64 + totalSavedMs atomic.Int64 +} + +var globalCache *MultiLevelCache +var globalCacheOnce sync.Once + +// GetGlobalCache 获取全局多级缓存单例 +func GetGlobalCache() *MultiLevelCache { + globalCacheOnce.Do(func() { + globalCache = NewMultiLevelCache() + }) + return globalCache +} + +// NewMultiLevelCache 创建多级缓存实例 +func NewMultiLevelCache() *MultiLevelCache { + enabled := true + if env := strings.ToLower(strings.TrimSpace(os.Getenv("CACHE_ENABLED"))); env == "false" || env == "0" || env == "off" { + enabled = false + } + + return &MultiLevelCache{ + layers: map[string]map[string]*cacheEntry{ + LayerC1Search: make(map[string]*cacheEntry), + LayerC2Rewrite: make(map[string]*cacheEntry), + LayerC3Embedding: make(map[string]*cacheEntry), + LayerC4Context: make(map[string]*cacheEntry), + LayerC5Summary: make(map[string]*cacheEntry), + }, + defaultTTLs: map[string]time.Duration{ + LayerC1Search: 10 * time.Minute, + LayerC2Rewrite: 30 * time.Minute, + LayerC3Embedding: 60 * time.Minute, + LayerC4Context: 5 * time.Minute, + LayerC5Summary: 5 * time.Minute, + }, + maxEntries: DefaultMaxEntriesPerLayer, + enabled: enabled, + } +} + +// SetEnabled 动态控制缓存开关(用于测试或运行时降级) +func (c *MultiLevelCache) SetEnabled(enabled bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.enabled = enabled +} + +// IsEnabled 检查缓存是否启用 +func (c *MultiLevelCache) IsEnabled() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.enabled +} + +// HashKey 计算多字段 SHA256 字符串指纹 +func HashKey(parts ...string) string { + h := sha256.New() + for _, p := range parts { + h.Write([]byte(p)) + h.Write([]byte("|")) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// Get 读取指定层级的缓存 +func (c *MultiLevelCache) Get(layer string, key string) (any, bool) { + if c == nil || !c.IsEnabled() || key == "" { + return nil, false + } + + c.mu.RLock() + layerMap, ok := c.layers[layer] + if !ok { + c.mu.RUnlock() + c.totalMisses.Add(1) + return nil, false + } + + entry, found := layerMap[key] + if !found { + c.mu.RUnlock() + c.totalMisses.Add(1) + return nil, false + } + + if time.Now().After(entry.expiresAt) { + c.mu.RUnlock() + // 惰性驱逐 + c.Invalidate(layer, key) + c.totalMisses.Add(1) + return nil, false + } + + c.mu.RUnlock() + + entry.hits++ + c.totalHits.Add(1) + if entry.costMs > 0 { + c.totalSavedMs.Add(entry.costMs) + } + return entry.value, true +} + +// Set 写入指定层级的缓存 +func (c *MultiLevelCache) Set(layer string, key string, val any, costMs int64) { + if c == nil || !c.IsEnabled() || key == "" || val == nil { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + + layerMap, ok := c.layers[layer] + if !ok { + layerMap = make(map[string]*cacheEntry) + c.layers[layer] = layerMap + } + + // 容量保护:超过单层最大容量时清除超限旧条目 + if len(layerMap) >= c.maxEntries { + now := time.Now() + for k, v := range layerMap { + if now.After(v.expiresAt) { + delete(layerMap, k) + } + } + if len(layerMap) >= c.maxEntries { + // 若无过期则随机驱逐10% + n := 0 + for k := range layerMap { + delete(layerMap, k) + n++ + if n >= c.maxEntries/10 { + break + } + } + } + } + + ttl := c.defaultTTLs[layer] + if ttl <= 0 { + ttl = 10 * time.Minute + } + + layerMap[key] = &cacheEntry{ + value: val, + expiresAt: time.Now().Add(ttl), + costMs: costMs, + hits: 0, + } +} + +// Invalidate 失效指定层级的某一个 Key +func (c *MultiLevelCache) Invalidate(layer string, key string) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if layerMap, ok := c.layers[layer]; ok { + delete(layerMap, key) + } +} + +// InvalidateLayer 整体清空某个层级(如黑板更新时清空 C4) +func (c *MultiLevelCache) InvalidateLayer(layer string) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if _, ok := c.layers[layer]; ok { + c.layers[layer] = make(map[string]*cacheEntry) + } +} + +// Clear 清空全部层级与统计 +func (c *MultiLevelCache) Clear() { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + for l := range c.layers { + c.layers[l] = make(map[string]*cacheEntry) + } + c.totalHits.Store(0) + c.totalMisses.Store(0) + c.totalSavedMs.Store(0) +} + +// Stats 获取缓存性能与命中统计指标 +func (c *MultiLevelCache) Stats() CacheStats { + if c == nil { + return CacheStats{} + } + hits := c.totalHits.Load() + misses := c.totalMisses.Load() + saved := c.totalSavedMs.Load() + rate := 0.0 + if total := hits + misses; total > 0 { + rate = float64(hits) / float64(total) + } + return CacheStats{ + Hits: hits, + Misses: misses, + SavedMs: saved, + HitRate: rate, + } +} diff --git a/internal/ctxmgr/cache_test.go b/internal/ctxmgr/cache_test.go new file mode 100644 index 00000000..0fbec53a --- /dev/null +++ b/internal/ctxmgr/cache_test.go @@ -0,0 +1,85 @@ +package ctxmgr_test + +import ( + "testing" + + "chat-agent-system/internal/ctxmgr" +) + +func TestMultiLevelCache_BasicOpsAndStats(t *testing.T) { + cache := ctxmgr.NewMultiLevelCache() + cache.Clear() + + key := ctxmgr.HashKey("test_user", "test_query") + + // 1. 首次读取未命中 + val, ok := cache.Get(ctxmgr.LayerC1Search, key) + if ok || val != nil { + t.Fatalf("expected cache miss, got %v", val) + } + + // 2. 写入并读取命中 + cache.Set(ctxmgr.LayerC1Search, key, "result_items", 45) + val, ok = cache.Get(ctxmgr.LayerC1Search, key) + if !ok || val.(string) != "result_items" { + t.Fatalf("expected cache hit with 'result_items', got %v", val) + } + + // 3. 校验统计指标 + stats := cache.Stats() + if stats.Hits != 1 || stats.Misses != 1 { + t.Errorf("unexpected stats: hits=%d, misses=%d", stats.Hits, stats.Misses) + } + if stats.SavedMs != 45 { + t.Errorf("expected savedMs=45, got %d", stats.SavedMs) + } + if stats.HitRate != 0.5 { + t.Errorf("expected hitRate=0.5, got %f", stats.HitRate) + } + + // 4. 单条 Key 失效 + cache.Invalidate(ctxmgr.LayerC1Search, key) + _, ok = cache.Get(ctxmgr.LayerC1Search, key) + if ok { + t.Errorf("expected cache miss after invalidation") + } +} + +func TestMultiLevelCache_LayerInvalidation(t *testing.T) { + cache := ctxmgr.NewMultiLevelCache() + cache.Clear() + + k1 := ctxmgr.HashKey("u1", "p1") + k2 := ctxmgr.HashKey("u2", "p2") + + cache.Set(ctxmgr.LayerC4Context, k1, "ctx_1", 20) + cache.Set(ctxmgr.LayerC4Context, k2, "ctx_2", 20) + + // 整体清空 C4 层 + cache.InvalidateLayer(ctxmgr.LayerC4Context) + + if _, ok := cache.Get(ctxmgr.LayerC4Context, k1); ok { + t.Errorf("expected k1 in C4 to be cleared") + } + if _, ok := cache.Get(ctxmgr.LayerC4Context, k2); ok { + t.Errorf("expected k2 in C4 to be cleared") + } +} + +func TestMultiLevelCache_DisabledToggle(t *testing.T) { + cache := ctxmgr.NewMultiLevelCache() + cache.SetEnabled(false) + + key := ctxmgr.HashKey("k_disabled") + cache.Set(ctxmgr.LayerC2Rewrite, key, "rewritten", 10) + + if _, ok := cache.Get(ctxmgr.LayerC2Rewrite, key); ok { + t.Errorf("expected cache to be inactive when disabled") + } + + cache.SetEnabled(true) + cache.Set(ctxmgr.LayerC2Rewrite, key, "rewritten", 10) + if _, ok := cache.Get(ctxmgr.LayerC2Rewrite, key); !ok { + t.Errorf("expected cache to be active when enabled") + } +} diff --git a/internal/ctxmgr/chat_memory.go b/internal/ctxmgr/chat_memory.go index 1c502988..5b0ec34b 100644 --- a/internal/ctxmgr/chat_memory.go +++ b/internal/ctxmgr/chat_memory.go @@ -199,6 +199,21 @@ func (m *ContextManager) SearchChatMemory(ctx context.Context, opts ChatMemorySe opts.MinScore = 0.25 } + cacheKey := HashKey( + "chat_memory", + fmt.Sprintf("%d", opts.UserID), + fmt.Sprintf("%d", opts.ProjectID), + opts.Query, + opts.SessionID, + opts.ExcludeSessionID, + fmt.Sprintf("%d", opts.TopK), + ) + if cached, ok := m.Cache().Get(LayerC1Search, cacheKey); ok { + if items, ok := cached.([]RecalledMemoryItem); ok { + return items, nil + } + } + emb := m.embedder if emb == nil { emb = NewDeterministicEmbedder(1024) @@ -277,6 +292,7 @@ func (m *ContextManager) SearchChatMemory(ctx context.Context, opts ChatMemorySe if len(candidates) > opts.TopK { candidates = candidates[:opts.TopK] } + m.Cache().Set(LayerC1Search, cacheKey, candidates, 20) return candidates, nil } @@ -348,6 +364,7 @@ func (m *ContextManager) SearchChatMemory(ctx context.Context, opts ChatMemorySe if len(candidates) > opts.TopK { candidates = candidates[:opts.TopK] } + m.Cache().Set(LayerC1Search, cacheKey, candidates, 20) return candidates, nil } diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index 59802436..28733977 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -29,6 +29,7 @@ type ContextManager struct { activities map[int64][]ActivityItem // project_id -> activities memoryChunks []MemoryChunkItem // 语义记忆切片缓存 summaries map[string]*ChatSummary // session_id -> rolling summary + cache *MultiLevelCache // 多级统一缓存系统 (C1-C5) nextActivityID int64 nextMessageID int64 nextChunkID int64 @@ -52,12 +53,24 @@ func NewContextManager(pool *pgxpool.Pool, cas *storage.CAS, decayCfg TimeDecayC activities: make(map[int64][]ActivityItem), memoryChunks: make([]MemoryChunkItem, 0), summaries: make(map[string]*ChatSummary), + cache: NewMultiLevelCache(), nextActivityID: 1, nextMessageID: 1, nextChunkID: 1, } } +// Cache 获取上下文管理器的多级缓存实例 +func (m *ContextManager) Cache() *MultiLevelCache { + if m == nil { + return GetGlobalCache() + } + if m.cache == nil { + m.cache = GetGlobalCache() + } + return m.cache +} + // SetEmbedder 设置自定义向量嵌入器 (如生产环境 bge-m3) func (m *ContextManager) SetEmbedder(emb VectorEmbedder) { if emb != nil { @@ -375,6 +388,7 @@ func (m *ContextManager) UpdateProjectBlackboard(ctx context.Context, projectID return fmt.Errorf("upsert project blackboard failed: %w", err) } } + m.Cache().InvalidateLayer(LayerC4Context) return nil } diff --git a/internal/ctxmgr/summarizer.go b/internal/ctxmgr/summarizer.go index 1aab7e2f..77c754ee 100644 --- a/internal/ctxmgr/summarizer.go +++ b/internal/ctxmgr/summarizer.go @@ -50,6 +50,13 @@ func (m *ContextManager) GetChatSummary(ctx context.Context, sessionID string) ( return nil, fmt.Errorf("sessionID required") } + if cached, ok := m.Cache().Get(LayerC5Summary, sessionID); ok { + if sum, ok := cached.(*ChatSummary); ok && sum != nil { + copySum := *sum + return ©Sum, nil + } + } + if m.pool != nil { var sum ChatSummary var factsJSON, openQuestionsJSON []byte @@ -61,6 +68,7 @@ func (m *ContextManager) GetChatSummary(ctx context.Context, sessionID string) ( if err == nil { _ = json.Unmarshal(factsJSON, &sum.Facts) _ = json.Unmarshal(openQuestionsJSON, &sum.OpenQuestions) + m.Cache().Set(LayerC5Summary, sessionID, &sum, 15) return &sum, nil } } @@ -71,6 +79,7 @@ func (m *ContextManager) GetChatSummary(ctx context.Context, sessionID string) ( if s, ok := m.summaries[sessionID]; ok && s != nil { // 返回副本 copySum := *s + m.Cache().Set(LayerC5Summary, sessionID, ©Sum, 15) return ©Sum, nil } } @@ -84,6 +93,10 @@ func (m *ContextManager) SaveChatSummary(ctx context.Context, sum *ChatSummary) } sum.UpdatedAt = time.Now() + // 主动失效 C5 摘要缓存与 C4 上下文缓存 + m.Cache().Invalidate(LayerC5Summary, sum.SessionID) + m.Cache().InvalidateLayer(LayerC4Context) + if m.pool != nil { factsJSON, _ := json.Marshal(sum.Facts) openQuestionsJSON, _ := json.Marshal(sum.OpenQuestions)