Files
Agent-System/internal/intent/doc_workflow_scope.go
T
weichenandCursor 4af4a9de13 fix(intent): keep draft requests that end in 看看 on the write path
「出一版…看看」是写完给看,不是只读查看;查看和读取仍走只读作用域裁决。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 09:26:03 +00:00

543 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package intent
import (
"fmt"
"regexp"
"sort"
"strings"
"sync"
"unicode/utf8"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/workflowdef"
"chat-agent-system/internal/workflowscope"
)
// docWorkflowScope 提供「文档标题/任务键 → 归属工作流」索引,
// 使填槽(target_doc / target_task)只命中当前激活工作流;
// 显式点名他流文档时打 workflow_scope_mismatch 标记并给出切换引导,不误填。
// docWorkflowIndex 文档标题/别名 → 归属工作流 key 集合(跨流歧义标题会有多个值)。
type docWorkflowIndex struct {
phraseToWorkflows map[string][]string
// phraseWorkflowTask 供「同名文档分属多流」时按激活流改路由(如 系统需求规格书 → pm_/rd_)。
phraseWorkflowTask map[string]map[string]string
sortedPhrases []string
nameToKey map[string]string
}
var (
docScopeOnce sync.Once
docScopeIndex *docWorkflowIndex
)
// 存量别名与历史叫法 → 规范工作流键(与 internal/workflowdef/keyspace.go 的旧任务键前缀对齐)。
var docScopeWorkflowAliases = map[string]string{
"bd": "main",
"urs": "product_manager",
"tm": "test_manager",
"rd": "rdev",
"sw": "software_bootstrap",
"main": "main",
}
// docScopeManualEntries 是清单之外的补充叫法(历史词表与口语别名)。
// 只收录语义明确的标题;模糊短别名(如「需求表」)不在此列,维持原有兜底。
var docScopeManualEntries = []struct {
workflow string
phrases []string
reference string // 借用该任务键的清单标题;可为空
}{
{workflow: "main", phrases: []string{"用户初始需求收集表", "用户初始需求表", "用户需求收集表", "需求收集表", "技术与市场调研报告", "CDO报价单", "初步报价单"}},
{workflow: "product_manager", phrases: []string{"URS", "用户需求规格"}},
{workflow: "test_manager", phrases: []string{"系统验证方案", "验证方案及计划"}},
// 「设计方案」在历史词表里指商务流的系统初步设计方案;研发流的同名文档用全称「系统设计方案」消歧。
{workflow: "main", phrases: []string{"设计方案"}, reference: "bd_prelim_design"},
}
// docScopeUnusablePhrase 过短的短语(含 UN/BOM 类记号)易命中无关句子,不入索引。
func docScopeUnusablePhrase(phrase string) bool {
return utf8.RuneCountInString(phrase) < 2
}
func buildDocWorkflowIndex() *docWorkflowIndex {
idx := &docWorkflowIndex{
phraseToWorkflows: map[string][]string{},
phraseWorkflowTask: map[string]map[string]string{},
nameToKey: map[string]string{},
}
add := func(workflow, phrase, taskKey string) {
phrase = strings.TrimSpace(phrase)
if phrase == "" || docScopeUnusablePhrase(phrase) {
return
}
dup := false
for _, existing := range idx.phraseToWorkflows[phrase] {
if existing == workflow {
dup = true
break
}
}
if !dup {
idx.phraseToWorkflows[phrase] = append(idx.phraseToWorkflows[phrase], workflow)
}
if taskKey != "" {
byWF, ok := idx.phraseWorkflowTask[phrase]
if !ok {
byWF = map[string]string{}
idx.phraseWorkflowTask[phrase] = byWF
}
if _, exists := byWF[workflow]; !exists {
byWF[workflow] = taskKey
}
}
}
for _, manifestFile := range workflowdef.V2ManifestFiles {
def, err := workflowdef.LoadManifestDefinition(manifestFile)
if err != nil || def.WorkflowKey == "" {
continue
}
for _, st := range def.Stages {
for _, dt := range st.DocTasks {
idx.nameToKey[dt.Key] = def.WorkflowKey
add(def.WorkflowKey, dt.Title, dt.Key)
if dt.OutputDocTitle != "" && dt.OutputDocTitle != dt.Title {
add(def.WorkflowKey, dt.OutputDocTitle, dt.Key)
}
if meta, ok := squad.LookupRDevTask(dt.Key); ok {
add(def.WorkflowKey, meta.Title, dt.Key)
for _, alias := range meta.Aliases {
add(def.WorkflowKey, alias, dt.Key)
}
}
}
}
}
for _, entry := range docScopeManualEntries {
for _, p := range entry.phrases {
add(entry.workflow, p, "")
}
if entry.reference != "" {
def, err := workflowdef.LoadManifestDefinition(entry.workflow + ".v2.json")
if err == nil {
for _, st := range def.Stages {
for _, dt := range st.DocTasks {
if dt.Key == entry.reference {
add(entry.workflow, dt.Title, dt.Key)
}
}
}
}
}
}
phrases := make([]string, 0, len(idx.phraseToWorkflows))
for p := range idx.phraseToWorkflows {
phrases = append(phrases, p)
}
sort.Slice(phrases, func(i, j int) bool {
li := utf8.RuneCountInString(phrases[i])
lj := utf8.RuneCountInString(phrases[j])
if li != lj {
return li > lj
}
return phrases[i] < phrases[j]
})
idx.sortedPhrases = phrases
return idx
}
func getDocWorkflowIndex() *docWorkflowIndex {
docScopeOnce.Do(func() {
docScopeIndex = buildDocWorkflowIndex()
})
return docScopeIndex
}
// taskKeyWorkflowOwner 按任务键推断归属工作流(旧键与新键均可;未知返回空)。
func taskKeyWorkflowOwner(taskKey string) string {
taskKey = strings.TrimSpace(taskKey)
if taskKey == "" {
return ""
}
idx := getDocWorkflowIndex()
if wf, ok := idx.nameToKey[taskKey]; ok {
return wf
}
if canonical := workflowdef.CanonicalTaskKey(taskKey); canonical != taskKey {
if wf, ok := idx.nameToKey[canonical]; ok {
return wf
}
}
prefix := taskKey
if i := strings.Index(taskKey, "_"); i > 0 {
prefix = taskKey[:i]
}
return docScopeWorkflowAliases[prefix]
}
// docNameHit 记录文档短语命中的归属集合。
type docNameHit struct {
Phrase string
Workflows []string
}
// matchDocPhrasesForScope 在输入中查找命中的文档标题/别名短语(最长优先)。
// 长短语占用的字符区间不再参与更短短语匹配,防止「用户需求规格书」拆出「需求规格」。
func matchDocPhrasesForScope(input string) []docNameHit {
idx := getDocWorkflowIndex()
used := make([]bool, len(input))
var hits []docNameHit
for _, phrase := range idx.sortedPhrases {
for start := 0; start < len(input); {
pos := strings.Index(input[start:], phrase)
if pos < 0 {
break
}
abs := start + pos
overlap := false
for k := abs; k < abs+len(phrase); k++ {
if used[k] {
overlap = true
break
}
}
for k := abs; k < abs+len(phrase); k++ {
used[k] = true
}
if !overlap {
hits = append(hits, docNameHit{Phrase: phrase, Workflows: idx.phraseToWorkflows[phrase]})
}
start = abs + len(phrase)
}
}
return hits
}
// docScopeForPhrase 返回某文档短语的归属工作流集合。
func docScopeForPhrase(phrase string) ([]string, bool) {
idx := getDocWorkflowIndex()
wfs, ok := idx.phraseToWorkflows[strings.TrimSpace(phrase)]
return wfs, ok
}
// activeScopedTaskKey 同名文档分属多流时,返回当前激活流的任务键(执行键形态)。
func activeScopedTaskKey(phrase, activeWorkflow string) (string, bool) {
if activeWorkflow == "" {
return "", false
}
idx := getDocWorkflowIndex()
byWF, ok := idx.phraseWorkflowTask[strings.TrimSpace(phrase)]
if !ok {
return "", false
}
key, ok := byWF[activeWorkflow]
if !ok || key == "" {
return "", false
}
return workflowdef.ImplementationTaskKey(key), true
}
// docWorkflowScopeMarker 检查输入是否应拦截他流文档。
// 权重模式下只有他流精确命中压过激活流才算跨流;strict 下仍是「激活流没有命中才拦截他流精确名」。
func docWorkflowScopeMarker(input, activeWorkflow string) (string, []string, bool) {
decision := decideInputDocScope(input, activeWorkflow, workflowscope.ActionWrite)
if decision.Kind != workflowscope.KindCrossBlock {
return "", nil, false
}
owner := decision.Winner.Owner
if owner == "" {
return decision.Winner.Label, nil, true
}
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 在槽位上打跨流标记。
func markWorkflowScopeMismatch(slots map[string]string, doc string, workflows []string) {
if slots == nil || doc == "" {
return
}
slots[SlotWorkflowScopeMismatch] = "true"
slots[SlotMismatchedDoc] = doc
slots[SlotMismatchedWorkflow] = strings.Join(workflows, ",")
}
// scopeMismatchClarify 若槽位带跨流标记则返回引导话术,否则空串。
func scopeMismatchClarify(slots map[string]string, activeWorkflow string) string {
if slots == nil || slots[SlotWorkflowScopeMismatch] != "true" {
return ""
}
workflows := []string{}
if v := slots[SlotMismatchedWorkflow]; v != "" {
workflows = strings.Split(v, ",")
}
return workflowScopeGuidanceQuestion(slots[SlotMismatchedDoc], workflows, activeWorkflow)
}
// fillTargetDocSlot 按工作流作用域规则填充 target_doc 槽位:
// 显式点名他流文档 → 不填 target_doc,改打 workflow_scope_mismatch 标记;
// 否则保持原逻辑(显式名 → ActiveDocID 回退)。
func fillTargetDocSlot(slots map[string]string, input string, userCtx *ContextSnapshot) {
if slots == nil {
return
}
active := ""
if userCtx != nil {
active = userCtx.ActiveWorkflowKey
}
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 != "" {
slots[SlotTargetDoc] = doc
return
}
if userCtx != nil && userCtx.ActiveDocID != "" {
slots[SlotTargetDoc] = userCtx.ActiveDocID
}
}
// WorkflowScopeGuidanceQuestion 生成「该文档属于 XX 工作流,请先切换」引导话术。
func WorkflowScopeGuidanceQuestion(doc string, workflows []string, activeWorkflow string) string {
return workflowScopeGuidanceQuestion(doc, workflows, activeWorkflow)
}
// WorkflowDisplayName 返回工作流键的中文展示名。
func WorkflowDisplayName(key string) string {
return workflowDisplayName(key)
}
// TaskKeyWorkflowOwner 按任务键推断归属工作流(旧键与新键均可;未知返回空)。
func TaskKeyWorkflowOwner(taskKey string) string {
return taskKeyWorkflowOwner(taskKey)
}
// workflowScopeGuidanceQuestion 生成「该文档属于 XX 工作流,请先切换」引导话术。
func workflowScopeGuidanceQuestion(doc string, workflows []string, activeWorkflow string) string {
names := make([]string, 0, len(workflows))
for _, wf := range workflows {
names = append(names, workflowDisplayName(wf))
}
owner := "其他工作流"
if len(names) > 0 {
owner = strings.Join(names, " / ")
}
return fmt.Sprintf(
"您提到的文档「%s」属于【%s】,当前激活工作流为【%s】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。",
doc, owner, workflowDisplayName(activeWorkflow),
)
}
func workflowDisplayName(key string) string {
switch key {
case "main":
return "商务对接流程"
case "product_manager":
return "智能产品经理流程"
case "test_manager":
return "智能测试工程师流程"
case "rdev":
return "研发设计控制流程"
case "software_bootstrap":
return "软件项目自举流程"
case "software_delivery":
return "软件交付迭代流程"
default:
return key
}
}
// activeWorkflowKeyOf 从上下文快照安全读取激活工作流键。
func activeWorkflowKeyOf(userCtx *ContextSnapshot) string {
if userCtx == nil {
return ""
}
return userCtx.ActiveWorkflowKey
}
// docGovActionKeywords 触发受控文档治理写动作的关键词。
var docGovActionKeywords = []string{
"驳回", "退回", "终审批准", "批准", "审核通过", "审阅通过", "评审通过", "准予",
"认领", "送审", "提交审核", "发起审核", "提审",
}
func hasDocGovActionKeyword(input string) bool {
for _, k := range docGovActionKeywords {
if strings.Contains(input, k) {
return true
}
}
return false
}
// docGovScopePreempt 治理写动作显式点名他流文档时,前置剪枝为跨工作流拦截,
// 不落入任何 DOCGOV_* 写意图。无激活工作流(存量单流 / planning 态)时不拦截。
func docGovScopePreempt(input string, userCtx *ContextSnapshot) *CandidateIntent {
active := activeWorkflowKeyOf(userCtx)
if active == "" || !hasDocGovActionKeyword(input) {
return nil
}
doc, owners, mismatched := docWorkflowScopeMarker(input, active)
if !mismatched {
return nil
}
slots := make(map[string]string)
markWorkflowScopeMismatch(slots, doc, owners)
return &CandidateIntent{
Intent: IntentUnsupportedAction,
Confidence: 0.94,
Branch: BranchNewIntentPreempt,
FilledSlots: slots,
Reasoning: fmt.Sprintf("受控文档治理动作显式点名他流文档「%s」,前置剪枝为跨工作流拦截", doc),
ClarificationQuestion: workflowScopeGuidanceQuestion(doc, owners, active),
}
}
// docNameIDPattern 匹配显式文档 ID(doc-xxx 或 doc_projN_xxx)。
var docNameIDPattern = regexp.MustCompile(`(doc[_-][a-zA-Z0-9_-]+)`)
// hasExplicitDocID 判断输入是否携带显式文档 ID(视为用户精确指向,交由消费端裁决)。
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 {
if strings.Contains(input, "查看") || strings.Contains(input, "读取") {
return true
}
// 「看看」常跟在「出一版…看看」后面,表示写完给看,不是只读查看。
if !strings.Contains(input, "看看") {
return false
}
for _, marker := range []string{"编写", "编制", "生成", "起草", "创建", "撰写", "重写", "更新", "修改", "完善", "补充", "刷新", "重做", "出", "做", "跑"} {
if strings.Contains(input, marker) {
return false
}
}
return true
}