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:
@@ -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"}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, "报告") {
|
||||
|
||||
Reference in New Issue
Block a user