From fc432d872651a7c7938afa0dd7b0f8734f4b81b3 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 06:52:30 +0000 Subject: [PATCH 1/5] docs(ops): claim intent workflow scoping on a fix branch Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 1 + .../2026-09-21-intent-workflow-scoping.md | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 .doc/ops/short-term/2026-09-21-intent-workflow-scoping.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index ef0dc44c..22237150 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-21-intent-workflow-scoping | fix/intent-workflow-scoping-2026-09-21 | - | intent+web+docgov | in_progress | | Cursor | 2026-09-21-workflow-doc-key-unification | fix/workflow-doc-key-unification-2026-09-21 | .worktrees/fix-workflow-doc-key-unification-2026-09-21 | docgov+web | done | | Cursor | 2026-09-21-dev-deep-test-four-issues | fix/dev-deep-test-four-issues-2026-09-21 | - | web+intent+taskrun | done | | Cursor | 2026-09-21-trace-panel-to-chat | feature/trace-panel-to-chat-2026-09-21 | - | trace-impact+web | done | diff --git a/.doc/ops/short-term/2026-09-21-intent-workflow-scoping.md b/.doc/ops/short-term/2026-09-21-intent-workflow-scoping.md new file mode 100644 index 00000000..db775adf --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-intent-workflow-scoping.md @@ -0,0 +1,68 @@ +--- +title: "意图填槽工作流作用域修复" +status: in_progress +created: 2026-09-21 +updated: 2026-09-21 +owner: Cursor +branch: fix/intent-workflow-scoping-2026-09-21 +worktree: "-" +mutex: intent+web+docgov +--- + +# 意图填槽工作流作用域修复 + +## 目标 + +意图识别填槽(target_doc / target_task)只命中当前激活工作流的文档与任务;显式点名其他工作流的文档时拦截并引导切换工作流,不误触、不执行。 + +## 背景 + +用户反馈:填「要修改的文档」等槽位时可能命中其他工作流的文档。根因链(2026-09-21 调研): + +1. `intent.ContextSnapshot` 无 workflow 字段,`ToSnapshot()` 丢弃 `Project.ActiveWorkflowKey`;server 默认上下文硬编码 `CurrentWorkflow: "intake"` +2. `extractDocName` 词表全是 main(bd_)流文档名,无按流归属约束 +3. 消费端匹配池 `ListWorkflowDocumentsByProject` 仅按 project_id 过滤(跨工作流全量),`selectReadableWorkflowDocument` 双向 contains 模糊匹配取首个命中 +4. 前端 `resolveTaskTemplateForMessage` 匹配池 `currentProjectDeliverables` 仅按 projectId 过滤;`activeWorkflowKey` 只用于徽标展示 + +## 范围 + +- 批次1 `feat(intent)`:`ContextSnapshot` 增加 `ActiveWorkflowKey`;`ToSnapshot` 下传;docgov 命中他流文档名时打 `workflow_scope_mismatch` 标记不误填 +- 批次2 `feat(coordinator+server+projectinstance)`:`ActiveWorkflowTaskKeys` 辅助查询;文档匹配池收窄到激活流;`ErrDocWrongWorkflow` 拦截引导;TRIGGER_TASK 任务键归属校验;enrich 时注入真实 `active_workflow_key` +- 批次3 `feat(web)`:`DeliverableItem.workflowKey`;文档列表 API 附带 `workflow_key` 与 `active_workflow_key`;templateMatch 匹配池按激活流过滤,他流点名返回 wrongWorkflow 提示 +- 批次4:真实环境验收 + 测试报告 + 合回 dev + +## 非目标 + +- 不迁移 `document_records` 表(不加 workflow_instance_id 列) +- 不改 `production_ready`、不晋升 test/main、不动项目 143 +- 不改 SWITCH_WORKFLOW 意图本身行为 +- UI 文档展示列表保持全量(仅匹配池收窄) + +## 渐进安全策略 + +`active_workflow_key` 为空或查不到激活实例(存量单流项目、planning 态)→ 不过滤,保持现状行为。 + +## 步骤 + +1. [ ] 批次1:intent 快照与工作流词表归属 +2. [ ] 批次2:服务端匹配池收窄与拦截引导 +3. [ ] 批次3:前端模板匹配池收窄 +4. [ ] 批次4:真实环境验收、报告归档、合回 dev + +## 验收标准 + +- [ ] 激活 main 流说「修改调研报告」→ 命中 main 流文档(正例) +- [ ] 激活 main 流说「编写系统需求规格书」(pm/rd 流文档)→ 拦截 + 引导切换(反例) +- [ ] 激活 pm/rdev 流后同名操作 → 正常执行(切换后正例) +- [ ] 存量无 active_workflow_key 项目 → 行为不变(回归) +- [ ] `go vet ./...`、`go test -short ./...`、`web: npm test && npm run build` 全绿 + +## 相关链接 + +- Plan: `~/.cursor/plans/意图填槽工作流作用域修复_dba2ab4c.plan.md` +- Handoff: +- Test report: `.doc/test-reports/2026-09-21-intent-workflow-scoping-test-report.md` + +## 证据 / 备注 + +用户在 dev 商务对接实测发现跨工作流误命中。用户在方案确认时选定:严格拦截 + 引导切换;等四问题修复 mutex 释放后独立分支推进。 From 5c6b4f7719557600523e27dfe0117b3fd7be8132 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 07:38:49 +0000 Subject: [PATCH 2/5] feat(intent): scope slot filling to the active workflow - ContextSnapshot gains ActiveWorkflowKey; ToSnapshot passes it downstream - manifest-backed doc-title -> workflow index (main/pm/tm/rdev/sw) - TRIGGER_TASK and DOCGOV write actions blocked with switch guidance when the named document belongs to another active workflow - legacy projects without an active workflow key keep the old behavior Co-authored-by: Cursor --- internal/coordinator/context.go | 1 + internal/intent/doc_workflow_scope.go | 396 +++++++++++++++++++++ internal/intent/doc_workflow_scope_test.go | 126 +++++++ internal/intent/recognizer.go | 71 +++- internal/intent/types.go | 6 + 5 files changed, 595 insertions(+), 5 deletions(-) create mode 100644 internal/intent/doc_workflow_scope.go create mode 100644 internal/intent/doc_workflow_scope_test.go diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go index 3bf4e30f..af70dd21 100644 --- a/internal/coordinator/context.go +++ b/internal/coordinator/context.go @@ -190,6 +190,7 @@ func (ctx *UserProjectContext) ToSnapshot() *intent.ContextSnapshot { SnapshotID: ctx.SnapshotID, ProjectName: ctx.Project.Name, CurrentStage: string(ctx.Project.CurrentStage), + ActiveWorkflowKey: ctx.Project.ActiveWorkflowKey, AvailableActions: ctx.AvailableActions, PendingExpectation: ctx.PendingExpectation, } diff --git a/internal/intent/doc_workflow_scope.go b/internal/intent/doc_workflow_scope.go new file mode 100644 index 00000000..7e432453 --- /dev/null +++ b/internal/intent/doc_workflow_scope.go @@ -0,0 +1,396 @@ +package intent + +import ( + "fmt" + "regexp" + "sort" + "strings" + "sync" + "unicode/utf8" + + "chat-agent-system/internal/squad" + "chat-agent-system/internal/workflowdef" +) + +// 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 检查输入是否显式点名了非激活工作流的受控文档。 +// 返回 (他流文档标题, 归属工作流 key 列表, 是否命中跨流)。 +// activeWorkflow 为空(存量单流 / planning 态)时一律不判跨流,保持现状行为。 +func docWorkflowScopeMarker(input, activeWorkflow string) (string, []string, bool) { + if activeWorkflow == "" { + 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 + } + } + return "", nil, false +} + +// 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 + } + if doc, wfs, mismatched := docWorkflowScopeMarker(input, active); mismatched { + markWorkflowScopeMismatch(slots, doc, wfs) + 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 { + 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) != "" +} diff --git a/internal/intent/doc_workflow_scope_test.go b/internal/intent/doc_workflow_scope_test.go new file mode 100644 index 00000000..5818a77b --- /dev/null +++ b/internal/intent/doc_workflow_scope_test.go @@ -0,0 +1,126 @@ +package intent_test + +import ( + "context" + "strings" + "testing" + + "chat-agent-system/internal/intent" +) + +// ---------------------------------------------------------------------------------- +// 工作流作用域填槽:target_doc / target_task 只命中激活工作流;跨流点名拦截引导 +// ---------------------------------------------------------------------------------- + +func TestWorkflowScope_TriggerTaskInActiveFlow(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{SnapshotID: "s_bd", ActiveWorkflowKey: "main"} + + // 激活商务流:调研报告属 main,正常填 task + res, err := rec.Recognize(context.Background(), "帮我生成调研报告", ctx) + if err != nil { + t.Fatal(err) + } + if res.TopIntent.Intent != intent.IntentTriggerTask { + t.Fatalf("want TRIGGER_TASK, got %s", res.TopIntent.Intent) + } + if got := res.TopIntent.FilledSlots[intent.SlotTargetTask]; got != "research" { + t.Fatalf("in-scope research want research, got %q", got) + } + if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] != "" { + t.Fatalf("in-scope task must not carry mismatch marker") + } +} + +func TestWorkflowScope_TriggerTaskCrossFlowBlocked(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{SnapshotID: "s_bd2", ActiveWorkflowKey: "main"} + + // 激活商务流却说「编写项目建议书」(研发流 rd_proposal)→ 拦截引导 + res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx) + if err != nil { + t.Fatal(err) + } + slots := res.TopIntent.FilledSlots + if slots[intent.SlotWorkflowScopeMismatch] != "true" { + t.Fatalf("cross-flow write must be blocked, slots=%v", slots) + } + if slots[intent.SlotTargetTask] != "" || slots[intent.SlotTargetDoc] != "" { + t.Fatalf("cross-flow must not fill target_doc/target_task, got %v", slots) + } + if !strings.Contains(res.TopIntent.ClarificationQuestion, "切换") { + t.Fatalf("guidance should mention 切换, got %q", res.TopIntent.ClarificationQuestion) + } + if !strings.Contains(res.TopIntent.ClarificationQuestion, "研发设计控制流程") { + t.Fatalf("guidance should name owning flow, got %q", res.TopIntent.ClarificationQuestion) + } +} + +func TestWorkflowScope_TriggerTaskAfterSwitch(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{SnapshotID: "s_rd", ActiveWorkflowKey: "rdev"} + + // 激活研发流后同名操作 → 正常执行 + res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx) + if err != nil { + t.Fatal(err) + } + if res.TopIntent.Intent != intent.IntentTriggerTask { + t.Fatalf("want TRIGGER_TASK after switch, got %s", res.TopIntent.Intent) + } + if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { + t.Fatalf("must not block in owning flow, slots=%v", res.TopIntent.FilledSlots) + } + if got := res.TopIntent.FilledSlots[intent.SlotTargetTask]; got != "rd_proposal" { + t.Fatalf("want rd_proposal, got %q", got) + } +} + +func TestWorkflowScope_DocGovWriteCrossFlowBlocked(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + SnapshotID: "s_pm", + ActiveWorkflowKey: "product_manager", + UserDocRole: "reviewer", + } + + // 激活产品经理流,却驳回商务流文档「调研报告」→ 治理写动作被前置剪枝拦截 + res, err := rec.Recognize(context.Background(), "把调研报告审核驳回", ctx) + if err != nil { + t.Fatal(err) + } + if res.TopIntent.Intent == intent.IntentDocGovReviewReject { + t.Fatalf("cross-flow governance reject must be blocked, got %s", res.TopIntent.Intent) + } + if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] != "true" { + t.Fatalf("expected mismatch marker, slots=%v", res.TopIntent.FilledSlots) + } + if !res.RequiresClarifying { + t.Fatalf("cross-flow block must require clarification") + } +} + +func TestWorkflowScope_LegacyNoActiveWorkflowUnchanged(t *testing.T) { + rec := intent.NewRecognizer() + // 存量单流 / planning 态:无激活工作流 → 不过滤,行为与修复前一致 + ctx := &intent.ContextSnapshot{SnapshotID: "s_legacy"} + + res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx) + if err != nil { + t.Fatal(err) + } + if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { + t.Fatalf("no active workflow must never block, got %v", res.TopIntent.FilledSlots) + } + if res.TopIntent.Intent != intent.IntentTriggerTask { + t.Fatalf("want TRIGGER_TASK passthrough, got %s", res.TopIntent.Intent) + } +} + +func TestWorkflowScope_SnapshotCarriesActiveWorkflowKey(t *testing.T) { + // ContextSnapshot 现在必须能携带激活工作流键,供消费端与识别器共同约束。 + snap := intent.ContextSnapshot{ActiveWorkflowKey: "main"} + if snap.ActiveWorkflowKey != "main" { + t.Fatalf("ActiveWorkflowKey must be settable, got %q", snap.ActiveWorkflowKey) + } +} diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index 69587d1c..ca64939c 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -197,7 +197,7 @@ func (r *RuleBasedRecognizer) recognizeCore(ctx context.Context, input string, u } // 3.3 交付任务触发 (TRIGGER_TASK) - if cand := matchTriggerTask(trimmed); cand != nil { + if cand := matchTriggerTask(trimmed, userCtx); cand != nil { cand.Branch = BranchNewIntentPreempt candidates = append(candidates, *cand) } @@ -415,8 +415,16 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent // 0. 询问驳回原因:归属 DECISION_QUERY,不得路由为 DOCGOV_*_REJECT if (strings.Contains(input, "驳回") || strings.Contains(input, "退回")) && isRejectReasonQuery(input) { slots := make(map[string]string) - if doc := resolveTargetDoc(input, userCtx); doc != "" { - slots[SlotTargetDoc] = doc + fillTargetDocSlot(slots, input, userCtx) + if q := scopeMismatchClarify(slots, activeWorkflowKeyOf(userCtx)); q != "" { + return &CandidateIntent{ + Intent: IntentQueryRejectReason, + Confidence: 0.96, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: "用户询问的受控文档属于非激活工作流,前置剪枝为只读询问并给出切换引导", + ClarificationQuestion: q, + } } return &CandidateIntent{ Intent: IntentQueryRejectReason, @@ -426,6 +434,11 @@ func matchDocGovAction(input string, userCtx *ContextSnapshot) *CandidateIntent } } + // 跨工作流作用域前置剪枝:治理写动作显式点名他流文档时,拦截并引导切换,不落入任何 DOCGOV_* 写意图。 + if cand := docGovScopePreempt(input, userCtx); cand != nil { + return cand + } + // 1. 驳回 (Review Reject or Final Reject) if strings.Contains(input, "驳回") { isFinal := false @@ -1130,7 +1143,7 @@ func matchSupplementInfo(input string) *CandidateIntent { } func matchQueryProgress(input string) *CandidateIntent { - if matchTriggerTask(input) != nil { + if matchTriggerTask(input, nil) != nil { return nil } if isWorkflowControlInput(input) || isRunControlInput(input) { @@ -1186,7 +1199,7 @@ func hasTaskWriteVerb(input string) bool { return false } -func matchTriggerTask(input string) *CandidateIntent { +func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent { type phrase struct { text string key string @@ -1224,16 +1237,64 @@ func matchTriggerTask(input string) *CandidateIntent { } // 「初始 / 首轮 / 摸底」是 V0.1 摸底,不能被更短的研发别名「用户需求」截走。 if hasIntakeSemanticMarker(input) && strings.Contains(input, "需求") { + if cand := scopedTriggerTaskCandidate(input, "需求表", "intake", userCtx); cand != nil { + return cand + } return triggerTaskCandidate("需求表", "intake") } for _, item := range phrases { if strings.Contains(input, item.text) { + if cand := scopedTriggerTaskCandidate(input, item.text, item.key, userCtx); cand != nil { + return cand + } return triggerTaskCandidate(item.text, item.key) } } return nil } +// scopedTriggerTaskCandidate 若目标任务键不属于当前激活工作流,返回跨流拦截候选; +// 无激活工作流(存量单流 / planning 态)或归属未知时返回 nil,维持原路由。 +func scopedTriggerTaskCandidate(input, phrase, taskKey string, userCtx *ContextSnapshot) *CandidateIntent { + active := "" + if userCtx != nil { + active = userCtx.ActiveWorkflowKey + } + owners, ok := docScopeForPhrase(phrase) + if !ok { + if owner := taskKeyWorkflowOwner(taskKey); owner != "" { + owners = []string{owner} + ok = true + } + } + if !ok || active == "" || containsWorkflow(owners, active) { + return nil + } + return workflowScopeCandidate(phrase, owners, active) +} + +func workflowScopeCandidate(phrase string, owners []string, activeWorkflow string) *CandidateIntent { + slots := make(map[string]string) + markWorkflowScopeMismatch(slots, phrase, owners) + return &CandidateIntent{ + Intent: IntentTriggerTask, + Confidence: 0.86, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: fmt.Sprintf("用户要求生成交付任务 %s,但该文档属于非激活工作流,前置剪枝", phrase), + ClarificationQuestion: workflowScopeGuidanceQuestion(phrase, owners, activeWorkflow), + } +} + +func containsWorkflow(workflows []string, key string) bool { + for _, wf := range workflows { + if wf == key { + return true + } + } + return false +} + func hasIntakeSemanticMarker(input string) bool { return strings.Contains(input, "首轮") || strings.Contains(input, "初始") || diff --git a/internal/intent/types.go b/internal/intent/types.go index 21ba556e..7ce3e7bb 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -157,6 +157,11 @@ const ( // 工作流槽位 SlotTargetWorkflow = "target_workflow" // product_manager | test_manager | main + // 工作流作用域标记:填槽显式点名了非激活工作流的文档时打标引导切换,不误填 target_doc + SlotWorkflowScopeMismatch = "workflow_scope_mismatch" // "true" + SlotMismatchedWorkflow = "mismatched_workflow" // 文档归属工作流 key(多个用逗号连接) + SlotMismatchedDoc = "mismatched_doc" // 被显式点名的他流文档标题 + // 长任务/运行槽位 SlotWorkDir = "work_dir" SlotRunAction = "run_action" // status | resume | restart @@ -194,6 +199,7 @@ type ContextSnapshot struct { SnapshotID string `json:"snapshot_id,omitempty"` ProjectName string `json:"project_name,omitempty"` CurrentStage string `json:"current_stage,omitempty"` + ActiveWorkflowKey string `json:"active_workflow_key,omitempty"` ActiveDocID string `json:"active_doc_id,omitempty"` DocLifecycleState string `json:"doc_lifecycle_state,omitempty"` UserDocRole string `json:"user_doc_role,omitempty"` From 0912efdbd9346e61300b35961acccd1ec16bef0a Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 08:05:44 +0000 Subject: [PATCH 3/5] feat(coordinator+server): narrow document match pool to active workflow - ActiveWorkflowScope resolves live instance task keys for the project - chat document reader matches inside the active pool; cross-flow hits return ErrWrongWorkflow with switch guidance - enrichSessionWorkflowStage injects ActiveWorkflowKey into UserCtx - TRIGGER_TASK (coordinator + task-run API) blocks out-of-scope task keys Co-authored-by: Cursor --- internal/coordinator/context.go | 19 +++ internal/coordinator/document_summary.go | 3 + internal/coordinator/engine.go | 39 +++++ internal/intent/doc_workflow_scope.go | 15 ++ internal/projectinstance/workflow_scope.go | 71 ++++++++ .../projectinstance/workflow_scope_test.go | 45 ++++++ internal/server/chat_document_reader.go | 153 +++++++++++++++++- internal/server/chat_document_reader_test.go | 48 +++++- internal/server/handlers.go | 8 + internal/server/stage_access_helpers.go | 11 ++ 10 files changed, 403 insertions(+), 9 deletions(-) create mode 100644 internal/projectinstance/workflow_scope.go create mode 100644 internal/projectinstance/workflow_scope_test.go diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go index af70dd21..18267a34 100644 --- a/internal/coordinator/context.go +++ b/internal/coordinator/context.go @@ -3,6 +3,7 @@ package coordinator import ( "context" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -75,6 +76,24 @@ type ReadableDocument struct { // 协调层不得直接枚举文档,以免绕过阶段可见性规则。 type DocumentSummaryReader func(ctx context.Context, target string) (*ReadableDocument, error) +// ErrWrongWorkflow 表示目标文档存在但不属于当前激活工作流。 +// 服务端文档读取器按激活流收窄匹配池后,用该错误携带「请先切换工作流」引导话术, +// 协调层识别后原样回复,不执行任何读/写动作。 +type ErrWrongWorkflow struct { + Message string +} + +func (e *ErrWrongWorkflow) Error() string { return e.Message } + +// WrongWorkflowGuidance 若错误链上是跨工作流拦截,返回引导话术;否则空串。 +func WrongWorkflowGuidance(err error) string { + var target *ErrWrongWorkflow + if errors.As(err, &target) && target != nil && strings.TrimSpace(target.Message) != "" { + return target.Message + } + return "" +} + // UserProjectContext 用户项目级全量统一上下文 (对话系统与执行系统共同消费) type UserProjectContext struct { mu sync.Mutex diff --git a/internal/coordinator/document_summary.go b/internal/coordinator/document_summary.go index 1ab8d0e9..04bcd231 100644 --- a/internal/coordinator/document_summary.go +++ b/internal/coordinator/document_summary.go @@ -28,6 +28,9 @@ func (e *CoordinatorEngine) handleDocumentSummary( doc, err := userCtx.DocumentSummaryReader(ctx, target) if err != nil || doc == nil { + if guidance := WrongWorkflowGuidance(err); guidance != "" { + return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil + } return documentSummaryReply(userCtx, "未找到可访问的目标文档,或您当前无权查看该阶段内容。请确认文档名称后重试。", ReplySourceTemplate), nil } if doc.IsTemplateOnly { diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index b88aa9e0..ab466d78 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -928,11 +928,50 @@ func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent } func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { + // 跨工作流作用域:识别器已打 mismatch 标记时直接引导切换,不执行任务。 + if cand != nil && cand.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { + active := "" + if userCtx != nil { + active = userCtx.Project.ActiveWorkflowKey + } + guidance := cand.ClarificationQuestion + if guidance == "" { + workflows := []string{} + if v := cand.FilledSlots[intent.SlotMismatchedWorkflow]; v != "" { + workflows = strings.Split(v, ",") + } + guidance = intent.WorkflowScopeGuidanceQuestion(cand.FilledSlots[intent.SlotMismatchedDoc], workflows, active) + } + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: cand.Intent, + Branch: cand.Branch, + ClarificationPrompt: guidance, + NaturalReply: guidance, + UpdatedContext: userCtx, + }, nil + } + taskKey := cand.FilledSlots[intent.SlotTargetTask] if taskKey == "" { taskKey = "intake" } + // 消费端二次校验:任务键不属于激活工作流时拦截(防御识别器漏标)。 + if userCtx != nil && userCtx.Project.ActiveWorkflowKey != "" { + if owner := intent.TaskKeyWorkflowOwner(taskKey); owner != "" && owner != userCtx.Project.ActiveWorkflowKey { + guidance := intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, userCtx.Project.ActiveWorkflowKey) + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: cand.Intent, + Branch: cand.Branch, + ClarificationPrompt: guidance, + NaturalReply: guidance, + UpdatedContext: userCtx, + }, nil + } + } + nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) userCtx.applyPending(nextExp) diff --git a/internal/intent/doc_workflow_scope.go b/internal/intent/doc_workflow_scope.go index 7e432453..a82af48b 100644 --- a/internal/intent/doc_workflow_scope.go +++ b/internal/intent/doc_workflow_scope.go @@ -306,6 +306,21 @@ func fillTargetDocSlot(slots map[string]string, input string, userCtx *ContextSn } } +// 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)) diff --git a/internal/projectinstance/workflow_scope.go b/internal/projectinstance/workflow_scope.go new file mode 100644 index 00000000..883bdcf0 --- /dev/null +++ b/internal/projectinstance/workflow_scope.go @@ -0,0 +1,71 @@ +package projectinstance + +import ( + "context" + + "chat-agent-system/internal/workflowdef" +) + +// ActiveWorkflowScope 返回项目当前激活工作流的键与文档任务键集合。 +// +// 语义(供意图填槽与文档正文读取收窄作用域): +// - 从未激活(无 live 实例)→ workflowKey 为空、taskKeys 为 nil → 调用方不得过滤(legacy / planning) +// - live 系实例 → workflowKey 取自 projects.active_workflow_key(回落实例快照定义键), +// taskKeys 收集全部启用文档任务,并补齐旧键/新键双向别名 +// - completed / terminated 不属于激活:active_workflow_key 已在终止时清空,视为未激活 +// - 存储错误原样上抛(fail-closed) +func (s *Service) ActiveWorkflowScope(ctx context.Context, projectID int64) (workflowKey string, taskKeys map[string]bool, err error) { + if s == nil || s.store == nil || projectID <= 0 { + return "", nil, nil + } + _, activeKey, _, _, err := s.store.GetProjectMeta(ctx, projectID) + if err != nil { + return "", nil, err + } + if activeKey == "" { + // 未激活:不过滤,保持现状行为。 + return "", nil, nil + } + live, err := s.store.GetLiveInstance(ctx, projectID) + if err != nil { + return "", nil, err + } + if live == nil { + // 有激活键但无 live 快照:仅返回键供话术,任务集合交由调用方按空处理(不过滤)。 + return activeKey, nil, nil + } + if live.ConfigSnapshot.WorkflowDefinitionKey != "" { + workflowKey = live.ConfigSnapshot.WorkflowDefinitionKey + } else { + workflowKey = activeKey + } + taskKeys = make(map[string]bool) + for _, st := range live.ConfigSnapshot.Stages { + if !st.Enabled { + continue + } + for _, dt := range st.DocTasks { + if !dt.Enabled || dt.TaskKey == "" { + continue + } + taskKeys[dt.TaskKey] = true + // 补齐旧键/新键双向别名,冻结实例与发布清单互指。 + if canonical := workflowdef.CanonicalTaskKey(dt.TaskKey); canonical != dt.TaskKey { + taskKeys[canonical] = true + } + if legacy := workflowdef.ImplementationTaskKey(dt.TaskKey); legacy != dt.TaskKey { + taskKeys[legacy] = true + } + } + } + return workflowKey, taskKeys, nil +} + +// TaskKeyInScope 判断任务键是否落在激活工作流任务集合内。 +// taskKeys 为空(未激活 / 无快照)时一律放行,保持现状。 +func TaskKeyInScope(taskKeys map[string]bool, taskKey string) bool { + if len(taskKeys) == 0 { + return true + } + return mapHasTaskKey(taskKeys, taskKey) +} diff --git a/internal/projectinstance/workflow_scope_test.go b/internal/projectinstance/workflow_scope_test.go new file mode 100644 index 00000000..cb7578a2 --- /dev/null +++ b/internal/projectinstance/workflow_scope_test.go @@ -0,0 +1,45 @@ +package projectinstance_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/projectinstance" +) + +func TestActiveWorkflowScope_NoLiveInstanceReturnsEmpty(t *testing.T) { + store := projectinstance.NewMemoryStore() + if err := projectinstance.SeedProjectMeta(store, 99, projectinstance.LifecyclePlanning, "", 1, 0); err != nil { + t.Fatalf("seed: %v", err) + } + svc := projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil) + key, tasks, err := svc.ActiveWorkflowScope(context.Background(), 99) + if err != nil { + t.Fatalf("scope: %v", err) + } + if key != "" || tasks != nil { + t.Fatalf("unactivated project must return empty scope, got key=%q tasks=%v", key, tasks) + } +} + +func TestTaskKeyInScope_EmptyAllowsAll(t *testing.T) { + if !projectinstance.TaskKeyInScope(nil, "anything") { + t.Fatal("nil scope must allow") + } + if !projectinstance.TaskKeyInScope(map[string]bool{}, "anything") { + t.Fatal("empty scope must allow") + } +} + +func TestTaskKeyInScope_CanonicalAlias(t *testing.T) { + scope := map[string]bool{"bd_research": true, "research": true} + if !projectinstance.TaskKeyInScope(scope, "research") { + t.Fatal("legacy alias research should be in scope") + } + if !projectinstance.TaskKeyInScope(scope, "bd_research") { + t.Fatal("canonical bd_research should be in scope") + } + if projectinstance.TaskKeyInScope(scope, "rd_proposal") { + t.Fatal("rd_proposal must be out of scope") + } +} diff --git a/internal/server/chat_document_reader.go b/internal/server/chat_document_reader.go index 059d9cb6..284618f4 100644 --- a/internal/server/chat_document_reader.go +++ b/internal/server/chat_document_reader.go @@ -10,6 +10,8 @@ import ( "chat-agent-system/internal/chat" "chat-agent-system/internal/coordinator" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/intent" + "chat-agent-system/internal/projectinstance" ) // bindSessionDocumentSummaryReader 将正文访问绑定到已认证的用户与项目。 @@ -40,7 +42,13 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth return nil, err } docs = s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, docs) - doc, err := selectReadableWorkflowDocument(docs, target) + // 工作流作用域:仅在激活工作流的文档池内解析正文;跨流点名返回可解释的引导错误。 + // 激活键由 enrichSessionWorkflowStage 每轮刷新;任务集合按 live 实例快照解析。 + workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID) + if sess.UserCtx != nil && sess.UserCtx.Project.ActiveWorkflowKey != "" { + workflowKey = sess.UserCtx.Project.ActiveWorkflowKey + } + doc, err := selectReadableWorkflowDocument(docs, target, workflowKey, scopeTaskKeys) if err != nil { return nil, err } @@ -60,21 +68,152 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth } } -func selectReadableWorkflowDocument(docs []docgov.WorkflowDocumentSummary, target string) (*docgov.WorkflowDocumentSummary, error) { +// activeWorkflowScopeForDocs 读取项目激活工作流键与任务集合;失败时返回空(= 不过滤)。 +func (s *Server) activeWorkflowScopeForDocs(ctx context.Context, projectID int64) (string, map[string]bool) { + if s == nil || s.instSvc == nil || !s.instSvc.Available() || projectID <= 0 { + return "", nil + } + workflowKey, taskKeys, err := s.instSvc.ActiveWorkflowScope(ctx, projectID) + if err != nil { + return "", nil + } + return workflowKey, taskKeys +} + +// taskKeyOutsideActiveWorkflow 判断任务键是否落在激活工作流之外。 +// 未激活 / 任务键未知归属时放行(存量兼容);跨流时返回引导话术。 +func (s *Server) taskKeyOutsideActiveWorkflow(ctx context.Context, projectID int64, taskKey string) (bool, string) { + taskKey = strings.TrimSpace(taskKey) + if taskKey == "" || projectID <= 0 { + return false, "" + } + workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID) + if workflowKey == "" { + return false, "" + } + if len(scopeTaskKeys) > 0 { + if projectinstance.TaskKeyInScope(scopeTaskKeys, taskKey) { + return false, "" + } + owner := intent.TaskKeyWorkflowOwner(taskKey) + if owner == "" || owner == workflowKey { + return false, "" + } + return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey) + } + owner := intent.TaskKeyWorkflowOwner(taskKey) + if owner == "" || owner == workflowKey { + return false, "" + } + return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey) +} + +// errDocWrongWorkflow 目标文档存在但不属于当前激活工作流。 +type errDocWrongWorkflow struct { + Title string + TaskKey string + OwnerWorkflow string + ActiveWorkflow string +} + +func (e *errDocWrongWorkflow) Error() string { + if e == nil { + return "document belongs to another workflow" + } + owners := []string{} + if e.OwnerWorkflow != "" { + owners = []string{e.OwnerWorkflow} + } + doc := e.Title + if doc == "" { + doc = e.TaskKey + } + return intent.WorkflowScopeGuidanceQuestion(doc, owners, e.ActiveWorkflow) +} + +func (e *errDocWrongWorkflow) AsCoordinatorErr() error { + return &coordinator.ErrWrongWorkflow{Message: e.Error()} +} + +// selectReadableWorkflowDocument 在可读文档池中解析目标。 +// scopeTaskKeys 非空时先在激活流池内匹配;未命中但精确/归一化匹配到被剔除的他流文档时返回 ErrWrongWorkflow。 +// scopeTaskKeys 为空(未激活 / planning)时保持现状:在全量池内匹配。 +func selectReadableWorkflowDocument( + docs []docgov.WorkflowDocumentSummary, + target string, + activeWorkflow string, + scopeTaskKeys map[string]bool, +) (*docgov.WorkflowDocumentSummary, error) { if len(docs) == 0 { return nil, fmt.Errorf("no readable workflow documents") } targetKey := normalizeDocumentLookupKey(target) if targetKey == "" { - if len(docs) == 1 { - return &docs[0], nil + inScope := filterDocsByTaskKeys(docs, scopeTaskKeys) + pool := inScope + if len(scopeTaskKeys) == 0 { + pool = docs + } + if len(pool) == 1 { + return &pool[0], nil } return nil, fmt.Errorf("document target required") } + inScope := filterDocsByTaskKeys(docs, scopeTaskKeys) + pool := inScope + if len(scopeTaskKeys) == 0 { + pool = docs + } + if hit := matchDocumentInPool(pool, target, targetKey); hit != nil { + return hit, nil + } + // 激活流池未命中:检查是否精确/归一化命中被剔除的他流文档。 + if len(scopeTaskKeys) > 0 { + outOfScope := filterDocsOutsideTaskKeys(docs, scopeTaskKeys) + if hit := matchDocumentInPool(outOfScope, target, targetKey); hit != nil { + owner := intent.TaskKeyWorkflowOwner(hit.TaskKey) + return nil, (&errDocWrongWorkflow{ + Title: hit.Title, + TaskKey: hit.TaskKey, + OwnerWorkflow: owner, + ActiveWorkflow: activeWorkflow, + }).AsCoordinatorErr() + } + } + return nil, fmt.Errorf("document not found in readable scope") +} + +func filterDocsByTaskKeys(docs []docgov.WorkflowDocumentSummary, taskKeys map[string]bool) []docgov.WorkflowDocumentSummary { + if len(taskKeys) == 0 { + return docs + } + out := make([]docgov.WorkflowDocumentSummary, 0, len(docs)) + for _, d := range docs { + if projectinstance.TaskKeyInScope(taskKeys, d.TaskKey) { + out = append(out, d) + } + } + return out +} + +func filterDocsOutsideTaskKeys(docs []docgov.WorkflowDocumentSummary, taskKeys map[string]bool) []docgov.WorkflowDocumentSummary { + if len(taskKeys) == 0 { + return nil + } + out := make([]docgov.WorkflowDocumentSummary, 0) + for _, d := range docs { + if !projectinstance.TaskKeyInScope(taskKeys, d.TaskKey) { + out = append(out, d) + } + } + return out +} + +func matchDocumentInPool(docs []docgov.WorkflowDocumentSummary, target, targetKey string) *docgov.WorkflowDocumentSummary { for i := range docs { if target == docs[i].DocID || target == docs[i].TaskKey || target == docs[i].Title { - return &docs[i], nil + return &docs[i] } } for i := range docs { @@ -85,11 +224,11 @@ func selectReadableWorkflowDocument(docs []docgov.WorkflowDocumentSummary, targe } for _, key := range keys { if key != "" && (key == targetKey || strings.Contains(key, targetKey) || strings.Contains(targetKey, key)) { - return &docs[i], nil + return &docs[i] } } } - return nil, fmt.Errorf("document not found in readable scope") + return nil } func normalizeDocumentLookupKey(value string) string { diff --git a/internal/server/chat_document_reader_test.go b/internal/server/chat_document_reader_test.go index 7f5007f1..9db568f4 100644 --- a/internal/server/chat_document_reader_test.go +++ b/internal/server/chat_document_reader_test.go @@ -1,8 +1,11 @@ package server import ( + "errors" + "strings" "testing" + "chat-agent-system/internal/coordinator" "chat-agent-system/internal/docgov" ) @@ -11,7 +14,7 @@ func TestSelectReadableWorkflowDocumentMatchesInitialRequirementsAlias(t *testin {DocID: "doc_proj143_intake", TaskKey: "intake", Title: "用户初始需求收集表"}, {DocID: "doc_proj143_research", TaskKey: "research", Title: "技术与市场调研报告"}, } - doc, err := selectReadableWorkflowDocument(docs, "用户初始需求表") + doc, err := selectReadableWorkflowDocument(docs, "用户初始需求表", "", nil) if err != nil { t.Fatalf("select: %v", err) } @@ -25,7 +28,48 @@ func TestSelectReadableWorkflowDocumentDoesNotGuessWhenTargetMissing(t *testing. {DocID: "doc_a", TaskKey: "intake", Title: "用户初始需求收集表"}, {DocID: "doc_b", TaskKey: "research", Title: "调研报告"}, } - if _, err := selectReadableWorkflowDocument(docs, ""); err == nil { + if _, err := selectReadableWorkflowDocument(docs, "", "", nil); err == nil { t.Fatal("expected target-required error") } } + +func TestSelectReadableWorkflowDocumentScopesToActiveWorkflow(t *testing.T) { + docs := []docgov.WorkflowDocumentSummary{ + {DocID: "doc_proj1_research", TaskKey: "bd_research", Title: "调研报告"}, + {DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"}, + } + scope := map[string]bool{"bd_research": true, "research": true} + + doc, err := selectReadableWorkflowDocument(docs, "调研报告", "main", scope) + if err != nil { + t.Fatalf("in-scope select: %v", err) + } + if doc.TaskKey != "bd_research" { + t.Fatalf("want bd_research, got %s", doc.TaskKey) + } + + _, err = selectReadableWorkflowDocument(docs, "项目建议书", "main", scope) + if err == nil { + t.Fatal("expected wrong-workflow error") + } + var wrong *coordinator.ErrWrongWorkflow + if !errors.As(err, &wrong) { + t.Fatalf("want ErrWrongWorkflow, got %T %v", err, err) + } + if !strings.Contains(wrong.Message, "切换") { + t.Fatalf("guidance should mention 切换, got %q", wrong.Message) + } +} + +func TestSelectReadableWorkflowDocumentNoScopeKeepsLegacy(t *testing.T) { + docs := []docgov.WorkflowDocumentSummary{ + {DocID: "doc_proj1_proposal", TaskKey: "rd_proposal", Title: "项目建议书"}, + } + doc, err := selectReadableWorkflowDocument(docs, "项目建议书", "", nil) + if err != nil { + t.Fatalf("legacy passthrough: %v", err) + } + if doc.TaskKey != "rd_proposal" { + t.Fatalf("want rd_proposal, got %s", doc.TaskKey) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index ac764b63..efe5eb72 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -290,6 +290,14 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { return } } + // 工作流作用域:任务键不属于当前激活工作流时拦截并引导切换(不执行)。 + if blocked, guidance := s.taskKeyOutsideActiveWorkflow(r.Context(), req.ProjectID, req.TaskKey); blocked { + writeJSONResponse(w, http.StatusConflict, map[string]string{ + "error_code": "ERR_DOC_WRONG_WORKFLOW", + "message": guidance, + }) + return + } } // 0. 如果关联了项目,在 projectworkflow 服务中记录运行实例 diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index a3f6285b..3dfdefd1 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -145,6 +145,17 @@ func (s *Server) enrichSessionWorkflowStage(ctx context.Context, sess *chat.Sess if err == nil && settings != nil { sess.ActiveStageKey = settings.ActiveStageKey } + // 工作流作用域:把项目激活工作流注入会话上下文,供 L1 填槽与消费端收窄。 + // 查询失败按空处理(= 不过滤),不得沿用上一轮的过期键。 + if sess.UserCtx != nil { + if err == nil && settings != nil { + sess.UserCtx.Project.ActiveWorkflowKey = settings.ActiveWorkflowKey + } else { + sess.UserCtx.Project.ActiveWorkflowKey = "" + } + } + } else if sess.UserCtx != nil { + sess.UserCtx.Project.ActiveWorkflowKey = "" } scope, err := s.resolveChatMessageScope(ctx, sess.Project(), sess.ActiveStageKey) if err == nil && scope != nil { From 0b1040ca7e6c6229beae4450fc6eda1b259bd1e0 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 08:09:36 +0000 Subject: [PATCH 4/5] feat(web): scope template matching to the active workflow - Document list returns workflow_key per item and active_workflow_key - DeliverableItem keeps workflowKey through server sync - resolveTaskTemplateForMessage filters the match pool and blocks cross-workflow mentions with switch guidance Co-authored-by: Cursor --- internal/docgov/project_docs.go | 1 + internal/server/chat_document_reader.go | 11 +++ internal/server/project_doc_handlers.go | 9 +- web/src/App.tsx | 2 + web/src/api/projectDocuments.ts | 31 ++++++- web/src/engine/templateMatch.test.ts | 43 ++++++++++ web/src/engine/templateMatch.ts | 104 +++++++++++++++++++++--- web/src/hooks/useSendMessage.ts | 23 +++++- web/src/hooks/useTaskRunAttempts.ts | 15 +++- web/src/lib/deliverableSync.ts | 2 + web/src/types/workbench.ts | 2 + 11 files changed, 226 insertions(+), 17 deletions(-) diff --git a/internal/docgov/project_docs.go b/internal/docgov/project_docs.go index 8312edf0..c869e2ef 100644 --- a/internal/docgov/project_docs.go +++ b/internal/docgov/project_docs.go @@ -20,6 +20,7 @@ type WorkflowDocumentSummary struct { ProjectID int64 `json:"project_id"` TaskKey string `json:"task_key"` Title string `json:"title"` + WorkflowKey string `json:"workflow_key,omitempty"` LifecycleState DocumentState `json:"lifecycle_state"` IsTemplateOnly bool `json:"is_template_only"` ContentSHA256 string `json:"content_sha256"` diff --git a/internal/server/chat_document_reader.go b/internal/server/chat_document_reader.go index 284618f4..ca32bdc6 100644 --- a/internal/server/chat_document_reader.go +++ b/internal/server/chat_document_reader.go @@ -108,6 +108,17 @@ func (s *Server) taskKeyOutsideActiveWorkflow(ctx context.Context, projectID int return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey) } +// workflowKeyForDocument 为列表项标注归属工作流:优先激活流任务集合,其次任务键前缀推断。 +func workflowKeyForDocument(taskKey, activeKey string, scopeTaskKeys map[string]bool) string { + if activeKey != "" && len(scopeTaskKeys) > 0 && projectinstance.TaskKeyInScope(scopeTaskKeys, taskKey) { + return activeKey + } + if owner := intent.TaskKeyWorkflowOwner(taskKey); owner != "" { + return owner + } + return activeKey +} + // errDocWrongWorkflow 目标文档存在但不属于当前激活工作流。 type errDocWrongWorkflow struct { Title string diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index cd787383..efda8b9a 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -95,7 +95,14 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht return } docs = s.filterWorkflowDocsByStageAccess(r.Context(), projectID, user.ID, docs) - writeJSONResponse(w, http.StatusOK, docs) + activeKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(r.Context(), projectID) + for i := range docs { + docs[i].WorkflowKey = workflowKeyForDocument(docs[i].TaskKey, activeKey, scopeTaskKeys) + } + writeJSONResponse(w, http.StatusOK, map[string]any{ + "documents": docs, + "active_workflow_key": activeKey, + }) } // handleDocGovGetDocumentContent 读取受控文档 Markdown 正文。 diff --git a/web/src/App.tsx b/web/src/App.tsx index 5ff30de5..8d2a2f2d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1185,6 +1185,7 @@ export default function App() { deliverables, visibleDeliverables, currentProjectDeliverables, + activeWorkflowKey: currentWfState.activeKey || undefined, deliverablesRef, activeDocIdRef, editorHydrationGateRef, @@ -1383,6 +1384,7 @@ export default function App() { deliverables, visibleDeliverables, currentProjectDeliverables, + activeWorkflowKey: currentWfState.activeKey || undefined, sessions, setDeliverables, setDocCache, diff --git a/web/src/api/projectDocuments.ts b/web/src/api/projectDocuments.ts index 0fd139e7..32f61d8a 100644 --- a/web/src/api/projectDocuments.ts +++ b/web/src/api/projectDocuments.ts @@ -7,6 +7,7 @@ export type WorkflowDocumentSummary = { project_id: number; task_key: string; title: string; + workflow_key?: string; lifecycle_state: string; is_template_only: boolean; content_sha256?: string; @@ -26,6 +27,11 @@ export type WorkflowDocumentSummary = { duty_policy?: string; }; +export type ProjectWorkflowDocumentsResponse = { + documents: WorkflowDocumentSummary[]; + active_workflow_key?: string; +}; + export type ControlledDocConsistency = | 'aligned' | 'missing' @@ -55,7 +61,30 @@ export async function listProjectWorkflowDocuments( throw new Error(`list project workflow documents failed: ${resp.status}`); } const data = await resp.json(); - return Array.isArray(data) ? data : []; + // 兼容旧数组响应与新 { documents, active_workflow_key } 包装。 + if (Array.isArray(data)) return data; + if (data && Array.isArray(data.documents)) return data.documents; + return []; +} + +export async function listProjectWorkflowDocumentsDetailed( + projectId: number, +): Promise { + const resp = await fetch( + `/api/v1/projects/${projectId}/documents?category=workflow`, + { headers: getBearerAuthHeaders() }, + ); + if (!resp.ok) { + throw new Error(`list project workflow documents failed: ${resp.status}`); + } + const data = await resp.json(); + if (Array.isArray(data)) { + return { documents: data }; + } + return { + documents: Array.isArray(data?.documents) ? data.documents : [], + active_workflow_key: typeof data?.active_workflow_key === 'string' ? data.active_workflow_key : undefined, + }; } export class ControlledContentRequestError extends Error { diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index ef8ed59c..6aff9962 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -266,3 +266,46 @@ test('word-order variants of the intake sheet stay on intake, not the shorter UR } assert.equal(resolveTaskTemplateForMessage('请更新用户需求表 URS', pool)?.taskKey, 'bd_user_requirements'); }); + +test('resolveTaskTemplateForMessage blocks cross-workflow document mentions', () => { + const mainResearch: DeliverableItem = { + ...base, + taskKey: 'bd_research', + title: '调研报告', + workflowKey: 'main', + }; + const rdevProposal: DeliverableItem = { + ...base, + taskKey: 'rd_proposal', + title: '项目建议书', + workflowKey: 'rdev', + isTemplateOnly: true, + }; + const pool = [mainResearch, rdevProposal]; + + const blocked = resolveTaskTemplateForMessage('编写项目建议书', pool, null, 'main'); + assert.ok(blocked?.wrongWorkflow); + assert.equal(blocked?.wrongWorkflow?.ownerWorkflow, 'rdev'); + assert.equal(blocked?.taskKey, ''); + + const allowed = resolveTaskTemplateForMessage('编写项目建议书', pool, null, 'rdev'); + assert.equal(allowed?.wrongWorkflow, undefined); + assert.equal(allowed?.taskKey, 'rd_proposal'); + + const legacy = resolveTaskTemplateForMessage('编写项目建议书', pool, null, undefined); + assert.equal(legacy?.wrongWorkflow, undefined); + assert.equal(legacy?.taskKey, 'rd_proposal'); +}); + +test('resolveTaskTemplateForMessage keeps in-scope main docs when active is main', () => { + const research: DeliverableItem = { + ...base, + taskKey: 'research', + title: '调研报告', + workflowKey: 'main', + isTemplateOnly: true, + }; + const resolved = resolveTaskTemplateForMessage('生成调研报告', [research], null, 'main'); + assert.equal(resolved?.wrongWorkflow, undefined); + assert.equal(resolved?.taskKey, 'research'); +}); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index ae119704..d589e9f2 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -24,12 +24,83 @@ export type ResolvedDocumentTask = { template?: DeliverableItem; docId?: string; pinnedToActiveDoc?: boolean; + /** 显式点名了非激活工作流的文档时填充;此时不应启动任务。 */ + wrongWorkflow?: { + docTitle: string; + ownerWorkflow: string; + activeWorkflow: string; + }; }; +const WORKFLOW_LABELS: Record = { + main: '商务对接流程', + product_manager: '智能产品经理流程', + test_manager: '智能测试工程师流程', + rdev: '研发设计控制流程', + software_bootstrap: '软件项目自举流程', + software_delivery: '软件交付迭代流程', +}; + +/** 交付物池外的他流文档别名(激活 main 却点名研发/产品经理文档时用于拦截)。 */ +const CROSS_WORKFLOW_ALIASES: { workflow: string; patterns: RegExp[] }[] = [ + { workflow: 'rdev', patterns: [/项目建议书/, /设计开发计划任务书/, /系统设计方案/, /用户需求规格书/] }, + { workflow: 'product_manager', patterns: [/系统需求规格书/] }, + { workflow: 'test_manager', patterns: [/系统验证方案及计划/, /系统验证方案/] }, +]; + +export function workflowDisplayLabel(key: string): string { + return WORKFLOW_LABELS[key] || key; +} + function templatePool(deliverables: DeliverableItem[]): DeliverableItem[] { return deliverables.filter((d) => d.isTemplateOnly); } +/** 激活流匹配池:无激活键或文档未标注 workflowKey 时保持全量(存量兼容)。 */ +function scopedDeliverables( + deliverables: DeliverableItem[], + activeWorkflowKey?: string, +): DeliverableItem[] { + if (!activeWorkflowKey) return deliverables; + return deliverables.filter((d) => !d.workflowKey || d.workflowKey === activeWorkflowKey); +} + +function detectWrongWorkflowMention( + text: string, + deliverables: DeliverableItem[], + activeWorkflowKey: string, +): ResolvedDocumentTask['wrongWorkflow'] | undefined { + const outside = deliverables + .filter((d) => d.workflowKey && d.workflowKey !== activeWorkflowKey && d.title?.trim()) + .sort((a, b) => (b.title?.length || 0) - (a.title?.length || 0)); + for (const d of outside) { + if (text.includes(d.title)) { + return { + docTitle: d.title, + ownerWorkflow: d.workflowKey!, + activeWorkflow: activeWorkflowKey, + }; + } + } + for (const alias of CROSS_WORKFLOW_ALIASES) { + if (alias.workflow === activeWorkflowKey) continue; + for (const p of alias.patterns) { + if (p.test(text)) { + return { + docTitle: text.match(p)?.[0] || alias.patterns[0].source, + ownerWorkflow: alias.workflow, + activeWorkflow: activeWorkflowKey, + }; + } + } + } + return undefined; +} + +export function wrongWorkflowGuidance(info: NonNullable): string { + return `您提到的文档「${info.docTitle}」属于【${workflowDisplayLabel(info.ownerWorkflow)}】,当前激活工作流为【${workflowDisplayLabel(info.activeWorkflow)}】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。`; +} + /** 只读动词优先;无明确写动作则不启动文档任务。 */ export function hasDocumentWriteIntent(text: string): boolean { const normalized = text.trim(); @@ -226,24 +297,35 @@ export function matchTemplateDeliverable( * 合并文案解析与模板匹配。 * 指示「当前受控文档」且 active 为已注册 doc_proj 时,钉住该文档 taskKey/docId, * 不被正文领域关键词改路由;显式点名另一交付物仍可切换。 + * activeWorkflowKey 非空时:匹配池收窄到激活流;显式点名他流文档返回 wrongWorkflow。 */ export function resolveTaskTemplateForMessage( text: string, deliverables: DeliverableItem[], activeDoc?: DeliverableItem | null, + activeWorkflowKey?: string, ): ResolvedDocumentTask | undefined { if (!hasDocumentWriteIntent(text)) return undefined; + if (activeWorkflowKey) { + const wrong = detectWrongWorkflowMention(text, deliverables, activeWorkflowKey); + if (wrong) { + return { taskKey: '', wrongWorkflow: wrong }; + } + } + + const scoped = scopedDeliverables(deliverables, activeWorkflowKey); + if (refersToCurrentControlledDocument(text) && isServerRegisteredControlledDoc(activeDoc)) { const pinnedKey = consistentTaskKeyForControlledDoc(activeDoc!); - const named = explicitNamedDeliverableTaskKey(text, deliverables, pinnedKey); + const named = explicitNamedDeliverableTaskKey(text, scoped, pinnedKey); if (named) { - const taskKey = normalizeTaskKeyToProject(named, deliverables); - const template = matchTemplateDeliverableByKey(taskKey, deliverables, text); + const taskKey = normalizeTaskKeyToProject(named, scoped); + const template = matchTemplateDeliverableByKey(taskKey, scoped, text); return { taskKey, template }; } if (pinnedKey) { - const template = matchTemplateDeliverableByKey(pinnedKey, deliverables, text); + const template = matchTemplateDeliverableByKey(pinnedKey, scoped, text); return { taskKey: pinnedKey, template, @@ -254,22 +336,22 @@ export function resolveTaskTemplateForMessage( } if (isRequirementsTopic(text) && hasIntakeSemanticMarker(text)) { - const taskKey = normalizeTaskKeyToProject('intake', deliverables); - const template = matchTemplateDeliverableByKey(taskKey, deliverables, text); + const taskKey = normalizeTaskKeyToProject('intake', scoped); + const template = matchTemplateDeliverableByKey(taskKey, scoped, text); return { taskKey, template }; } - const named = explicitNamedDeliverableTaskKey(text, deliverables); + const named = explicitNamedDeliverableTaskKey(text, scoped); if (named) { - const taskKey = normalizeTaskKeyToProject(named, deliverables); - const template = matchTemplateDeliverableByKey(taskKey, deliverables, text); + const taskKey = normalizeTaskKeyToProject(named, scoped); + const template = matchTemplateDeliverableByKey(taskKey, scoped, text); return { taskKey, template }; } const rawKey = resolveTaskKeyFromMessage(text); if (!rawKey) return undefined; - const taskKey = normalizeTaskKeyToProject(rawKey, deliverables); - const template = matchTemplateDeliverable(text, deliverables); + const taskKey = normalizeTaskKeyToProject(rawKey, scoped); + const template = matchTemplateDeliverable(text, scoped); return { taskKey, template }; } diff --git a/web/src/hooks/useSendMessage.ts b/web/src/hooks/useSendMessage.ts index e27ad999..fa339f64 100644 --- a/web/src/hooks/useSendMessage.ts +++ b/web/src/hooks/useSendMessage.ts @@ -11,7 +11,7 @@ import { isAffirmativeResponse, } from '../engine/cardActionEngine'; import { markdownToBlockAST } from '../engine/markdownToBlock'; -import { resolveTaskTemplateForMessage } from '../engine/templateMatch'; +import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch'; import { createClientTurnId } from '../lib/createClientTurnId'; import { canonicalDocIdForTask } from '../lib/controlledDocHydrate'; import { holdsDocDuty } from '../lib/docGovActionVisibility'; @@ -95,6 +95,8 @@ export interface SendMessageInput { deliverables: DeliverableItem[]; visibleDeliverables: DeliverableItem[]; currentProjectDeliverables: DeliverableItem[]; + /** 当前项目激活工作流键;用于文档任务匹配池收窄。 */ + activeWorkflowKey?: string; sessions: ProjectSession[]; setDeliverables: Dispatch>; setDocCache: Dispatch>>; @@ -131,6 +133,7 @@ export function useSendMessage(input: SendMessageInput) { deliverables, visibleDeliverables, currentProjectDeliverables, + activeWorkflowKey, sessions, setDeliverables, setDocCache, @@ -309,11 +312,27 @@ export function useSendMessage(input: SendMessageInput) { text, currentProjectDeliverables, sendActiveDoc, + activeWorkflowKey, ); + if (resolvedTask?.wrongWorkflow) { + setMessages((prev) => [ + ...prev, + { + id: 'ast-wrong-wf-' + Date.now(), + sessionId: activeSessionId, + role: 'assistant', + authorName: '项目助手', + content: wrongWorkflowGuidance(resolvedTask.wrongWorkflow!), + timestamp: new Date().toTimeString().substring(0, 5), + status: 'completed', + }, + ]); + return { accepted: true }; + } const isMockShortcut = isGenDocShortcut || isRejectShortcut || isModifyShortcut || isReviewPassShortcut; // P2:模板命中的长任务走真实后端 task stream(等待态 + PhaseEventStrip live + 分型恢复) - if (resolvedTask) { + if (resolvedTask?.taskKey) { const asstId = 'ast-' + Date.now(); const currentProj = sessions.find((s) => s.sessionId === activeSessionId); // 项目五要素暂取会话标题/设备类型;客户名沿用 bizdev-cli 演示默认值,五要素完整建模留待后续 diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index cf36f74f..667d0cf3 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -11,7 +11,7 @@ import { listProjectWorkflowDocuments } from '../api/projectDocuments'; import { createTaskRun, getTaskRun, resumeTaskRun, subscribeTaskRunEvents } from '../api/taskRuns'; import { buildTaskRequest, streamTask } from '../api/taskStream'; import { applyPhaseEvent } from '../engine/phaseEventMapper'; -import { resolveTaskTemplateForMessage } from '../engine/templateMatch'; +import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch'; import { GOVERNANCE_BLOCKED_HINT, isBlockedGovernanceStatus } from '../lib/blockedGovernanceCopy'; import { controlledHydrateCandidateIds, @@ -68,6 +68,7 @@ export interface TaskRunAttemptsInput { deliverables: DeliverableItem[]; visibleDeliverables: DeliverableItem[]; currentProjectDeliverables: DeliverableItem[]; + activeWorkflowKey?: string; deliverablesRef: MutableRefObject; activeDocIdRef: MutableRefObject; editorHydrationGateRef: MutableRefObject; @@ -99,6 +100,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { deliverables, visibleDeliverables, currentProjectDeliverables, + activeWorkflowKey, deliverablesRef, activeDocIdRef, editorHydrationGateRef, @@ -803,8 +805,17 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { userText, currentProjectDeliverables, retryActive, + activeWorkflowKey, ); - const retryTask = reResolved + if (reResolved?.wrongWorkflow) { + chatWindow.updateLiveMessage(messageId, { + content: wrongWorkflowGuidance(reResolved.wrongWorkflow), + livePhaseLabel: undefined, + status: 'completed', + }); + return; + } + const retryTask = reResolved?.taskKey ? { ...target.taskRequest, taskKey: reResolved.taskKey, diff --git a/web/src/lib/deliverableSync.ts b/web/src/lib/deliverableSync.ts index 00065ccd..9a95d5ae 100644 --- a/web/src/lib/deliverableSync.ts +++ b/web/src/lib/deliverableSync.ts @@ -115,6 +115,7 @@ export function mergeWorkflowDocumentsFromServer( docId: s.doc_id, projectId, taskKey: s.task_key, + workflowKey: s.workflow_key, title: s.title, stage: 'V0.5', category: 'workflow', @@ -136,6 +137,7 @@ export function mergeWorkflowDocumentsFromServer( base.docId = s.doc_id; base.taskKey = s.task_key; + base.workflowKey = s.workflow_key || base.workflowKey; base.title = s.title || base.title; base.isTemplateOnly = s.is_template_only; base.lifecycleState = mapLifecycleState(s.lifecycle_state); diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index ba171e69..998e77a9 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -307,6 +307,8 @@ export interface DeliverableItem { projectId: number; taskKey: string; title: string; + /** 文档所属工作流键(main / product_manager / rdev …);缺省表示存量未标注。 */ + workflowKey?: string; stage: StageKey; category: DeliverableCategory; creatorId: number; From d0b9ac424711875b318d329b20b88d01e8646f12 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 08:10:15 +0000 Subject: [PATCH 5/5] docs(ops): hold the integration slot for intent workflow scoping Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 22237150..8ded3206 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -178,7 +178,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-21 | 文档键统一已合入并重建 dev;`/health` schema_version=60;项目 6579 为 7 条 bd_* | - | +| Cursor | merging | 2026-09-21 | intent-workflow-scoping:合入 fix/intent-workflow-scoping-2026-09-21 并 rebuild 冒烟 | - | **phase**: `idle` | `merging` | `smoke` | `e2e`