diff --git a/internal/intent/doc_workflow_scope.go b/internal/intent/doc_workflow_scope.go index a82af48b..9705584a 100644 --- a/internal/intent/doc_workflow_scope.go +++ b/internal/intent/doc_workflow_scope.go @@ -10,6 +10,7 @@ import ( "chat-agent-system/internal/squad" "chat-agent-system/internal/workflowdef" + "chat-agent-system/internal/workflowscope" ) // docWorkflowScope 提供「文档标题/任务键 → 归属工作流」索引, @@ -238,26 +239,42 @@ func activeScopedTaskKey(phrase, activeWorkflow string) (string, bool) { return workflowdef.ImplementationTaskKey(key), true } -// docWorkflowScopeMarker 检查输入是否显式点名了非激活工作流的受控文档。 -// 返回 (他流文档标题, 归属工作流 key 列表, 是否命中跨流)。 -// activeWorkflow 为空(存量单流 / planning 态)时一律不判跨流,保持现状行为。 +// docWorkflowScopeMarker 检查输入是否应拦截他流文档。 +// 权重模式下只有他流精确命中压过激活流才算跨流;strict 下仍是「激活流没有命中才拦截他流精确名」。 func docWorkflowScopeMarker(input, activeWorkflow string) (string, []string, bool) { - if activeWorkflow == "" { + decision := decideInputDocScope(input, activeWorkflow, workflowscope.ActionWrite) + if decision.Kind != workflowscope.KindCrossBlock { return "", nil, false } - for _, hit := range matchDocPhrasesForScope(input) { - owns := false - for _, wf := range hit.Workflows { - if wf == activeWorkflow { - owns = true - break - } - } - if !owns { - return hit.Phrase, hit.Workflows, true - } + owner := decision.Winner.Owner + if owner == "" { + return decision.Winner.Label, nil, true } - return "", nil, false + return decision.Winner.Label, []string{owner}, true +} + +func decideInputDocScope(input, active string, action workflowscope.Action) workflowscope.Decision { + hits := matchDocPhrasesForScope(input) + candidates := make([]workflowscope.Candidate, 0, len(hits)) + for _, hit := range hits { + owner := "" + if active != "" && !containsWorkflow(hit.Workflows, active) && len(hit.Workflows) > 0 { + owner = hit.Workflows[0] + } else if active != "" { + owner = active + } + quality := workflowscope.QualityExact + if utf8.RuneCountInString(hit.Phrase) < 4 { + quality = 0.5 + } + candidates = append(candidates, workflowscope.Candidate{ + ID: hit.Phrase, + Label: hit.Phrase, + Owner: owner, + Quality: quality, + }) + } + return workflowscope.Decide(candidates, active, action) } // markWorkflowScopeMismatch 在槽位上打跨流标记。 @@ -293,8 +310,16 @@ func fillTargetDocSlot(slots map[string]string, input string, userCtx *ContextSn if userCtx != nil { active = userCtx.ActiveWorkflowKey } - if doc, wfs, mismatched := docWorkflowScopeMarker(input, active); mismatched { - markWorkflowScopeMismatch(slots, doc, wfs) + decision := decideInputDocScope(input, active, workflowscope.ActionRead) + switch decision.Kind { + case workflowscope.KindCrossBlock: + markWorkflowScopeMismatch(slots, decision.Winner.Label, ownerList(decision.Winner.Owner)) + return + case workflowscope.KindCrossAllow: + slots[SlotTargetDoc] = decision.Winner.Label + if decision.Winner.Owner != "" { + slots[SlotCrossWorkflowOwner] = decision.Winner.Owner + } return } if doc := extractDocName(input); doc != "" { @@ -409,3 +434,102 @@ var docNameIDPattern = regexp.MustCompile(`(doc[_-][a-zA-Z0-9_-]+)`) func hasExplicitDocID(input string) bool { return docNameIDPattern.FindString(input) != "" } + +func ownerList(owner string) []string { + if owner == "" { + return nil + } + return []string{owner} +} + +// weightedTriggerTask 在权重模式下同时看激活流和他流短语,避免长他流别名劫持更贴切的激活流文档。 +func weightedTriggerTask(input string, phrases []struct{ text, key string }, userCtx *ContextSnapshot) *CandidateIntent { + active := activeWorkflowKeyOf(userCtx) + candidates := make([]workflowscope.Candidate, 0, len(phrases)) + for _, item := range phrases { + if !strings.Contains(input, item.text) { + continue + } + owners, ok := docScopeForPhrase(item.text) + if !ok { + if inferred := taskKeyWorkflowOwner(item.key); inferred != "" { + owners = []string{inferred} + ok = true + } + } + owner := "" + if active != "" && ok && !containsWorkflow(owners, active) && len(owners) > 0 { + owner = owners[0] + } else if active != "" { + owner = active + } + quality := workflowscope.QualityExact + if utf8.RuneCountInString(item.text) < 4 { + quality = 0.5 + } + candidates = append(candidates, workflowscope.Candidate{ + ID: item.key, + Label: item.text, + Owner: owner, + Quality: quality, + }) + } + decision := workflowscope.Decide(candidates, active, workflowscope.ActionWrite) + switch decision.Kind { + case workflowscope.KindCrossBlock: + return workflowScopeCandidate(decision.Winner.Label, ownerList(decision.Winner.Owner), active) + case workflowscope.KindInScope: + return triggerTaskCandidate(decision.Winner.Label, decision.Winner.ID) + default: + return nil + } +} + +func workflowScopeUsesWeights() bool { + return workflowscope.CurrentMode() == workflowscope.ModeWeighted +} + +// readDocScopeCandidate 只读点名受控文档。strict 下他流精确名仍拦截;weighted 下精确名放行并记下归属。 +func readDocScopeCandidate(input string, snap *ContextSnapshot) *CandidateIntent { + active := activeWorkflowKeyOf(snap) + decision := decideInputDocScope(input, active, workflowscope.ActionRead) + switch decision.Kind { + case workflowscope.KindNone: + return nil + case workflowscope.KindCrossBlock: + slots := map[string]string{} + markWorkflowScopeMismatch(slots, decision.Winner.Label, ownerList(decision.Winner.Owner)) + question := workflowScopeGuidanceQuestion(decision.Winner.Label, ownerList(decision.Winner.Owner), active) + return &CandidateIntent{ + Intent: IntentQueryDocStatus, + Confidence: 0.94, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: "只读点名的受控文档属于非激活工作流", + ClarificationQuestion: question, + } + default: + slots := map[string]string{ + SlotQueryTopic: "doc_summary", + SlotTargetDoc: decision.Winner.Label, + } + if decision.Kind == workflowscope.KindCrossAllow && decision.Winner.Owner != "" { + slots[SlotCrossWorkflowOwner] = decision.Winner.Owner + } + return &CandidateIntent{ + Intent: IntentQueryDocStatus, + Confidence: 0.97, + FilledSlots: slots, + Reasoning: "用户只读点名受控文档", + } + } +} + +func hasDocViewVerb(input string) bool { + for _, verb := range []string{"查看", "看看", "读取"} { + if strings.Contains(input, verb) { + return true + } + } + return false +} diff --git a/internal/intent/doc_workflow_scope_test.go b/internal/intent/doc_workflow_scope_test.go index 5818a77b..47b92bf3 100644 --- a/internal/intent/doc_workflow_scope_test.go +++ b/internal/intent/doc_workflow_scope_test.go @@ -6,6 +6,7 @@ import ( "testing" "chat-agent-system/internal/intent" + "chat-agent-system/internal/workflowscope" ) // ---------------------------------------------------------------------------------- @@ -117,6 +118,60 @@ func TestWorkflowScope_LegacyNoActiveWorkflowUnchanged(t *testing.T) { } } +func TestWorkflowScope_WeightedInScopeBeatsCrossPhrase(t *testing.T) { + workflowscope.SetModeForTest(workflowscope.ModeWeighted) + t.Cleanup(workflowscope.ResetModeForTest) + + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{SnapshotID: "s_w", ActiveWorkflowKey: "main"} + res, err := rec.Recognize(context.Background(), "生成调研报告,不要写成项目建议书", ctx) + if err != nil { + t.Fatal(err) + } + if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { + t.Fatalf("in-scope research must not be hijacked, slots=%v", res.TopIntent.FilledSlots) + } + if got := res.TopIntent.FilledSlots[intent.SlotTargetTask]; got != "research" { + t.Fatalf("want research, got %q slots=%v", got, res.TopIntent.FilledSlots) + } +} + +func TestWorkflowScope_WeightedReadCrossAllowed(t *testing.T) { + workflowscope.SetModeForTest(workflowscope.ModeWeighted) + t.Cleanup(workflowscope.ResetModeForTest) + + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{SnapshotID: "s_r", ActiveWorkflowKey: "main"} + res, err := rec.Recognize(context.Background(), "查看项目建议书", ctx) + if err != nil { + t.Fatal(err) + } + if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { + t.Fatalf("weighted read must not block, slots=%v", res.TopIntent.FilledSlots) + } + if res.TopIntent.FilledSlots[intent.SlotCrossWorkflowOwner] != "rdev" { + t.Fatalf("want rdev owner, slots=%v", res.TopIntent.FilledSlots) + } + if !strings.Contains(res.TopIntent.FilledSlots[intent.SlotTargetDoc], "项目建议书") { + t.Fatalf("want target doc, slots=%v", res.TopIntent.FilledSlots) + } +} + +func TestWorkflowScope_WeightedWriteStillBlocked(t *testing.T) { + workflowscope.SetModeForTest(workflowscope.ModeWeighted) + t.Cleanup(workflowscope.ResetModeForTest) + + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{SnapshotID: "s_wb", ActiveWorkflowKey: "main"} + res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx) + if err != nil { + t.Fatal(err) + } + if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] != "true" { + t.Fatalf("weighted write must still block, slots=%v intent=%s", res.TopIntent.FilledSlots, res.TopIntent.Intent) + } +} + func TestWorkflowScope_SnapshotCarriesActiveWorkflowKey(t *testing.T) { // ContextSnapshot 现在必须能携带激活工作流键,供消费端与识别器共同约束。 snap := intent.ContextSnapshot{ActiveWorkflowKey: "main"} diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index fed5044d..015d7e7f 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -1318,6 +1318,14 @@ func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent { return nil } + if workflowScopeUsesWeights() { + flat := make([]struct{ text, key string }, 0, len(phrases)) + for _, item := range phrases { + flat = append(flat, struct{ text, key string }{text: item.text, key: item.key}) + } + return weightedTriggerTask(input, flat, userCtx) + } + for _, item := range phrases { if strings.Contains(input, item.text) { if cand := scopedTriggerTaskCandidate(input, item.text, item.key, userCtx); cand != nil { diff --git a/internal/intent/workflow_matcher.go b/internal/intent/workflow_matcher.go index a3302a14..32daa7c0 100644 --- a/internal/intent/workflow_matcher.go +++ b/internal/intent/workflow_matcher.go @@ -185,6 +185,11 @@ func matchQueryDocStatus(input string, snap *ContextSnapshot) *CandidateIntent { } } } + if isSummary || hasDocViewVerb(input) { + if cand := readDocScopeCandidate(input, snap); cand != nil { + return cand + } + } if isSummary { target := extractDocName(input) if target != "" || strings.Contains(input, "文档") || strings.Contains(input, "报告") {