merge: fix/intent-workflow-scoping-2026-09-21

Scope intent slot filling and document matching to the active workflow;
cross-workflow mentions are blocked with switch guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 08:11:27 +00:00
co-authored by Cursor
25 changed files with 1294 additions and 33 deletions
+2 -2
View File
@@ -13,7 +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-intent-workflow-scoping | fix/intent-workflow-scoping-2026-09-21 | - | intent+web+docgov | in_progress |
| Codex | 2026-09-21-agent-workflow-governance-adoption | feature/agent-workflow-governance-plan | .worktrees/feature-agent-workflow-governance-plan | agent-workflow-governance | in_progress |
| Codex | 2026-09-21-agent-workflow-governance-plan | feature/agent-workflow-governance-plan | .worktrees/feature-agent-workflow-governance-plan | agent-workflow-governance-plan | done |
| 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 |
@@ -180,7 +180,7 @@
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| Codex | merging | 2026-09-21 | Agent worktree 规范采纳;仅文档候选集成,主目录旧任务现场保留;deployment=not_required | - |
| Cursor | merging | 2026-09-21 | intent-workflow-scoping:合入 fix 分支并 rebuild 冒烟(接续 Codex 文档候选集成完成后) | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -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 释放后独立分支推进。
+20
View File
@@ -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
@@ -190,6 +209,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,
}
+3
View File
@@ -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 {
+39
View File
@@ -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)
+1
View File
@@ -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"`
+411
View File
@@ -0,0 +1,411 @@
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 {
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) != ""
}
+126
View File
@@ -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)
}
}
+66 -5
View File
@@ -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, "初始") ||
+6
View File
@@ -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"`
@@ -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)
}
@@ -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")
}
}
+157 -7
View File
@@ -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,163 @@ 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)
}
// 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
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 +235,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 {
+46 -2
View File
@@ -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)
}
}
+8
View File
@@ -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 服务中记录运行实例
+8 -1
View File
@@ -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 正文。
+11
View File
@@ -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 {
+2
View File
@@ -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,
+30 -1
View File
@@ -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<ProjectWorkflowDocumentsResponse> {
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 {
+43
View File
@@ -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');
});
+93 -11
View File
@@ -24,12 +24,83 @@ export type ResolvedDocumentTask = {
template?: DeliverableItem;
docId?: string;
pinnedToActiveDoc?: boolean;
/** 显式点名了非激活工作流的文档时填充;此时不应启动任务。 */
wrongWorkflow?: {
docTitle: string;
ownerWorkflow: string;
activeWorkflow: string;
};
};
const WORKFLOW_LABELS: Record<string, string> = {
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<ResolvedDocumentTask['wrongWorkflow']>): 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 };
}
+21 -2
View File
@@ -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<SetStateAction<DeliverableItem[]>>;
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
@@ -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 演示默认值,五要素完整建模留待后续
+13 -2
View File
@@ -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<DeliverableItem[]>;
activeDocIdRef: MutableRefObject<string>;
editorHydrationGateRef: MutableRefObject<Gate>;
@@ -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,
+2
View File
@@ -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);
+2
View File
@@ -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;