From 0bf4977d279a8318527d9756b72adfca59631c52 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:07:50 +0000 Subject: [PATCH 1/8] docs(ops): claim workflow scope weighting on a feature branch Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 1 + .../2026-09-21-workflow-scope-weighting.md | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 .doc/ops/short-term/2026-09-21-workflow-scope-weighting.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index a2f48ec0..6213a1e1 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -14,6 +14,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-21-workflow-scope-weighting | feature/workflow-scope-weighting-2026-09-21 | .worktrees/workflow-scope-weighting-2026-09-21 | intent+web+docgov | in_progress | | Cursor | 2026-09-21-mineru-doc-parsing | feature/mineru-doc-parsing-2026-09-21 | .worktrees/mineru-doc-parsing-2026-09-21 | storage+bizdev+kb | done | | Cursor | 2026-09-21-intent-confirm-stage-aware | fix/intent-confirm-stage-aware-2026-09-21 | .worktrees/intent-confirm-stage-aware-2026-09-21 | intent+web+docgov | done | | Cursor | 2026-09-21-taskrun-status-card | feature/taskrun-status-card-2026-09-21 | .worktrees/feature-taskrun-status-card-2026-09-21 | chat+web+taskrun | done | diff --git a/.doc/ops/short-term/2026-09-21-workflow-scope-weighting.md b/.doc/ops/short-term/2026-09-21-workflow-scope-weighting.md new file mode 100644 index 00000000..7df15009 --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-workflow-scope-weighting.md @@ -0,0 +1,59 @@ +--- +title: "工作流作用域权重化" +status: in_progress +created: 2026-09-21 +updated: 2026-09-21 +owner: Cursor +branch: feature/workflow-scope-weighting-2026-09-21 +worktree: .worktrees/workflow-scope-weighting-2026-09-21 +base_dev_sha: 10aee3feb478c550adc9959f2d3a8d17cd5b48a0 +write_scope: [internal/workflowscope, internal/intent, internal/server, internal/coordinator, internal/config, internal/sysadmin, web/src, docker-compose.dev.yml, docker-compose.test.yml, docker-compose.main.yml, .env.example, .doc] +head_sha: +deployment: pending +cleanup: pending +mutex: intent+web+docgov +--- + +# 工作流作用域权重化 + +## 目标 + +把「句子里出现他流文档短语就硬拦截」改为「匹配质量 × 工作流关系权重」裁决。激活流优先;只有他流精确命中明显压过激活流时才跨流。只读跨流在查看权限通过后放行并注明归属;写动作仍引导到项目设置切换(跨流写在现架构下不可执行)。`WORKFLOW_SCOPE_MODE` 默认 `strict`,保持现状。 + +## 范围 + +- 新建 `internal/workflowscope`(开关、关系权重、打分裁决) +- 服务端文档读取、意图填槽、前端模板匹配改用同一裁决 +- compose / `.env.example` / sysadmin 登记开关 +- dev 真实环境验收与测试报告 + +## 非目标 + +- 不迁移 `document_records`,不加 `workflow_instance_id` +- 不改工作流切换 / 激活接口 +- 不改 `production_ready`,不晋升 test/main,不动项目 143 + +## 步骤 + +1. [ ] 打分内核与开关 +2. [ ] 服务端只读跨流放行与查看权限 +3. [ ] 意图填槽改打分 +4. [ ] 前端模板匹配改打分 +5. [ ] 开关声明、真实验收、报告、合回 dev + +## 验收标准 + +- [ ] strict 模式与现有跨流拦截断言一致 +- [ ] weighted 下激活流精确名压过他流短语,不再误拦 +- [ ] weighted 下只读精确点名他流文档可放行并注明归属;写动作仍拦截 +- [ ] 无查看权限 / 实例已关闭时跨流只读拒绝 +- [ ] 空激活键不过滤 + +## 相关链接 + +- Plan: `~/.cursor/plans/工作流作用域权重化_411b5e35.plan.md` +- Test report: `.doc/test-reports/2026-09-21-workflow-scope-weighting-test-report.md` + +## 证据 / 备注 + +查看权限:治理实例 `ViewableTaskKeys` 只含当前实例已启用文档。他流任务键不在快照内,不能把它当成「显式隐藏」。判定为:解析失败按 `failClosedAuthz` 拒绝;`ViewableTaskKeys` 为空(实例已关闭)拒绝;键落在当前实例但不可见则拒绝;键不属于当前实例、且调用方已能查看该实例文档时允许只读。 From fac57043030ee6c7d751b2c1060d6a6ba1cc8486 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:07:50 +0000 Subject: [PATCH 2/8] feat(workflowscope): score document hits by workflow relation weight Keep WORKFLOW_SCOPE_MODE=strict as the default so cross-workflow mentions stay blocked until weighted mode is turned on. Co-authored-by: Cursor --- internal/workflowscope/flags.go | 55 +++++++ internal/workflowscope/relation.go | 55 +++++++ internal/workflowscope/score.go | 212 +++++++++++++++++++++++++++ internal/workflowscope/score_test.go | 122 +++++++++++++++ 4 files changed, 444 insertions(+) create mode 100644 internal/workflowscope/flags.go create mode 100644 internal/workflowscope/relation.go create mode 100644 internal/workflowscope/score.go create mode 100644 internal/workflowscope/score_test.go diff --git a/internal/workflowscope/flags.go b/internal/workflowscope/flags.go new file mode 100644 index 00000000..4f804304 --- /dev/null +++ b/internal/workflowscope/flags.go @@ -0,0 +1,55 @@ +package workflowscope + +import ( + "os" + "strings" + "sync" +) + +// Mode 是工作流作用域裁决开关。默认 strict,与「命中他流即拦截」的现状一致。 +type Mode string + +const ( + ModeStrict Mode = "strict" + ModeWeighted Mode = "weighted" +) + +const EnvWorkflowScopeMode = "WORKFLOW_SCOPE_MODE" + +var ( + modeMu sync.RWMutex + activeMode = parseMode(os.Getenv(EnvWorkflowScopeMode)) +) + +func parseMode(raw string) Mode { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "weighted": + return ModeWeighted + case "", "strict": + return ModeStrict + default: + // 未知值 fail-safe,保持现状拦截。 + return ModeStrict + } +} + +// CurrentMode 返回进程启动时快照的裁决模式。 +func CurrentMode() Mode { + modeMu.RLock() + defer modeMu.RUnlock() + return activeMode +} + +// SetModeForTest 仅供测试切换模式。 +func SetModeForTest(mode Mode) { + modeMu.Lock() + activeMode = parseMode(string(mode)) + modeMu.Unlock() +} + +// ResetModeForTest 恢复为环境变量(或默认 strict)。 +func ResetModeForTest() { + modeMu.Lock() + activeMode = parseMode(os.Getenv(EnvWorkflowScopeMode)) + modeMu.Unlock() +} diff --git a/internal/workflowscope/relation.go b/internal/workflowscope/relation.go new file mode 100644 index 00000000..1617dc01 --- /dev/null +++ b/internal/workflowscope/relation.go @@ -0,0 +1,55 @@ +package workflowscope + +import "chat-agent-system/internal/workflowdef" + +const ( + // WeightSame 当前激活工作流。 + WeightSame = 1.0 + // WeightUnknown 文档没有工作流归属(存量行)。 + WeightUnknown = 0.80 + // WeightAdjacent 同产品域且目录序相邻(|Δorder| == 10)。 + WeightAdjacent = 0.60 + // WeightSameDomain 同产品域但目录序更远。 + WeightSameDomain = 0.50 + // WeightCrossDomain 医疗四与软件两之间。 + WeightCrossDomain = 0.35 +) + +// RelationWeight 按「当前激活工作流 ↔ 文档所属工作流」给出关系权重。 +// active 为空时不惩罚(调用方应不过滤)。owner 为空视为归属未知。 +func RelationWeight(active, owner string) float64 { + if active == "" { + return WeightSame + } + if owner == "" { + return WeightUnknown + } + if active == owner { + return WeightSame + } + if !sameProductDomain(active, owner) { + return WeightCrossDomain + } + activeOrder := workflowdef.OfficialCatalogOrder(active) + ownerOrder := workflowdef.OfficialCatalogOrder(owner) + if activeOrder != 9999 && ownerOrder != 9999 && absInt(activeOrder-ownerOrder) == 10 { + return WeightAdjacent + } + return WeightSameDomain +} + +func sameProductDomain(a, b string) bool { + aMed := workflowdef.IsMedicalWorkflowKey(a) + bMed := workflowdef.IsMedicalWorkflowKey(b) + if aMed || bMed { + return aMed && bMed + } + return workflowdef.IsSoftwareWorkflowKey(a) && workflowdef.IsSoftwareWorkflowKey(b) +} + +func absInt(v int) int { + if v < 0 { + return -v + } + return v +} diff --git a/internal/workflowscope/score.go b/internal/workflowscope/score.go new file mode 100644 index 00000000..d73e9c75 --- /dev/null +++ b/internal/workflowscope/score.go @@ -0,0 +1,212 @@ +package workflowscope + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +const ( + // QualityExact 标题 / 任务键 / 文档 ID 原文精确命中。 + QualityExact = 1.0 + // QualityNormalized 归一化后精确命中。 + QualityNormalized = 0.85 + // CrossMargin 他流要胜出,分数必须高出激活流最佳分这么多。 + CrossMargin = 0.15 + // MinCrossQuality 模糊包含的质量上限低于此值,因此不能让他流胜出。 + MinCrossQuality = 0.85 +) + +// Action 区分只读放行与写动作拦截。 +type Action string + +const ( + ActionRead Action = "read" + ActionWrite Action = "write" +) + +// Kind 是裁决结果。 +type Kind string + +const ( + KindNone Kind = "" + KindInScope Kind = "in_scope" + KindCrossAllow Kind = "cross_allow" + KindCrossBlock Kind = "cross_block" +) + +// Candidate 是一条文档命中。Owner 为空表示归属未知,视作可留在池内。 +type Candidate struct { + ID string + Label string + Owner string + Quality float64 +} + +// Decision 携带胜出项。KindNone 表示没有足够把握的命中。 +type Decision struct { + Kind Kind + Winner Candidate + Score float64 +} + +// Decide 在激活工作流约束下选出文档。 +// active 为空:不过滤,按质量取最高并记为 InScope。 +// strict:先取激活流(含归属未知),没有才在质量达到精确档的他流上 CrossBlock。 +// weighted:score = quality × 关系权重;他流仅在分差与精确质量同时满足时胜出。 +func Decide(candidates []Candidate, active string, action Action) Decision { + usable := make([]Candidate, 0, len(candidates)) + for _, c := range candidates { + if c.Quality <= 0 { + continue + } + usable = append(usable, c) + } + if len(usable) == 0 { + return Decision{} + } + if strings.TrimSpace(active) == "" { + return bestOf(usable, active, func(c Candidate) float64 { return c.Quality }, KindInScope) + } + if CurrentMode() != ModeWeighted { + return decideStrict(usable, active) + } + return decideWeighted(usable, active, action) +} + +func decideStrict(candidates []Candidate, active string) Decision { + var inScope []Candidate + var cross []Candidate + for _, c := range candidates { + if c.Owner == "" || c.Owner == active { + inScope = append(inScope, c) + continue + } + cross = append(cross, c) + } + if len(inScope) > 0 { + return bestOf(inScope, active, func(c Candidate) float64 { return c.Quality }, KindInScope) + } + exact := make([]Candidate, 0, len(cross)) + for _, c := range cross { + if c.Quality >= MinCrossQuality { + exact = append(exact, c) + } + } + if len(exact) == 0 { + return Decision{} + } + return bestOf(exact, active, func(c Candidate) float64 { return c.Quality }, KindCrossBlock) +} + +func decideWeighted(candidates []Candidate, active string, action Action) Decision { + scoreOf := func(c Candidate) float64 { + return c.Quality * RelationWeight(active, c.Owner) + } + var bestIn, bestCross Candidate + inScore, crossScore := -1.0, -1.0 + for _, c := range candidates { + s := scoreOf(c) + if c.Owner == "" || c.Owner == active { + if s > inScore { + inScore = s + bestIn = c + } + continue + } + if s > crossScore || (s == crossScore && c.Quality > bestCross.Quality) { + crossScore = s + bestCross = c + } + } + if crossScore < 0 { + return Decision{Kind: KindInScope, Winner: bestIn, Score: inScore} + } + baseline := 0.0 + if inScore >= 0 { + baseline = inScore + } + crossWins := bestCross.Quality >= MinCrossQuality && crossScore >= baseline+CrossMargin + if !crossWins { + if inScore >= 0 { + return Decision{Kind: KindInScope, Winner: bestIn, Score: inScore} + } + return Decision{} + } + kind := KindCrossAllow + if action == ActionWrite { + kind = KindCrossBlock + } + return Decision{Kind: kind, Winner: bestCross, Score: crossScore} +} + +func bestOf(candidates []Candidate, active string, scoreOf func(Candidate) float64, kind Kind) Decision { + best := candidates[0] + bestScore := scoreOf(best) + for _, c := range candidates[1:] { + s := scoreOf(c) + if s > bestScore { + best = c + bestScore = s + } + } + if kind == KindInScope && active != "" && best.Owner != "" && best.Owner != active { + kind = KindCrossBlock + } + return Decision{Kind: kind, Winner: best, Score: bestScore} +} + +// BestQuality 取 query 对多个字段的最高匹配质量。 +func BestQuality(query string, fields ...string) float64 { + best := 0.0 + for _, field := range fields { + if q := ContainmentQuality(query, field); q > best { + best = q + } + } + return best +} + +// ContainmentQuality 三档质量:原文精确 1、归一化精确 0.85、双向包含 0.5×长度比。 +func ContainmentQuality(query, field string) float64 { + query = strings.TrimSpace(query) + field = strings.TrimSpace(field) + if query == "" || field == "" { + return 0 + } + if query == field { + return QualityExact + } + nq := NormalizeKey(query) + nf := NormalizeKey(field) + if nq == "" || nf == "" { + return 0 + } + if nq == nf { + return QualityNormalized + } + if strings.Contains(nf, nq) || strings.Contains(nq, nf) { + shorter := utf8.RuneCountInString(nq) + longer := utf8.RuneCountInString(nf) + if shorter > longer { + shorter, longer = longer, shorter + } + if longer == 0 { + return 0 + } + return 0.5 * float64(shorter) / float64(longer) + } + return 0 +} + +// NormalizeKey 去掉空白、标点和无区分度的虚词,供模糊比较。 +func NormalizeKey(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.NewReplacer("收集", "", "采集", "", "正文", "", "文档", "", "《", "", "》", "").Replace(value) + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) || unicode.IsPunct(r) { + return -1 + } + return r + }, value) +} diff --git a/internal/workflowscope/score_test.go b/internal/workflowscope/score_test.go new file mode 100644 index 00000000..a2061039 --- /dev/null +++ b/internal/workflowscope/score_test.go @@ -0,0 +1,122 @@ +package workflowscope + +import "testing" + +func TestRelationWeightBands(t *testing.T) { + if got := RelationWeight("main", "main"); got != WeightSame { + t.Fatalf("same=%v", got) + } + if got := RelationWeight("main", ""); got != WeightUnknown { + t.Fatalf("unknown=%v", got) + } + if got := RelationWeight("main", "product_manager"); got != WeightAdjacent { + t.Fatalf("adjacent=%v", got) + } + if got := RelationWeight("main", "rdev"); got != WeightSameDomain { + t.Fatalf("far medical=%v", got) + } + if got := RelationWeight("main", "software_bootstrap"); got != WeightCrossDomain { + t.Fatalf("cross domain=%v", got) + } + if got := RelationWeight("", "rdev"); got != WeightSame { + t.Fatalf("no active must not penalize, got %v", got) + } +} + +func TestDecideWeightedInScopeBeatsCrossName(t *testing.T) { + SetModeForTest(ModeWeighted) + t.Cleanup(ResetModeForTest) + + dec := Decide([]Candidate{ + {ID: "bd_research", Label: "调研报告", Owner: "main", Quality: QualityExact}, + {ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: QualityExact}, + }, "main", ActionWrite) + if dec.Kind != KindInScope || dec.Winner.ID != "bd_research" { + t.Fatalf("in-scope exact must win, got %+v", dec) + } +} + +func TestDecideWeightedCrossExactReadAllowed(t *testing.T) { + SetModeForTest(ModeWeighted) + t.Cleanup(ResetModeForTest) + + dec := Decide([]Candidate{ + {ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: QualityExact}, + }, "main", ActionRead) + if dec.Kind != KindCrossAllow || dec.Winner.Owner != "rdev" { + t.Fatalf("read cross exact want allow, got %+v", dec) + } + + write := Decide([]Candidate{ + {ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: QualityExact}, + }, "main", ActionWrite) + if write.Kind != KindCrossBlock { + t.Fatalf("write cross exact want block, got %+v", write) + } +} + +func TestDecideFuzzyCrossNeverWins(t *testing.T) { + SetModeForTest(ModeWeighted) + t.Cleanup(ResetModeForTest) + + dec := Decide([]Candidate{ + {ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: 0.4}, + }, "main", ActionRead) + if dec.Kind != KindNone { + t.Fatalf("fuzzy cross must not win, got %+v", dec) + } +} + +func TestDecideEmptyActiveDoesNotFilter(t *testing.T) { + SetModeForTest(ModeWeighted) + t.Cleanup(ResetModeForTest) + + dec := Decide([]Candidate{ + {ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: QualityExact}, + }, "", ActionWrite) + if dec.Kind != KindInScope || dec.Winner.ID != "rd_proposal" { + t.Fatalf("empty active must pass through, got %+v", dec) + } +} + +func TestDecideStrictKeepsTwoStageBlock(t *testing.T) { + SetModeForTest(ModeStrict) + t.Cleanup(ResetModeForTest) + + dec := Decide([]Candidate{ + {ID: "bd_research", Label: "调研报告", Owner: "main", Quality: 0.4}, + {ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: QualityExact}, + }, "main", ActionRead) + if dec.Kind != KindInScope || dec.Winner.ID != "bd_research" { + t.Fatalf("strict prefers any in-scope hit, got %+v", dec) + } + + onlyCross := Decide([]Candidate{ + {ID: "rd_proposal", Label: "项目建议书", Owner: "rdev", Quality: QualityExact}, + }, "main", ActionRead) + if onlyCross.Kind != KindCrossBlock { + t.Fatalf("strict cross exact must block even for read, got %+v", onlyCross) + } +} + +func TestParseModeFailSafe(t *testing.T) { + if parseMode("nope") != ModeStrict { + t.Fatal("unknown mode must fall back to strict") + } + if parseMode("weighted") != ModeWeighted { + t.Fatal("weighted") + } +} + +func TestContainmentQualityBands(t *testing.T) { + if ContainmentQuality("调研报告", "调研报告") != QualityExact { + t.Fatal("exact") + } + if ContainmentQuality("用户初始需求表", "用户初始需求收集表") != QualityNormalized { + t.Fatal("normalized") + } + fuzzy := ContainmentQuality("调研", "技术与市场调研报告") + if fuzzy <= 0 || fuzzy >= MinCrossQuality { + t.Fatalf("fuzzy=%v must stay below cross threshold", fuzzy) + } +} From 976f8d30b8ca3dc108294dfaf64a20a408bfdfe4 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:11:47 +0000 Subject: [PATCH 3/8] feat(server): allow weighted cross-workflow reads after a view check Exact out-of-workflow documents can be summarized when WORKFLOW_SCOPE_MODE=weighted and the caller can still view the active instance; writes stay blocked. Co-authored-by: Cursor --- internal/coordinator/context.go | 25 +++++ internal/coordinator/document_summary.go | 30 ++++- internal/coordinator/engine.go | 4 +- internal/intent/types.go | 1 + internal/server/chat_document_reader.go | 109 +++++++++++++++++-- internal/server/chat_document_reader_test.go | 46 ++++++++ internal/server/stage_access_helpers.go | 28 +++++ internal/workflowscope/score.go | 15 +++ 8 files changed, 242 insertions(+), 16 deletions(-) diff --git a/internal/coordinator/context.go b/internal/coordinator/context.go index 18267a34..4f2864bd 100644 --- a/internal/coordinator/context.go +++ b/internal/coordinator/context.go @@ -70,6 +70,10 @@ type ReadableDocument struct { Version int Markdown string IsTemplateOnly bool + // WorkflowKey 是文档归属工作流。CrossWorkflow 为真时正文来自非激活流,仅只读。 + WorkflowKey string + ActiveWorkflow string + CrossWorkflow bool } // DocumentSummaryReader 由 HTTP 服务端按当前登录用户和项目绑定。 @@ -94,6 +98,27 @@ func WrongWorkflowGuidance(err error) string { return "" } +// ErrDocViewDenied 表示跨工作流文档存在,但当前用户无权查看。 +type ErrDocViewDenied struct { + Message string +} + +func (e *ErrDocViewDenied) Error() string { + if e == nil { + return "document view denied" + } + return e.Message +} + +// DocViewDeniedGuidance 若错误链是跨流查看拒绝,返回话术。 +func DocViewDeniedGuidance(err error) string { + var target *ErrDocViewDenied + 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 04bcd231..752fe055 100644 --- a/internal/coordinator/document_summary.go +++ b/internal/coordinator/document_summary.go @@ -31,6 +31,17 @@ func (e *CoordinatorEngine) handleDocumentSummary( if guidance := WrongWorkflowGuidance(err); guidance != "" { return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil } + if guidance := DocViewDeniedGuidance(err); guidance != "" { + return documentSummaryReply(userCtx, guidance, ReplySourceTemplate), nil + } + if owner := strings.TrimSpace(cand.FilledSlots[intent.SlotCrossWorkflowOwner]); owner != "" { + active := "" + if userCtx != nil { + active = userCtx.Project.ActiveWorkflowKey + } + note := fmt.Sprintf("您点名的《%s》属于【%s】,当前激活工作流为【%s】。当前项目台账里没有可读取的正文。", defaultStr(target, "该文档"), intent.WorkflowDisplayName(owner), intent.WorkflowDisplayName(active)) + return documentSummaryReply(userCtx, note, ReplySourceTemplate), nil + } return documentSummaryReply(userCtx, "未找到可访问的目标文档,或您当前无权查看该阶段内容。请确认文档名称后重试。", ReplySourceTemplate), nil } if doc.IsTemplateOnly { @@ -40,10 +51,23 @@ func (e *CoordinatorEngine) handleDocumentSummary( return documentSummaryReply(userCtx, fmt.Sprintf("📄 《%s》正文为空,暂时无法总结。", defaultStr(doc.Title, doc.DocID)), ReplySourceTemplate), nil } - if reply := e.generateDocumentSummaryReply(ctx, doc, userCtx); reply != "" { - return documentSummaryReply(userCtx, reply, ReplySourcePiAgent), nil + reply := "" + source := ReplySourceTemplate + if generated := e.generateDocumentSummaryReply(ctx, doc, userCtx); generated != "" { + reply = generated + source = ReplySourcePiAgent + } else { + reply = fallbackDocumentSummary(doc) } - return documentSummaryReply(userCtx, fallbackDocumentSummary(doc), ReplySourceTemplate), nil + if doc.CrossWorkflow { + reply = crossWorkflowReadPreface(doc) + "\n\n" + reply + } + return documentSummaryReply(userCtx, reply, source), nil +} + +func crossWorkflowReadPreface(doc *ReadableDocument) string { + title := defaultStr(doc.Title, doc.DocID) + return fmt.Sprintf("以下是【%s】的《%s》,当前激活工作流为【%s】。", intent.WorkflowDisplayName(doc.WorkflowKey), title, intent.WorkflowDisplayName(doc.ActiveWorkflow)) } func documentSummaryReply(userCtx *UserProjectContext, reply, source string) *CoordinationResult { diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 6524b319..5f5c580e 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -22,6 +22,7 @@ import ( "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/squad" "chat-agent-system/internal/stream" + "chat-agent-system/internal/workflowscope" ) // CoordinationStatus 协调裁决状态 @@ -959,7 +960,8 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent. // 消费端二次校验:任务键不属于激活工作流时拦截(防御识别器漏标)。 if userCtx != nil && userCtx.Project.ActiveWorkflowKey != "" { - if owner := intent.TaskKeyWorkflowOwner(taskKey); owner != "" && owner != userCtx.Project.ActiveWorkflowKey { + owner := intent.TaskKeyWorkflowOwner(taskKey) + if workflowscope.BlocksWrite(userCtx.Project.ActiveWorkflowKey, owner, taskKey) { guidance := intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, userCtx.Project.ActiveWorkflowKey) return &CoordinationResult{ Status: StatusClarificationRequired, diff --git a/internal/intent/types.go b/internal/intent/types.go index e655e0da..57491da4 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -161,6 +161,7 @@ const ( SlotWorkflowScopeMismatch = "workflow_scope_mismatch" // "true" SlotMismatchedWorkflow = "mismatched_workflow" // 文档归属工作流 key(多个用逗号连接) SlotMismatchedDoc = "mismatched_doc" // 被显式点名的他流文档标题 + SlotCrossWorkflowOwner = "cross_workflow_owner" // 只读跨流放行时的归属工作流 key // 长任务/运行槽位 SlotWorkDir = "work_dir" diff --git a/internal/server/chat_document_reader.go b/internal/server/chat_document_reader.go index ca32bdc6..932d95e4 100644 --- a/internal/server/chat_document_reader.go +++ b/internal/server/chat_document_reader.go @@ -12,6 +12,7 @@ import ( "chat-agent-system/internal/docgov" "chat-agent-system/internal/intent" "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/workflowscope" ) // bindSessionDocumentSummaryReader 将正文访问绑定到已认证的用户与项目。 @@ -37,21 +38,37 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth if err := s.authorizer.RequireProjectAccessOrAdmin(ctx, projectID, userID, s.isSystemAdminUser(ctx, user)); err != nil { return nil, err } - docs, err := s.gov.ListWorkflowDocumentsByProject(ctx, projectID) + listed, err := s.gov.ListWorkflowDocumentsByProject(ctx, projectID) if err != nil { return nil, err } - docs = s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, docs) + docs := s.filterWorkflowDocsByStageAccess(ctx, projectID, userID, listed) // 工作流作用域:仅在激活工作流的文档池内解析正文;跨流点名返回可解释的引导错误。 // 激活键由 enrichSessionWorkflowStage 每轮刷新;任务集合按 live 实例快照解析。 workflowKey, scopeTaskKeys := s.activeWorkflowScopeForDocs(ctx, projectID) if sess.UserCtx != nil && sess.UserCtx.Project.ActiveWorkflowKey != "" { workflowKey = sess.UserCtx.Project.ActiveWorkflowKey } + if workflowscope.CurrentMode() == workflowscope.ModeWeighted && len(scopeTaskKeys) > 0 { + // 权重模式把被阶段过滤掉的他流台账加回候选,胜出后再做查看权限校验。 + docs = append(append([]docgov.WorkflowDocumentSummary{}, docs...), filterDocsOutsideTaskKeys(listed, scopeTaskKeys)...) + } doc, err := selectReadableWorkflowDocument(docs, target, workflowKey, scopeTaskKeys) if err != nil { return nil, err } + owner := intent.TaskKeyWorkflowOwner(doc.TaskKey) + cross := workflowKey != "" && owner != "" && owner != workflowKey && + (len(scopeTaskKeys) == 0 || !projectinstance.TaskKeyInScope(scopeTaskKeys, doc.TaskKey)) + if cross && !s.crossWorkflowViewAllowed(ctx, projectID, userID, doc.TaskKey) { + title := doc.Title + if title == "" { + title = doc.TaskKey + } + return nil, &coordinator.ErrDocViewDenied{ + Message: fmt.Sprintf("您无权查看【%s】的《%s》。", intent.WorkflowDisplayName(owner), title), + } + } content, err := s.loadControlledDocumentContent(ctx, doc.DocID) if err != nil { return nil, err @@ -64,6 +81,9 @@ func (s *Server) bindSessionDocumentSummaryReader(sess *chat.Session, user *auth Version: doc.Version, Markdown: content.Markdown, IsTemplateOnly: doc.IsTemplateOnly || content.IsTemplateOnly, + WorkflowKey: owner, + ActiveWorkflow: workflowKey, + CrossWorkflow: cross, }, nil } } @@ -96,16 +116,16 @@ func (s *Server) taskKeyOutsideActiveWorkflow(ctx context.Context, projectID int return false, "" } owner := intent.TaskKeyWorkflowOwner(taskKey) - if owner == "" || owner == workflowKey { - return false, "" + if workflowscope.BlocksWrite(workflowKey, owner, taskKey) { + return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey) } - 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) + owner := intent.TaskKeyWorkflowOwner(taskKey) + if workflowscope.BlocksWrite(workflowKey, owner, taskKey) { + return true, intent.WorkflowScopeGuidanceQuestion(taskKey, []string{owner}, workflowKey) + } + return false, "" } // workflowKeyForDocument 为列表项标注归属工作流:优先激活流任务集合,其次任务键前缀推断。 @@ -121,9 +141,9 @@ func workflowKeyForDocument(taskKey, activeKey string, scopeTaskKeys map[string] // errDocWrongWorkflow 目标文档存在但不属于当前激活工作流。 type errDocWrongWorkflow struct { - Title string - TaskKey string - OwnerWorkflow string + Title string + TaskKey string + OwnerWorkflow string ActiveWorkflow string } @@ -154,6 +174,71 @@ func selectReadableWorkflowDocument( target string, activeWorkflow string, scopeTaskKeys map[string]bool, +) (*docgov.WorkflowDocumentSummary, error) { + if workflowscope.CurrentMode() == workflowscope.ModeWeighted && activeWorkflow != "" && len(scopeTaskKeys) > 0 && strings.TrimSpace(target) != "" { + return selectReadableWeighted(docs, target, activeWorkflow, scopeTaskKeys) + } + return selectReadableStrict(docs, target, activeWorkflow, scopeTaskKeys) +} + +func selectReadableWeighted( + docs []docgov.WorkflowDocumentSummary, + target string, + activeWorkflow string, + scopeTaskKeys map[string]bool, +) (*docgov.WorkflowDocumentSummary, error) { + candidates := make([]workflowscope.Candidate, 0, len(docs)) + byID := make(map[string]docgov.WorkflowDocumentSummary, len(docs)) + for _, doc := range docs { + quality := workflowscope.BestQuality(target, doc.DocID, doc.TaskKey, doc.Title) + if quality <= 0 { + continue + } + owner := "" + if projectinstance.TaskKeyInScope(scopeTaskKeys, doc.TaskKey) { + owner = activeWorkflow + } else if inferred := intent.TaskKeyWorkflowOwner(doc.TaskKey); inferred != "" { + owner = inferred + } + id := doc.DocID + if id == "" { + id = doc.TaskKey + } + candidates = append(candidates, workflowscope.Candidate{ + ID: id, + Label: doc.Title, + Owner: owner, + Quality: quality, + }) + byID[id] = doc + } + decision := workflowscope.Decide(candidates, activeWorkflow, workflowscope.ActionRead) + switch decision.Kind { + case workflowscope.KindInScope, workflowscope.KindCrossAllow: + doc := byID[decision.Winner.ID] + return &doc, nil + case workflowscope.KindCrossBlock: + doc := byID[decision.Winner.ID] + owner := decision.Winner.Owner + if owner == "" { + owner = intent.TaskKeyWorkflowOwner(doc.TaskKey) + } + return nil, (&errDocWrongWorkflow{ + Title: doc.Title, + TaskKey: doc.TaskKey, + OwnerWorkflow: owner, + ActiveWorkflow: activeWorkflow, + }).AsCoordinatorErr() + default: + return nil, fmt.Errorf("document not found in readable scope") + } +} + +func selectReadableStrict( + 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") diff --git a/internal/server/chat_document_reader_test.go b/internal/server/chat_document_reader_test.go index 9db568f4..c277d0d1 100644 --- a/internal/server/chat_document_reader_test.go +++ b/internal/server/chat_document_reader_test.go @@ -7,6 +7,8 @@ import ( "chat-agent-system/internal/coordinator" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/projectinstance" + "chat-agent-system/internal/workflowscope" ) func TestSelectReadableWorkflowDocumentMatchesInitialRequirementsAlias(t *testing.T) { @@ -73,3 +75,47 @@ func TestSelectReadableWorkflowDocumentNoScopeKeepsLegacy(t *testing.T) { t.Fatalf("want rd_proposal, got %s", doc.TaskKey) } } + +func TestSelectReadableWeightedAllowsExactCrossRead(t *testing.T) { + workflowscope.SetModeForTest(workflowscope.ModeWeighted) + t.Cleanup(workflowscope.ResetModeForTest) + + 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} + doc, err := selectReadableWorkflowDocument(docs, "项目建议书", "main", scope) + if err != nil { + t.Fatalf("weighted cross read: %v", err) + } + if doc.TaskKey != "rd_proposal" { + t.Fatalf("want rd_proposal, got %s", doc.TaskKey) + } + + inScope, err := selectReadableWorkflowDocument(docs, "调研报告", "main", scope) + if err != nil || inScope.TaskKey != "bd_research" { + t.Fatalf("in-scope still wins: %v %v", inScope, err) + } +} + +func TestCrossWorkflowTaskVisible(t *testing.T) { + open := &projectinstance.StageAccess{ + Restricted: true, + ViewableTaskKeys: map[string]bool{"bd_research": true}, + } + scope := map[string]bool{"bd_research": true, "bd_hidden": true} + if !crossWorkflowTaskVisible(open, scope, "rd_proposal") { + t.Fatal("foreign key should be readable when the caller can view the active instance") + } + if crossWorkflowTaskVisible(open, scope, "bd_hidden") { + t.Fatal("in-scope but not viewable must be denied") + } + closed := &projectinstance.StageAccess{Restricted: true, ViewableTaskKeys: map[string]bool{}} + if crossWorkflowTaskVisible(closed, scope, "rd_proposal") { + t.Fatal("closed instance must deny cross read") + } + if !crossWorkflowTaskVisible(nil, scope, "rd_proposal") { + t.Fatal("unrestricted access must allow") + } +} diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index 3dfdefd1..ea1d8968 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -163,6 +163,34 @@ func (s *Server) enrichSessionWorkflowStage(ctx context.Context, sess *chat.Sess } } +// crossWorkflowViewAllowed 判断他流文档是否允许当前用户只读。 +// 解析失败按 fail-closed 开关拒绝;可见集合为空(实例已关闭)拒绝; +// 任务键属于激活实例但不可见则拒绝;键不在激活实例快照内、且调用方仍能查看本实例文档时允许。 +func (s *Server) crossWorkflowViewAllowed(ctx context.Context, projectID, userID int64, taskKey string) bool { + access, err := s.resolveStageAccess(ctx, projectID, userID) + if err != nil { + return !s.failClosedAuthz() + } + _, scope := s.activeWorkflowScopeForDocs(ctx, projectID) + return crossWorkflowTaskVisible(access, scope, taskKey) +} + +func crossWorkflowTaskVisible(access *projectinstance.StageAccess, scope map[string]bool, taskKey string) bool { + if access == nil || !access.Restricted { + return true + } + if access.TaskKeyVisible(taskKey) { + return true + } + if len(access.ViewableTaskKeys) == 0 { + return false + } + if len(scope) > 0 && projectinstance.TaskKeyInScope(scope, taskKey) { + return false + } + return true +} + func (s *Server) filterWorkflowDocsByStageAccess( ctx context.Context, projectID, userID int64, diff --git a/internal/workflowscope/score.go b/internal/workflowscope/score.go index d73e9c75..a0f1c3c5 100644 --- a/internal/workflowscope/score.go +++ b/internal/workflowscope/score.go @@ -156,6 +156,21 @@ func bestOf(candidates []Candidate, active string, scoreOf func(Candidate) float return Decision{Kind: kind, Winner: best, Score: bestScore} } +// BlocksWrite 报告一个已确定归属的任务键在写路径上是否应拦截。 +// 归属未知或未激活时不拦截。 +func BlocksWrite(active, owner, id string) bool { + if strings.TrimSpace(active) == "" || strings.TrimSpace(owner) == "" || owner == active { + return false + } + decision := Decide([]Candidate{{ + ID: id, + Label: id, + Owner: owner, + Quality: QualityExact, + }}, active, ActionWrite) + return decision.Kind == KindCrossBlock +} + // BestQuality 取 query 对多个字段的最高匹配质量。 func BestQuality(query string, fields ...string) float64 { best := 0.0 From 0972900215770c8ff4ff3400c74c73aabd4b0380 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:15:51 +0000 Subject: [PATCH 4/8] feat(intent): score slot filling against the active workflow Weighted mode keeps an in-scope document when a cross-workflow name is only mentioned beside it, while exact cross-workflow writes stay blocked and exact reads record the owning workflow. Co-authored-by: Cursor --- internal/intent/doc_workflow_scope.go | 160 ++++++++++++++++++--- internal/intent/doc_workflow_scope_test.go | 55 +++++++ internal/intent/recognizer.go | 8 ++ internal/intent/workflow_matcher.go | 5 + 4 files changed, 210 insertions(+), 18 deletions(-) diff --git a/internal/intent/doc_workflow_scope.go b/internal/intent/doc_workflow_scope.go index a82af48b..9705584a 100644 --- a/internal/intent/doc_workflow_scope.go +++ b/internal/intent/doc_workflow_scope.go @@ -10,6 +10,7 @@ import ( "chat-agent-system/internal/squad" "chat-agent-system/internal/workflowdef" + "chat-agent-system/internal/workflowscope" ) // docWorkflowScope 提供「文档标题/任务键 → 归属工作流」索引, @@ -238,26 +239,42 @@ func activeScopedTaskKey(phrase, activeWorkflow string) (string, bool) { return workflowdef.ImplementationTaskKey(key), true } -// docWorkflowScopeMarker 检查输入是否显式点名了非激活工作流的受控文档。 -// 返回 (他流文档标题, 归属工作流 key 列表, 是否命中跨流)。 -// activeWorkflow 为空(存量单流 / planning 态)时一律不判跨流,保持现状行为。 +// docWorkflowScopeMarker 检查输入是否应拦截他流文档。 +// 权重模式下只有他流精确命中压过激活流才算跨流;strict 下仍是「激活流没有命中才拦截他流精确名」。 func docWorkflowScopeMarker(input, activeWorkflow string) (string, []string, bool) { - if activeWorkflow == "" { + decision := decideInputDocScope(input, activeWorkflow, workflowscope.ActionWrite) + if decision.Kind != workflowscope.KindCrossBlock { return "", nil, false } - for _, hit := range matchDocPhrasesForScope(input) { - owns := false - for _, wf := range hit.Workflows { - if wf == activeWorkflow { - owns = true - break - } - } - if !owns { - return hit.Phrase, hit.Workflows, true - } + owner := decision.Winner.Owner + if owner == "" { + return decision.Winner.Label, nil, true } - return "", nil, false + return decision.Winner.Label, []string{owner}, true +} + +func decideInputDocScope(input, active string, action workflowscope.Action) workflowscope.Decision { + hits := matchDocPhrasesForScope(input) + candidates := make([]workflowscope.Candidate, 0, len(hits)) + for _, hit := range hits { + owner := "" + if active != "" && !containsWorkflow(hit.Workflows, active) && len(hit.Workflows) > 0 { + owner = hit.Workflows[0] + } else if active != "" { + owner = active + } + quality := workflowscope.QualityExact + if utf8.RuneCountInString(hit.Phrase) < 4 { + quality = 0.5 + } + candidates = append(candidates, workflowscope.Candidate{ + ID: hit.Phrase, + Label: hit.Phrase, + Owner: owner, + Quality: quality, + }) + } + return workflowscope.Decide(candidates, active, action) } // markWorkflowScopeMismatch 在槽位上打跨流标记。 @@ -293,8 +310,16 @@ func fillTargetDocSlot(slots map[string]string, input string, userCtx *ContextSn if userCtx != nil { active = userCtx.ActiveWorkflowKey } - if doc, wfs, mismatched := docWorkflowScopeMarker(input, active); mismatched { - markWorkflowScopeMismatch(slots, doc, wfs) + decision := decideInputDocScope(input, active, workflowscope.ActionRead) + switch decision.Kind { + case workflowscope.KindCrossBlock: + markWorkflowScopeMismatch(slots, decision.Winner.Label, ownerList(decision.Winner.Owner)) + return + case workflowscope.KindCrossAllow: + slots[SlotTargetDoc] = decision.Winner.Label + if decision.Winner.Owner != "" { + slots[SlotCrossWorkflowOwner] = decision.Winner.Owner + } return } if doc := extractDocName(input); doc != "" { @@ -409,3 +434,102 @@ var docNameIDPattern = regexp.MustCompile(`(doc[_-][a-zA-Z0-9_-]+)`) func hasExplicitDocID(input string) bool { return docNameIDPattern.FindString(input) != "" } + +func ownerList(owner string) []string { + if owner == "" { + return nil + } + return []string{owner} +} + +// weightedTriggerTask 在权重模式下同时看激活流和他流短语,避免长他流别名劫持更贴切的激活流文档。 +func weightedTriggerTask(input string, phrases []struct{ text, key string }, userCtx *ContextSnapshot) *CandidateIntent { + active := activeWorkflowKeyOf(userCtx) + candidates := make([]workflowscope.Candidate, 0, len(phrases)) + for _, item := range phrases { + if !strings.Contains(input, item.text) { + continue + } + owners, ok := docScopeForPhrase(item.text) + if !ok { + if inferred := taskKeyWorkflowOwner(item.key); inferred != "" { + owners = []string{inferred} + ok = true + } + } + owner := "" + if active != "" && ok && !containsWorkflow(owners, active) && len(owners) > 0 { + owner = owners[0] + } else if active != "" { + owner = active + } + quality := workflowscope.QualityExact + if utf8.RuneCountInString(item.text) < 4 { + quality = 0.5 + } + candidates = append(candidates, workflowscope.Candidate{ + ID: item.key, + Label: item.text, + Owner: owner, + Quality: quality, + }) + } + decision := workflowscope.Decide(candidates, active, workflowscope.ActionWrite) + switch decision.Kind { + case workflowscope.KindCrossBlock: + return workflowScopeCandidate(decision.Winner.Label, ownerList(decision.Winner.Owner), active) + case workflowscope.KindInScope: + return triggerTaskCandidate(decision.Winner.Label, decision.Winner.ID) + default: + return nil + } +} + +func workflowScopeUsesWeights() bool { + return workflowscope.CurrentMode() == workflowscope.ModeWeighted +} + +// readDocScopeCandidate 只读点名受控文档。strict 下他流精确名仍拦截;weighted 下精确名放行并记下归属。 +func readDocScopeCandidate(input string, snap *ContextSnapshot) *CandidateIntent { + active := activeWorkflowKeyOf(snap) + decision := decideInputDocScope(input, active, workflowscope.ActionRead) + switch decision.Kind { + case workflowscope.KindNone: + return nil + case workflowscope.KindCrossBlock: + slots := map[string]string{} + markWorkflowScopeMismatch(slots, decision.Winner.Label, ownerList(decision.Winner.Owner)) + question := workflowScopeGuidanceQuestion(decision.Winner.Label, ownerList(decision.Winner.Owner), active) + return &CandidateIntent{ + Intent: IntentQueryDocStatus, + Confidence: 0.94, + Branch: BranchNewIntentPreempt, + FilledSlots: slots, + Reasoning: "只读点名的受控文档属于非激活工作流", + ClarificationQuestion: question, + } + default: + slots := map[string]string{ + SlotQueryTopic: "doc_summary", + SlotTargetDoc: decision.Winner.Label, + } + if decision.Kind == workflowscope.KindCrossAllow && decision.Winner.Owner != "" { + slots[SlotCrossWorkflowOwner] = decision.Winner.Owner + } + return &CandidateIntent{ + Intent: IntentQueryDocStatus, + Confidence: 0.97, + FilledSlots: slots, + Reasoning: "用户只读点名受控文档", + } + } +} + +func hasDocViewVerb(input string) bool { + for _, verb := range []string{"查看", "看看", "读取"} { + if strings.Contains(input, verb) { + return true + } + } + return false +} diff --git a/internal/intent/doc_workflow_scope_test.go b/internal/intent/doc_workflow_scope_test.go index 5818a77b..47b92bf3 100644 --- a/internal/intent/doc_workflow_scope_test.go +++ b/internal/intent/doc_workflow_scope_test.go @@ -6,6 +6,7 @@ import ( "testing" "chat-agent-system/internal/intent" + "chat-agent-system/internal/workflowscope" ) // ---------------------------------------------------------------------------------- @@ -117,6 +118,60 @@ func TestWorkflowScope_LegacyNoActiveWorkflowUnchanged(t *testing.T) { } } +func TestWorkflowScope_WeightedInScopeBeatsCrossPhrase(t *testing.T) { + workflowscope.SetModeForTest(workflowscope.ModeWeighted) + t.Cleanup(workflowscope.ResetModeForTest) + + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{SnapshotID: "s_w", ActiveWorkflowKey: "main"} + res, err := rec.Recognize(context.Background(), "生成调研报告,不要写成项目建议书", ctx) + if err != nil { + t.Fatal(err) + } + if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { + t.Fatalf("in-scope research must not be hijacked, slots=%v", res.TopIntent.FilledSlots) + } + if got := res.TopIntent.FilledSlots[intent.SlotTargetTask]; got != "research" { + t.Fatalf("want research, got %q slots=%v", got, res.TopIntent.FilledSlots) + } +} + +func TestWorkflowScope_WeightedReadCrossAllowed(t *testing.T) { + workflowscope.SetModeForTest(workflowscope.ModeWeighted) + t.Cleanup(workflowscope.ResetModeForTest) + + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{SnapshotID: "s_r", ActiveWorkflowKey: "main"} + res, err := rec.Recognize(context.Background(), "查看项目建议书", ctx) + if err != nil { + t.Fatal(err) + } + if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { + t.Fatalf("weighted read must not block, slots=%v", res.TopIntent.FilledSlots) + } + if res.TopIntent.FilledSlots[intent.SlotCrossWorkflowOwner] != "rdev" { + t.Fatalf("want rdev owner, slots=%v", res.TopIntent.FilledSlots) + } + if !strings.Contains(res.TopIntent.FilledSlots[intent.SlotTargetDoc], "项目建议书") { + t.Fatalf("want target doc, slots=%v", res.TopIntent.FilledSlots) + } +} + +func TestWorkflowScope_WeightedWriteStillBlocked(t *testing.T) { + workflowscope.SetModeForTest(workflowscope.ModeWeighted) + t.Cleanup(workflowscope.ResetModeForTest) + + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{SnapshotID: "s_wb", ActiveWorkflowKey: "main"} + res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx) + if err != nil { + t.Fatal(err) + } + if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] != "true" { + t.Fatalf("weighted write must still block, slots=%v intent=%s", res.TopIntent.FilledSlots, res.TopIntent.Intent) + } +} + func TestWorkflowScope_SnapshotCarriesActiveWorkflowKey(t *testing.T) { // ContextSnapshot 现在必须能携带激活工作流键,供消费端与识别器共同约束。 snap := intent.ContextSnapshot{ActiveWorkflowKey: "main"} diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index fed5044d..015d7e7f 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -1318,6 +1318,14 @@ func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent { return nil } + if workflowScopeUsesWeights() { + flat := make([]struct{ text, key string }, 0, len(phrases)) + for _, item := range phrases { + flat = append(flat, struct{ text, key string }{text: item.text, key: item.key}) + } + return weightedTriggerTask(input, flat, userCtx) + } + for _, item := range phrases { if strings.Contains(input, item.text) { if cand := scopedTriggerTaskCandidate(input, item.text, item.key, userCtx); cand != nil { diff --git a/internal/intent/workflow_matcher.go b/internal/intent/workflow_matcher.go index a3302a14..32daa7c0 100644 --- a/internal/intent/workflow_matcher.go +++ b/internal/intent/workflow_matcher.go @@ -185,6 +185,11 @@ func matchQueryDocStatus(input string, snap *ContextSnapshot) *CandidateIntent { } } } + if isSummary || hasDocViewVerb(input) { + if cand := readDocScopeCandidate(input, snap); cand != nil { + return cand + } + } if isSummary { target := extractDocName(input) if target != "" || strings.Contains(input, "文档") || strings.Contains(input, "报告") { From fd4b95030fc9ab9283ea069a64aa7811636506bb Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:22:13 +0000 Subject: [PATCH 5/8] feat(web): score write-path matches by workflow relation The document list now reports WORKFLOW_SCOPE_MODE, and weighted mode only blocks a cross-workflow write when that title outscores the active workflow. Co-authored-by: Cursor --- internal/server/project_doc_handlers.go | 2 + web/src/App.tsx | 4 ++ web/src/api/projectDocuments.ts | 2 + web/src/engine/templateMatch.test.ts | 33 +++++++++ web/src/engine/templateMatch.ts | 94 ++++++++++++++++++++++++- web/src/hooks/useSendMessage.ts | 4 ++ web/src/hooks/useServerDocumentSync.ts | 20 ++++-- web/src/hooks/useTaskRunAttempts.ts | 3 + 8 files changed, 156 insertions(+), 6 deletions(-) diff --git a/internal/server/project_doc_handlers.go b/internal/server/project_doc_handlers.go index efda8b9a..f38c6625 100644 --- a/internal/server/project_doc_handlers.go +++ b/internal/server/project_doc_handlers.go @@ -13,6 +13,7 @@ import ( "chat-agent-system/internal/docgov" "chat-agent-system/internal/docquery" "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/workflowscope" ) func (s *Server) docflowQuery() *docflow.Query { @@ -102,6 +103,7 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht writeJSONResponse(w, http.StatusOK, map[string]any{ "documents": docs, "active_workflow_key": activeKey, + "workflow_scope_mode": string(workflowscope.CurrentMode()), }) } diff --git a/web/src/App.tsx b/web/src/App.tsx index 8d2a2f2d..534fb5bc 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -369,6 +369,7 @@ export default function App() { const [projectWorkflowsState, setProjectWorkflowsState] = useState< Record >({}); + const [workflowScopeMode, setWorkflowScopeMode] = useState<'strict' | 'weighted'>('strict'); const [projectLineages, setProjectLineages] = useState([]); const currentActiveSession = @@ -872,6 +873,7 @@ export default function App() { activeDocIdRef, editorHydrationGateRef, applyControlledInvalidationPlan, + onWorkflowScopeMode: setWorkflowScopeMode, }); // 模态弹窗与侧栏 @@ -1186,6 +1188,7 @@ export default function App() { visibleDeliverables, currentProjectDeliverables, activeWorkflowKey: currentWfState.activeKey || undefined, + workflowScopeMode, deliverablesRef, activeDocIdRef, editorHydrationGateRef, @@ -1385,6 +1388,7 @@ export default function App() { visibleDeliverables, currentProjectDeliverables, activeWorkflowKey: currentWfState.activeKey || undefined, + workflowScopeMode, sessions, setDeliverables, setDocCache, diff --git a/web/src/api/projectDocuments.ts b/web/src/api/projectDocuments.ts index 32f61d8a..1b5e0514 100644 --- a/web/src/api/projectDocuments.ts +++ b/web/src/api/projectDocuments.ts @@ -30,6 +30,7 @@ export type WorkflowDocumentSummary = { export type ProjectWorkflowDocumentsResponse = { documents: WorkflowDocumentSummary[]; active_workflow_key?: string; + workflow_scope_mode?: 'strict' | 'weighted'; }; export type ControlledDocConsistency = @@ -84,6 +85,7 @@ export async function listProjectWorkflowDocumentsDetailed( return { documents: Array.isArray(data?.documents) ? data.documents : [], active_workflow_key: typeof data?.active_workflow_key === 'string' ? data.active_workflow_key : undefined, + workflow_scope_mode: data?.workflow_scope_mode === 'weighted' ? 'weighted' : 'strict', }; } diff --git a/web/src/engine/templateMatch.test.ts b/web/src/engine/templateMatch.test.ts index 6aff9962..3f340ba2 100644 --- a/web/src/engine/templateMatch.test.ts +++ b/web/src/engine/templateMatch.test.ts @@ -309,3 +309,36 @@ test('resolveTaskTemplateForMessage keeps in-scope main docs when active is main assert.equal(resolved?.wrongWorkflow, undefined); assert.equal(resolved?.taskKey, 'research'); }); + +test('weighted mode does not let a cross-workflow title hijack an in-scope write', () => { + const research: DeliverableItem = { + ...base, + taskKey: 'bd_research', + title: '调研报告', + workflowKey: 'main', + isTemplateOnly: true, + }; + const proposal: DeliverableItem = { + ...base, + taskKey: 'rd_proposal', + title: '项目建议书', + workflowKey: 'rdev', + isTemplateOnly: true, + }; + const pool = [research, proposal]; + const kept = resolveTaskTemplateForMessage( + '生成调研报告,不要写成项目建议书', + pool, + null, + 'main', + 'weighted', + ); + assert.equal(kept?.wrongWorkflow, undefined); + assert.equal(kept?.taskKey, 'bd_research'); + + const blocked = resolveTaskTemplateForMessage('编写项目建议书', pool, null, 'main', 'weighted'); + assert.equal(blocked?.wrongWorkflow?.ownerWorkflow, 'rdev'); + + const fuzzy = resolveTaskTemplateForMessage('编写建议', pool, null, 'main', 'weighted'); + assert.equal(fuzzy?.wrongWorkflow, undefined); +}); diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index 67ed3d67..797c863b 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -42,7 +42,47 @@ const WORKFLOW_LABELS: Record = { software_delivery: '软件交付迭代流程', }; -/** 交付物池外的他流文档别名(激活 main 却点名研发/产品经理文档时用于拦截)。 */ +/** 与 internal/workflowscope 保持同一组常数,避免前后端对「他流能否压过激活流」判断分叉。 */ +const CATALOG_ORDER: Record = { + main: 10, + product_manager: 20, + test_manager: 30, + rdev: 40, + software_bootstrap: 50, + software_delivery: 60, +}; +const MEDICAL_WORKFLOWS = new Set(['main', 'product_manager', 'test_manager', 'rdev']); +const SOFTWARE_WORKFLOWS = new Set(['software_bootstrap', 'software_delivery']); +const WEIGHT_SAME = 1; +const WEIGHT_UNKNOWN = 0.8; +const WEIGHT_ADJACENT = 0.6; +const WEIGHT_SAME_DOMAIN = 0.5; +const WEIGHT_CROSS_DOMAIN = 0.35; +const CROSS_MARGIN = 0.15; +const MIN_CROSS_QUALITY = 0.85; + +export type WorkflowScopeMode = 'strict' | 'weighted'; + +export function relationWeight(active: string, owner: string): number { + if (!active) return WEIGHT_SAME; + if (!owner) return WEIGHT_UNKNOWN; + if (active === owner) return WEIGHT_SAME; + const activeMedical = MEDICAL_WORKFLOWS.has(active); + const ownerMedical = MEDICAL_WORKFLOWS.has(owner); + const sameDomain = activeMedical || ownerMedical + ? activeMedical && ownerMedical + : SOFTWARE_WORKFLOWS.has(active) && SOFTWARE_WORKFLOWS.has(owner); + if (!sameDomain) return WEIGHT_CROSS_DOMAIN; + const delta = Math.abs((CATALOG_ORDER[active] ?? 9999) - (CATALOG_ORDER[owner] ?? 9999)); + if (CATALOG_ORDER[active] != null && CATALOG_ORDER[owner] != null && delta === 10) { + return WEIGHT_ADJACENT; + } + return WEIGHT_SAME_DOMAIN; +} + +function mentionQuality(phrase: string): number { + return Array.from(phrase).length >= 4 ? 1 : 0.5; +} const CROSS_WORKFLOW_ALIASES: { workflow: string; patterns: RegExp[] }[] = [ { workflow: 'rdev', patterns: [/项目建议书/, /设计开发计划任务书/, /系统设计方案/, /用户需求规格书/] }, { workflow: 'product_manager', patterns: [/系统需求规格书/] }, @@ -98,6 +138,53 @@ function detectWrongWorkflowMention( return undefined; } +function weightedWrongWorkflow( + text: string, + deliverables: DeliverableItem[], + activeWorkflowKey: string, +): ResolvedDocumentTask['wrongWorkflow'] | undefined { + const mentions: { label: string; owner: string; quality: number }[] = []; + for (const item of deliverables) { + const title = item.title?.trim(); + if (!title || !text.includes(title)) continue; + const owner = !item.workflowKey || item.workflowKey === activeWorkflowKey + ? activeWorkflowKey + : item.workflowKey; + mentions.push({ label: title, owner, quality: mentionQuality(title) }); + } + for (const alias of CROSS_WORKFLOW_ALIASES) { + for (const pattern of alias.patterns) { + const hit = text.match(pattern)?.[0]; + if (!hit || mentions.some((m) => m.label === hit)) continue; + const owner = alias.workflow === activeWorkflowKey ? activeWorkflowKey : alias.workflow; + mentions.push({ label: hit, owner, quality: mentionQuality(hit) }); + } + } + let bestInScope = -1; + let bestCross = -1; + let cross: { label: string; owner: string; quality: number } | undefined; + for (const mention of mentions) { + const score = mention.quality * relationWeight(activeWorkflowKey, mention.owner); + if (!mention.owner || mention.owner === activeWorkflowKey) { + bestInScope = Math.max(bestInScope, score); + continue; + } + if (score > bestCross) { + bestCross = score; + cross = mention; + } + } + const baseline = bestInScope < 0 ? 0 : bestInScope; + if (!cross || cross.quality < MIN_CROSS_QUALITY || bestCross < baseline + CROSS_MARGIN) { + return undefined; + } + return { + docTitle: cross.label, + ownerWorkflow: cross.owner, + activeWorkflow: activeWorkflowKey, + }; +} + export function wrongWorkflowGuidance(info: NonNullable): string { return `您提到的文档「${info.docTitle}」属于【${workflowDisplayLabel(info.ownerWorkflow)}】,当前激活工作流为【${workflowDisplayLabel(info.activeWorkflow)}】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。`; } @@ -305,11 +392,14 @@ export function resolveTaskTemplateForMessage( deliverables: DeliverableItem[], activeDoc?: DeliverableItem | null, activeWorkflowKey?: string, + scopeMode?: WorkflowScopeMode, ): ResolvedDocumentTask | undefined { if (!hasDocumentWriteIntent(text)) return undefined; if (activeWorkflowKey) { - const wrong = detectWrongWorkflowMention(text, deliverables, activeWorkflowKey); + const wrong = scopeMode === 'weighted' + ? weightedWrongWorkflow(text, deliverables, activeWorkflowKey) + : detectWrongWorkflowMention(text, deliverables, activeWorkflowKey); if (wrong) { return { taskKey: '', wrongWorkflow: wrong }; } diff --git a/web/src/hooks/useSendMessage.ts b/web/src/hooks/useSendMessage.ts index fa339f64..f97b666b 100644 --- a/web/src/hooks/useSendMessage.ts +++ b/web/src/hooks/useSendMessage.ts @@ -97,6 +97,8 @@ export interface SendMessageInput { currentProjectDeliverables: DeliverableItem[]; /** 当前项目激活工作流键;用于文档任务匹配池收窄。 */ activeWorkflowKey?: string; + /** 来自文档列表的 WORKFLOW_SCOPE_MODE;缺省 strict。 */ + workflowScopeMode?: 'strict' | 'weighted'; sessions: ProjectSession[]; setDeliverables: Dispatch>; setDocCache: Dispatch>>; @@ -134,6 +136,7 @@ export function useSendMessage(input: SendMessageInput) { visibleDeliverables, currentProjectDeliverables, activeWorkflowKey, + workflowScopeMode, sessions, setDeliverables, setDocCache, @@ -313,6 +316,7 @@ export function useSendMessage(input: SendMessageInput) { currentProjectDeliverables, sendActiveDoc, activeWorkflowKey, + workflowScopeMode, ); if (resolvedTask?.wrongWorkflow) { setMessages((prev) => [ diff --git a/web/src/hooks/useServerDocumentSync.ts b/web/src/hooks/useServerDocumentSync.ts index 5de4103e..3cca7a55 100644 --- a/web/src/hooks/useServerDocumentSync.ts +++ b/web/src/hooks/useServerDocumentSync.ts @@ -1,6 +1,6 @@ import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; import { listFreeformDocs } from '../api/freeformDoc'; -import { listProjectWorkflowDocuments } from '../api/projectDocuments'; +import { listProjectWorkflowDocumentsDetailed } from '../api/projectDocuments'; import { applyLeaseUpdates, sameLease } from '../lib/deliverableLeaseSync'; import { mergeWorkflowDocumentsFromServer } from '../lib/deliverableSync'; import { @@ -30,6 +30,8 @@ export interface ServerDocumentSyncInput { activeDocIdRef: MutableRefObject; editorHydrationGateRef: MutableRefObject; applyControlledInvalidationPlan: (plan: ControlledInvalidationPlan) => void; + onActiveWorkflowKey?: (key: string) => void; + onWorkflowScopeMode?: (mode: 'strict' | 'weighted') => void; } export function useServerDocumentSync(input: ServerDocumentSyncInput): void { @@ -45,6 +47,8 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void { activeDocIdRef, editorHydrationGateRef, applyControlledInvalidationPlan, + onActiveWorkflowKey, + onWorkflowScopeMode, } = input; useEffect(() => { @@ -106,8 +110,11 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void { const syncWorkflowDocs = async () => { try { // 成功返回(含空数组)才对账;失败不得调用 merge,以免误清本地真实项。 - const workflowDocs = await listProjectWorkflowDocuments(currentProjectId); + const listed = await listProjectWorkflowDocumentsDetailed(currentProjectId); if (!active) return; + if (listed.active_workflow_key) onActiveWorkflowKey?.(listed.active_workflow_key); + if (listed.workflow_scope_mode) onWorkflowScopeMode?.(listed.workflow_scope_mode); + const workflowDocs = listed.documents; setDeliverables((prev) => mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser), ); @@ -138,13 +145,18 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void { currentUser.name, workflowDocsEpoch, applyControlledInvalidationPlan, + onActiveWorkflowKey, + onWorkflowScopeMode, ]); useEffect(() => { if (!session || sessionValidated !== true || currentProjectId <= 0) return; let cancelled = false; const syncLeases = async () => { - const docs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []); + const listed = await listProjectWorkflowDocumentsDetailed(currentProjectId).catch(() => null); + const docs = listed?.documents ?? []; + if (listed?.active_workflow_key) onActiveWorkflowKey?.(listed.active_workflow_key); + if (listed?.workflow_scope_mode) onWorkflowScopeMode?.(listed.workflow_scope_mode); if (cancelled || docs.length === 0) return; const leases = new Map( docs.map((doc) => [ @@ -177,5 +189,5 @@ export function useServerDocumentSync(input: ServerDocumentSyncInput): void { cancelled = true; window.clearInterval(timer); }; - }, [session, sessionValidated, currentProjectId]); + }, [session, sessionValidated, currentProjectId, onActiveWorkflowKey, onWorkflowScopeMode]); } diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index c09ec1a6..ffae04b2 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -70,6 +70,7 @@ export interface TaskRunAttemptsInput { visibleDeliverables: DeliverableItem[]; currentProjectDeliverables: DeliverableItem[]; activeWorkflowKey?: string; + workflowScopeMode?: 'strict' | 'weighted'; deliverablesRef: MutableRefObject; activeDocIdRef: MutableRefObject; editorHydrationGateRef: MutableRefObject; @@ -102,6 +103,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { visibleDeliverables, currentProjectDeliverables, activeWorkflowKey, + workflowScopeMode, deliverablesRef, activeDocIdRef, editorHydrationGateRef, @@ -843,6 +845,7 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { currentProjectDeliverables, retryActive, activeWorkflowKey, + workflowScopeMode, ); if (reResolved?.wrongWorkflow) { chatWindow.updateLiveMessage(messageId, { From 4af4a9de13512f43d63ca53f82112affc05dc590 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:26:03 +0000 Subject: [PATCH 6/8] =?UTF-8?q?fix(intent):=20keep=20draft=20requests=20th?= =?UTF-8?q?at=20end=20in=20=E7=9C=8B=E7=9C=8B=20on=20the=20write=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「出一版…看看」是写完给看,不是只读查看;查看和读取仍走只读作用域裁决。 Co-authored-by: Cursor --- internal/intent/doc_workflow_scope.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/intent/doc_workflow_scope.go b/internal/intent/doc_workflow_scope.go index 9705584a..7143e48d 100644 --- a/internal/intent/doc_workflow_scope.go +++ b/internal/intent/doc_workflow_scope.go @@ -526,10 +526,17 @@ func readDocScopeCandidate(input string, snap *ContextSnapshot) *CandidateIntent } func hasDocViewVerb(input string) bool { - for _, verb := range []string{"查看", "看看", "读取"} { - if strings.Contains(input, verb) { - return true + if strings.Contains(input, "查看") || strings.Contains(input, "读取") { + return true + } + // 「看看」常跟在「出一版…看看」后面,表示写完给看,不是只读查看。 + if !strings.Contains(input, "看看") { + return false + } + for _, marker := range []string{"编写", "编制", "生成", "起草", "创建", "撰写", "重写", "更新", "修改", "完善", "补充", "刷新", "重做", "出", "做", "跑"} { + if strings.Contains(input, marker) { + return false } } - return false + return true } From 8b84ee4ecf554c644f9ec7b1de433413c157c156 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:26:03 +0000 Subject: [PATCH 7/8] chore(config): declare WORKFLOW_SCOPE_MODE with a strict default The flag reaches all three compose app services and the sysadmin catalog, and unknown values stay on the existing hard block. Co-authored-by: Cursor --- .env.example | 2 ++ docker-compose.dev.yml | 1 + docker-compose.main.yml | 1 + docker-compose.test.yml | 1 + internal/app/app.go | 2 ++ internal/config/compose_flag_declaration_test.go | 9 +++++++++ internal/sysadmin/features.go | 1 + 7 files changed, 17 insertions(+) diff --git a/.env.example b/.env.example index aa8ef6a2..3346f0b5 100644 --- a/.env.example +++ b/.env.example @@ -108,6 +108,8 @@ MULTI_INTENT_U9_SEGMENT=false MULTI_INTENT_U10_MUTATE=false MULTI_INTENT_U11_DOCGOV=false MULTI_INTENT_U12_RUN_CONTROL=false +# 工作流作用域:strict 命中他流即拦截;weighted 按关系权重打分,验收通过后再开。 +WORKFLOW_SCOPE_MODE=strict # W2: shadow only. Do not set SEMANTIC_SUMMARY_REPLACE=true; REPLACE is unwired. SEMANTIC_SUMMARY_SHADOW=false SEMANTIC_SUMMARY_REPLACE=false diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2bad3431..b89d5e98 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -123,6 +123,7 @@ services: MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false} MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false} MULTI_INTENT_U12_RUN_CONTROL: ${MULTI_INTENT_U12_RUN_CONTROL:-false} + WORKFLOW_SCOPE_MODE: ${WORKFLOW_SCOPE_MODE:-strict} # W2 PU6: shadow semantic summary only. REPLACE stays unwired and is not declared here. SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false} PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false} diff --git a/docker-compose.main.yml b/docker-compose.main.yml index a08bd1c3..6ca74df3 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -124,6 +124,7 @@ services: MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false} MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false} MULTI_INTENT_U12_RUN_CONTROL: ${MULTI_INTENT_U12_RUN_CONTROL:-false} + WORKFLOW_SCOPE_MODE: ${WORKFLOW_SCOPE_MODE:-strict} SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false} PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false} FAV_DEGRADED_READ: "false" diff --git a/docker-compose.test.yml b/docker-compose.test.yml index d8162095..2ed26c65 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -121,6 +121,7 @@ services: MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false} MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false} MULTI_INTENT_U12_RUN_CONTROL: ${MULTI_INTENT_U12_RUN_CONTROL:-false} + WORKFLOW_SCOPE_MODE: ${WORKFLOW_SCOPE_MODE:-strict} SEMANTIC_SUMMARY_SHADOW: ${SEMANTIC_SUMMARY_SHADOW:-false} PI_CHANGE_REPORT_NARRATIVE: ${PI_CHANGE_REPORT_NARRATIVE:-false} FAV_DEGRADED_READ: ${FAV_DEGRADED_READ:-false} diff --git a/internal/app/app.go b/internal/app/app.go index a089a90d..820a3eaa 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -38,6 +38,7 @@ import ( "chat-agent-system/internal/taskrun" "chat-agent-system/internal/traceimpact" "chat-agent-system/internal/workflowdef" + "chat-agent-system/internal/workflowscope" "chat-agent-system/internal/workspace" "github.com/jackc/pgx/v5/pgxpool" @@ -324,6 +325,7 @@ func sysadminFeatureValues(cfg *config.Config) func() map[string]string { "MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate), "MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov), "MULTI_INTENT_U12_RUN_CONTROL": strconv.FormatBool(pflags.U12RunControl), + "WORKFLOW_SCOPE_MODE": string(workflowscope.CurrentMode()), "SEMANTIC_SUMMARY_SHADOW": strconv.FormatBool(sflags.Shadow), "SEMANTIC_SUMMARY_REPLACE": strconv.FormatBool(sflags.Replace), "PI_CHANGE_REPORT_NARRATIVE": strconv.FormatBool(docrevision.PIChangeReportNarrativeEnabled()), diff --git a/internal/config/compose_flag_declaration_test.go b/internal/config/compose_flag_declaration_test.go index 038a237d..1c6f958d 100644 --- a/internal/config/compose_flag_declaration_test.go +++ b/internal/config/compose_flag_declaration_test.go @@ -77,6 +77,15 @@ func TestComposeAppDeclaresSemanticSummaryAndNarrativeShadowFlags(t *testing.T) } } +func TestComposeAppDeclaresWorkflowScopeMode(t *testing.T) { + for _, name := range composeFilesForFlagCheck { + block := serviceBlockText(t, readComposeFile(t, name), "app") + if !strings.Contains(block, "WORKFLOW_SCOPE_MODE: ${WORKFLOW_SCOPE_MODE:-strict}") { + t.Errorf("%s: app service must declare WORKFLOW_SCOPE_MODE with strict default", name) + } + } +} + // E-6: main must explicitly disable demo-user seeding at the compose layer // (defense in depth; internal/auth/bootstrap.go already gates on cfg.Env=="main"). func TestComposeMainSeedDemoUsersExplicitFalse(t *testing.T) { diff --git a/internal/sysadmin/features.go b/internal/sysadmin/features.go index bdac3489..21444826 100644 --- a/internal/sysadmin/features.go +++ b/internal/sysadmin/features.go @@ -113,6 +113,7 @@ var featureCatalog = []featureSpec{ {key: "MULTI_INTENT_U10_MUTATE", label: "U10 变更适配器", group: "intentflow", fallback: "false", description: "低/中风险 mutate 动作适配器"}, {key: "MULTI_INTENT_U11_DOCGOV", label: "U11 文档治理适配器", group: "intentflow", fallback: "false", description: "docgov 动作适配器"}, {key: "MULTI_INTENT_U12_RUN_CONTROL", label: "U12 运行控制适配器", group: "intentflow", fallback: "false", description: "workflow/run-control 动作适配器"}, + {key: "WORKFLOW_SCOPE_MODE", label: "工作流作用域裁决", group: "intent", fallback: "strict", description: "文档命中裁决:strict 命中他流即拦截;weighted 按工作流关系权重打分"}, {key: "SEMANTIC_SUMMARY_SHADOW", label: "语义摘要影子", group: "ctxmgr", fallback: "false", description: "上下文语义摘要影子生成"}, {key: "SEMANTIC_SUMMARY_REPLACE", label: "语义摘要替换", group: "ctxmgr", fallback: "false", description: "语义摘要替换确定性叙事(保留位,未接线)"}, {key: "PI_CHANGE_REPORT_NARRATIVE", label: "PI 变更报告叙事", group: "docrevision", fallback: "false", description: "变更报告 PI 叙事增强;默认仅确定性回退"}, From 776e069e2f9d83edc3ba6fab8a9fa0af37a9cf1b Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 09:30:31 +0000 Subject: [PATCH 8/8] docs(ops): mark the integration slot for workflow scope weighting 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 6213a1e1..ec085e52 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -185,7 +185,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-21 | mineru soft-dev 已验收;intent-confirm 真实 PI 抽样 PASS;schema=61 | - | +| Cursor | merging | 2026-09-21 | workflow-scope-weighting;合入 feature/workflow-scope-weighting-2026-09-21 | - | **phase**: `idle` | `merging` | `smoke` | `e2e`