From b75a06cc1a1ad2aecdd88aecd958f8d434d0ddbf Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Thu, 10 Sep 2026 11:46:27 +0800 Subject: [PATCH] feat(coordinator): implement determinism-first query rewrite with ellipsis completion and pronoun resolution (P3) --- internal/chat/session.go | 32 +++- internal/chat/session_test.go | 72 ++++++++ internal/coordinator/context.go | 4 + internal/coordinator/memory_composer.go | 6 +- internal/coordinator/rewriter.go | 218 ++++++++++++++++++++++++ internal/coordinator/rewriter_test.go | 93 ++++++++++ internal/ctxmgr/taskstate.go | 25 ++- 7 files changed, 444 insertions(+), 6 deletions(-) create mode 100644 internal/coordinator/rewriter.go create mode 100644 internal/coordinator/rewriter_test.go diff --git a/internal/chat/session.go b/internal/chat/session.go index 5ae352bb..de6856be 100644 --- a/internal/chat/session.go +++ b/internal/chat/session.go @@ -259,7 +259,23 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str ) } - // 1.5 刷新会话级任务状态机 (DST) + // 1.5 规则查询改写 (P3 阶段:基于当轮输入前的历史状态机进行省略补全 + 代词消解 + 主题延续) + searchQuery := userInput + if s.UserCtx != nil { + rewriteRes := coordinator.RewriteQuery( + userInput, + s.UserCtx.TaskState, + s.UserCtx.RecentDialogue, + s.UserCtx.Project, + ) + s.UserCtx.RewrittenQuery = rewriteRes.Rewritten + s.UserCtx.RewriteRules = rewriteRes.AppliedRules + if rewriteRes.SearchQuery != "" { + searchQuery = rewriteRes.SearchQuery + } + } + + // 1.6 刷新会话级任务状态机 (DST,合入改写后完整问句所携带的实体与锚点) if s.UserCtx != nil { if s.UserCtx.TaskState == nil { s.UserCtx.TaskState = ctxmgr.NewConversationTaskState() @@ -270,8 +286,12 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str intentName = string(recRes.TopIntent.Intent) filledSlots = recRes.TopIntent.FilledSlots } + effectiveInput := userInput + if s.UserCtx.RewrittenQuery != "" { + effectiveInput = s.UserCtx.RewrittenQuery + } s.UserCtx.TaskState.UpdateFromTurn( - userInput, + effectiveInput, intentName, filledSlots, string(recRes.Branch), @@ -279,8 +299,8 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str ) } - // 1.8 按需语义召回历史对话切片 (P2 阶段) - s.syncRecalledMemories(ctx, userInput, recRes) + // 1.8 按需语义召回历史对话切片 (P2 阶段:优先使用改写增强后的 searchQuery) + s.syncRecalledMemories(ctx, searchQuery, recRes) // 2. 协调调度与校验 (L2 协调层) if streamer != nil { @@ -306,6 +326,10 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str coordRes.UpdatedContext.RecalledMemories = s.UserCtx.RecalledMemories coordRes.UpdatedContext.RecalledMemorySnippet = s.UserCtx.RecalledMemorySnippet } + if coordRes.UpdatedContext.RewrittenQuery == "" && s.UserCtx != nil { + coordRes.UpdatedContext.RewrittenQuery = s.UserCtx.RewrittenQuery + coordRes.UpdatedContext.RewriteRules = s.UserCtx.RewriteRules + } s.UserCtx = coordRes.UpdatedContext } diff --git a/internal/chat/session_test.go b/internal/chat/session_test.go index 01e4b05e..727dbd76 100644 --- a/internal/chat/session_test.go +++ b/internal/chat/session_test.go @@ -546,5 +546,77 @@ func TestChatSession_SemanticMemoryRecallAndAsyncTurnRecord(t *testing.T) { } } +func TestChatSession_QueryRewrite_EllipsisAndPronoun(t *testing.T) { + ctx := context.Background() + rec := intent.NewRecognizer() + engine := coordinator.NewCoordinatorEngine(nil) + ctxMgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig()) + + userCtx := &coordinator.UserProjectContext{ + SnapshotID: "sess_rewrite_test", + User: coordinator.UserInfo{ + ID: "101", + Name: "钱工", + Role: "engineer", + ProjectRole: "composer", + }, + Project: coordinator.ProjectInfo{ + ID: "proj_rewrite_1", + Name: "数字化高频超声吸引系统", + DeviceType: "高频超声手术刀", + CurrentStage: bizdev.StageV01_Triage, + }, + BlackboardFacts: make(map[string]string), + } + + session := chat.NewSession("sess_rewrite_test", userCtx, rec, engine) + session.UserID = 101 + session.ProjectID = 301 + session.CtxMgr = ctxMgr + + // Turn 1: 提问完整独立句(建立 TopicAnchor) + _, err := session.SendMessage(ctx, "GB 9706.1 对我们这个器械适用吗?") + if err != nil { + t.Fatalf("turn 1 failed: %v", err) + } + if session.UserCtx.TaskState == nil || session.UserCtx.TaskState.TopicAnchor == "" { + t.Fatalf("expected TopicAnchor to be set on Turn 1") + } + + // Turn 2: 省略句提问 "YY 0505 呢?" + _, err = session.SendMessage(ctx, "YY 0505 呢?") + if err != nil { + t.Fatalf("turn 2 failed: %v", err) + } + + // 验证改写结果与规则命中 + if session.UserCtx.RewrittenQuery == "" { + t.Errorf("expected RewrittenQuery to be set on Turn 2") + } + if !strings.Contains(session.UserCtx.RewrittenQuery, "YY 0505") || !strings.Contains(session.UserCtx.RewrittenQuery, "适用吗") { + t.Errorf("expected completed query with YY 0505 and predicate, got: %s", session.UserCtx.RewrittenQuery) + } + + // 验证 Prompt 双呈现 + composer := coordinator.NewMemoryComposer() + promptTurn2 := composer.ComposeDialoguePrompt("YY 0505 呢?", session.UserCtx) + if !strings.Contains(promptTurn2, "用户原话: YY 0505 呢?") { + t.Errorf("expected raw user input in prompt, got: %s", promptTurn2) + } + if !strings.Contains(promptTurn2, "结合上下文理解完整诉求:") { + t.Errorf("expected contextual explanation in prompt, got: %s", promptTurn2) + } + + // Turn 3: 代词消解提问 "它的预算是多少?" + _, err = session.SendMessage(ctx, "它的预算是多少?") + if err != nil { + t.Fatalf("turn 3 failed: %v", err) + } + if !strings.Contains(session.UserCtx.RewrittenQuery, "数字化高频超声吸引系统") { + t.Errorf("expected pronoun resolution to project name, got: %s", session.UserCtx.RewrittenQuery) + } +} + + diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go index dd2aeea9..4d2ab40d 100644 --- a/internal/coordinator/context.go +++ b/internal/coordinator/context.go @@ -79,6 +79,10 @@ type UserProjectContext struct { RecalledMemories []ctxmgr.RecalledMemoryItem `json:"-"` // RecalledMemorySnippet 格式化后的相关历史片段 Prompt 文本 RecalledMemorySnippet string `json:"-"` + // RewrittenQuery 规则改写补全后的完整独立问句(用于检索与 Prompt 双呈现,不进意图识别) + RewrittenQuery string `json:"-"` + // RewriteRules 本轮命中的改写规则列表(如 ellipsis_completion, pronoun_resolution) + RewriteRules []string `json:"-"` // UpgradeOffered 路由升级建议冷却标记(每会话至多主动建议一次,防打扰——对齐"参谋不强推"人设) UpgradeOffered bool `json:"-"` } diff --git a/internal/coordinator/memory_composer.go b/internal/coordinator/memory_composer.go index 1c8460d5..d65f442e 100644 --- a/internal/coordinator/memory_composer.go +++ b/internal/coordinator/memory_composer.go @@ -180,7 +180,11 @@ func (mc *MemoryComposer) ComposeDialoguePrompt(userInput string, userCtx *UserP // 8. 【本轮用户】 // ------------------------------------------------------------- sb.WriteString("【本轮用户】\n") - sb.WriteString(userInput) + if userCtx.RewrittenQuery != "" && userCtx.RewrittenQuery != userInput { + sb.WriteString(fmt.Sprintf("用户原话: %s\n(结合上下文理解完整诉求: %s)", userInput, userCtx.RewrittenQuery)) + } else { + sb.WriteString(userInput) + } sb.WriteString("\n\n请结合上下文,以温和助理口吻作答,并写入 reply.md。") return sb.String() diff --git a/internal/coordinator/rewriter.go b/internal/coordinator/rewriter.go new file mode 100644 index 00000000..cd844c69 --- /dev/null +++ b/internal/coordinator/rewriter.go @@ -0,0 +1,218 @@ +package coordinator + +import ( + "fmt" + "regexp" + "strings" + + "chat-agent-system/internal/ctxmgr" +) + +// RewriteResult 查询改写产物 +type RewriteResult struct { + Rewritten string `json:"rewritten"` // 补全后的完整独立问句(若无需改写则等于 rawInput) + SearchQuery string `json:"search_query"` // 面向语义检索/KB优化的 Query(带槽位上下文) + AppliedRules []string `json:"applied_rules"` // 命中的规则名(可解释、可观测) +} + +var ( + // R1 省略特征正则 + ellipsisSuffixRe = regexp.MustCompile(`(?:呢|也|还是|怎么样|适用吗|如何|怎样|要多久)[??]*$`) + ellipsisPrefixRe = regexp.MustCompile(`^(?:那|另外|还有|同时|那关于|再说|如果)\s*`) + + // R2 代词正则 + pronounRe = regexp.MustCompile(`(这个项目|该项目|这项目|该文档|这份文档|那份报告|该报告|该器械|该产品|这个设备|这设备|上次说的|之前说的|它们|他们|它)`) + + // 标准法规提取正则 (复用模式) + standardCodeRe = regexp.MustCompile(`(?i)(?:GB[/\s-T]*\d+(?:\.\d+)?(?:-\d{4})?|YY[/\s-T]*\d+(?:\.\d+)?(?:-\d{4})?|ISO[/\s-T]*\d+|IEC[/\s-T]*\d+|MDR|FDA|NMPA)`) + + // 常见谓语/疑问框架 + commonPredicates = []string{ + "适用吗", "适用的标准是什么", "怎么处理", "如何解决", "周期要多久", + "预算是多少", "流程是怎样的", "要准备几台样机", "怎么准备", + "要求是什么", "怎么规划", "需要满足什么规范", "怎么做", "符合要求吗", + } +) + +// RewriteQuery 规则改写主入口 +// 遵循确定性优先原则:零外部网络、零大模型时延,毫秒级就绪 +func RewriteQuery( + rawInput string, + st *ctxmgr.ConversationTaskState, + recent []DialogueTurn, + proj ProjectInfo, +) RewriteResult { + cleanInput := strings.TrimSpace(rawInput) + if cleanInput == "" { + return RewriteResult{Rewritten: "", SearchQuery: "", AppliedRules: nil} + } + + appliedRules := make([]string, 0) + rewritten := cleanInput + + anchor := "" + if st != nil && st.TopicAnchor != "" { + anchor = strings.TrimSpace(st.TopicAnchor) + } + + // ------------------------------------------------------------- + // 规则 R2: 代词消解 (Pronoun Resolution) + // 例: "它的预算是多少" => "「数字化高频超声手术吸引系统」的预算是多少" + // ------------------------------------------------------------- + if pronounRe.MatchString(rewritten) { + replacementEntity := resolvePronounEntity(rewritten, st, proj) + if replacementEntity != "" { + rewritten = pronounRe.ReplaceAllString(rewritten, replacementEntity) + appliedRules = append(appliedRules, "pronoun_resolution") + } + } + + // ------------------------------------------------------------- + // 规则 R1: 省略补全 (Ellipsis Completion) + // 例: 上轮 "GB 9706.1 适用吗" -> 本轮 "YY 0505 呢" => "YY 0505 适用吗" + // ------------------------------------------------------------- + runes := []rune(cleanInput) + isShort := len(runes) <= 16 + hasEllipsisMarker := ellipsisSuffixRe.MatchString(cleanInput) || ellipsisPrefixRe.MatchString(cleanInput) + isPureEntity := isLikelyPureEntity(cleanInput) + isContinuation := strings.HasPrefix(cleanInput, "再") || + strings.HasPrefix(cleanInput, "加上") || + strings.HasPrefix(cleanInput, "还有") || + strings.HasPrefix(cleanInput, "顺便") + + if anchor != "" && isShort && isContinuation && !strings.Contains(rewritten, anchor) { + // ------------------------------------------------------------- + // 规则 R3: 主题延续合并 (Topic Continuation) + // 例: 上轮 "对比A和B的报价" -> 本轮 "再加上C呢" => "对比A和B的报价,再加上C呢" + // ------------------------------------------------------------- + rewritten = fmt.Sprintf("%s,%s", anchor, cleanInput) + appliedRules = append(appliedRules, "topic_continuation") + } else if anchor != "" && (isShort && (hasEllipsisMarker || isPureEntity)) && !strings.Contains(rewritten, anchor) { + // ------------------------------------------------------------- + // 规则 R1: 省略补全 (Ellipsis Completion) + // 例: 上轮 "GB 9706.1 适用吗" -> 本轮 "YY 0505 呢" => "YY 0505 适用吗" + // ------------------------------------------------------------- + completed := completeEllipsis(cleanInput, anchor, st) + if completed != "" && completed != cleanInput { + rewritten = completed + appliedRules = append(appliedRules, "ellipsis_completion") + } + } + + // ------------------------------------------------------------- + // 规则 R4 & R5: 面向检索的增强 Query (SearchQuery) + // ------------------------------------------------------------- + searchQuery := rewritten + if st != nil && len(st.CollectedSlots) > 0 { + var slotParts []string + if dev, ok := st.CollectedSlots["device_type"]; ok && dev != "" && !strings.Contains(searchQuery, dev) { + slotParts = append(slotParts, "器械="+dev) + } + if mkt, ok := st.CollectedSlots["target_market"]; ok && mkt != "" && !strings.Contains(searchQuery, mkt) { + slotParts = append(slotParts, "市场="+mkt) + } + if len(slotParts) > 0 { + searchQuery = fmt.Sprintf("%s (%s)", searchQuery, strings.Join(slotParts, "; ")) + appliedRules = append(appliedRules, "slot_context_completion") + } + } + + // 若未命中改写且句子过短,应用 R5 完整概念合成作为检索兜底 + if len(appliedRules) == 0 && isShort && anchor != "" && !strings.Contains(cleanInput, anchor) { + searchQuery = anchor + " " + cleanInput + appliedRules = append(appliedRules, "concept_synthesis") + } + + return RewriteResult{ + Rewritten: rewritten, + SearchQuery: searchQuery, + AppliedRules: appliedRules, + } +} + +// resolvePronounEntity 寻找最匹配的代词指代实体 +func resolvePronounEntity(query string, st *ctxmgr.ConversationTaskState, proj ProjectInfo) string { + // 若代词偏向文档 + if strings.Contains(query, "文档") || strings.Contains(query, "报告") { + if st != nil && len(st.TopicEntities) > 0 { + for _, e := range st.TopicEntities { + if strings.Contains(e, "报告") || strings.Contains(e, "表") || strings.Contains(e, "书") { + return fmt.Sprintf("「%s」", e) + } + } + } + } + + // 默认指代项目或器械产品实体 + if proj.Name != "" { + return fmt.Sprintf("「%s」", proj.Name) + } + if proj.DeviceType != "" { + return fmt.Sprintf("「%s」", proj.DeviceType) + } + if st != nil && len(st.TopicEntities) > 0 { + return fmt.Sprintf("「%s」", st.TopicEntities[0]) + } + return "项目" +} + +// completeEllipsis 执行省略句与前序锚点的谓语框架合成 +func completeEllipsis(cleanInput, anchor string, st *ctxmgr.ConversationTaskState) string { + // 去除 "呢"、"?"、"那" 等修饰词得到核心实体 + stripped := ellipsisSuffixRe.ReplaceAllString(cleanInput, "") + stripped = ellipsisPrefixRe.ReplaceAllString(stripped, "") + coreEntity := strings.TrimSpace(stripped) + if coreEntity == "" { + return "" + } + + // 1. 标准强标编号替换:若锚点含强标号,且本轮输入也是强标号 + anchorStandards := standardCodeRe.FindAllString(anchor, -1) + inputStandards := standardCodeRe.FindAllString(coreEntity, -1) + if len(anchorStandards) > 0 && len(inputStandards) > 0 { + // 用新标准号替换锚点中的旧标准号 + res := strings.Replace(anchor, anchorStandards[0], inputStandards[0], 1) + return res + } + + // 2. 谓语框架迁移:从锚点中提取疑问/谓语短语 + for _, pred := range commonPredicates { + if strings.Contains(anchor, pred) { + return fmt.Sprintf("%s %s", coreEntity, pred) + } + } + + // 3. 通用合成:若锚点以问号结尾,提取疑问部分 + if strings.HasSuffix(anchor, "?") || strings.HasSuffix(anchor, "?") { + cleanAnchor := strings.TrimRight(anchor, "??") + parts := strings.Split(cleanAnchor, " ") + if len(parts) > 1 { + predicatePart := parts[len(parts)-1] + return fmt.Sprintf("%s %s?", coreEntity, predicatePart) + } + } + + return fmt.Sprintf("%s(关于%s)", anchor, coreEntity) +} + +func isLikelyPureEntity(s string) bool { + clean := strings.TrimSpace(s) + // 标准编号本身是实体 + if standardCodeRe.MatchString(clean) { + return true + } + // 极短且无常见动词 + if len([]rune(clean)) <= 8 && !strings.ContainsAny(clean, "是做有去看查改问") { + return true + } + return false +} + +func containsRule(rules []string, target string) bool { + for _, r := range rules { + if r == target { + return true + } + } + return false +} diff --git a/internal/coordinator/rewriter_test.go b/internal/coordinator/rewriter_test.go new file mode 100644 index 00000000..a79067e7 --- /dev/null +++ b/internal/coordinator/rewriter_test.go @@ -0,0 +1,93 @@ +package coordinator_test + +import ( + "strings" + "testing" + + "chat-agent-system/internal/coordinator" + "chat-agent-system/internal/ctxmgr" +) + +func TestRewriteQuery_Rules(t *testing.T) { + proj := coordinator.ProjectInfo{ + Name: "数字化高频超声手术吸引系统", + DeviceType: "超声手术设备", + } + + // ------------------------------------------------------------- + // 1. 测试 R1: 省略补全 (标准法规编号替换与谓语继承) + // ------------------------------------------------------------- + stR1 := ctxmgr.NewConversationTaskState() + stR1.TopicAnchor = "GB 9706.1 对超声设备适用吗" + + resR1 := coordinator.RewriteQuery("YY 0505 呢?", stR1, nil, proj) + if !containsRule(resR1.AppliedRules, "ellipsis_completion") { + t.Errorf("expected ellipsis_completion rule, got: %v", resR1.AppliedRules) + } + if !strings.Contains(resR1.Rewritten, "YY 0505") || !strings.Contains(resR1.Rewritten, "适用吗") { + t.Errorf("expected YY 0505 predicate completion, got: %s", resR1.Rewritten) + } + + // ------------------------------------------------------------- + // 2. 测试 R2: 代词消解 (Pronoun Resolution) + // ------------------------------------------------------------- + stR2 := ctxmgr.NewConversationTaskState() + stR2.TopicAnchor = "数字化高频超声手术吸引系统方案研讨" + + resR2 := coordinator.RewriteQuery("它的预算是多少?", stR2, nil, proj) + if !containsRule(resR2.AppliedRules, "pronoun_resolution") { + t.Errorf("expected pronoun_resolution rule, got: %v", resR2.AppliedRules) + } + if !strings.Contains(resR2.Rewritten, "数字化高频超声手术吸引系统") { + t.Errorf("expected project entity in rewritten query, got: %s", resR2.Rewritten) + } + + // ------------------------------------------------------------- + // 3. 测试 R3: 主题延续合并 (Topic Continuation) + // ------------------------------------------------------------- + stR3 := ctxmgr.NewConversationTaskState() + stR3.TopicAnchor = "对比A厂家和B厂家的换能器报价" + + resR3 := coordinator.RewriteQuery("再加上C厂家呢", stR3, nil, proj) + if !containsRule(resR3.AppliedRules, "topic_continuation") { + t.Errorf("expected topic_continuation rule, got: %v", resR3.AppliedRules) + } + if !strings.Contains(resR3.Rewritten, "对比A厂家和B厂家的换能器报价") || !strings.Contains(resR3.Rewritten, "再加上C厂家呢") { + t.Errorf("expected merged continuation query, got: %s", resR3.Rewritten) + } + + // ------------------------------------------------------------- + // 4. 测试 R4: 槽位上下文补全 (Slot Context Completion for SearchQuery) + // ------------------------------------------------------------- + stR4 := ctxmgr.NewConversationTaskState() + stR4.CollectedSlots["device_type"] = "超声手术设备" + stR4.CollectedSlots["target_market"] = "国内+CE" + + resR4 := coordinator.RewriteQuery("型检周期一般多久", stR4, nil, proj) + if !containsRule(resR4.AppliedRules, "slot_context_completion") { + t.Errorf("expected slot_context_completion in search query, got: %v", resR4.AppliedRules) + } + if !strings.Contains(resR4.SearchQuery, "器械=超声手术设备") || !strings.Contains(resR4.SearchQuery, "市场=国内+CE") { + t.Errorf("expected slot constraints in SearchQuery, got: %s", resR4.SearchQuery) + } + + // ------------------------------------------------------------- + // 5. 独立完整长句:不应被改写 + // ------------------------------------------------------------- + stStand := ctxmgr.NewConversationTaskState() + stStand.TopicAnchor = "前期沟通记录" + fullSentence := "请帮我详细阐述一下压电换能器逆压电效应的物理机理以及在超声刀上的实现方式" + resStand := coordinator.RewriteQuery(fullSentence, stStand, nil, proj) + if resStand.Rewritten != fullSentence { + t.Errorf("standalone full sentence should not be rewritten, got: %s", resStand.Rewritten) + } +} + +func containsRule(rules []string, target string) bool { + for _, r := range rules { + if r == target { + return true + } + } + return false +} diff --git a/internal/ctxmgr/taskstate.go b/internal/ctxmgr/taskstate.go index e6ebfec5..ef446737 100644 --- a/internal/ctxmgr/taskstate.go +++ b/internal/ctxmgr/taskstate.go @@ -2,6 +2,7 @@ package ctxmgr import ( "fmt" + "regexp" "strings" "time" ) @@ -42,7 +43,7 @@ func (ts *ConversationTaskState) UpdateFromTurn(userInput string, intentName str ts.ActiveTask = intentName } trimmedInput := strings.TrimSpace(userInput) - if len([]rune(trimmedInput)) >= 4 && !isGenericAffirmative(trimmedInput) { + if isCompleteAnchorCandidate(trimmedInput) { ts.TopicAnchor = trimmedInput } @@ -137,6 +138,28 @@ func isGenericAffirmative(s string) bool { return s == "好" || s == "好的" || s == "行" || s == "可以" || s == "1" || s == "同意" || s == "推进" } +var ( + ellipsisAnchorFilterRe = regexp.MustCompile(`(?:呢|也|还是|怎么样|怎样|如何)[??]*$`) + pronounAnchorFilterRe = regexp.MustCompile(`^(?:那|另外|还有|同时|那关于)?\s*(?:这个|该|这|这份|那份|上次说的|之前说的|它|它们)`) +) + +func isCompleteAnchorCandidate(s string) bool { + s = strings.TrimSpace(s) + if s == "" || isGenericAffirmative(s) { + return false + } + runes := []rune(s) + // 短于 12 个字符且含有省略或代词特征,不能覆盖已有完整主题锚点 + if len(runes) < 12 && (ellipsisAnchorFilterRe.MatchString(s) || pronounAnchorFilterRe.MatchString(s)) { + return false + } + // 即使达到或超过 12 个字符,如果末尾是纯疑问助词(例如单纯追问标准),也尽量避免冲刷 + if ellipsisAnchorFilterRe.MatchString(s) && len(runes) < 16 { + return false + } + return len(runes) >= 6 +} + func containsString(arr []string, s string) bool { for _, item := range arr { if item == s {