feat(intent): score slot filling against the active workflow

Weighted mode keeps an in-scope document when a cross-workflow name is only mentioned beside it, while exact cross-workflow writes stay blocked and exact reads record the owning workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 09:15:51 +00:00
co-authored by Cursor
parent 976f8d30b8
commit 0972900215
4 changed files with 210 additions and 18 deletions
+142 -18
View File
@@ -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
}
@@ -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"}
+8
View File
@@ -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 {
+5
View File
@@ -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, "报告") {