merge: feature/workflow-scope-weighting-2026-09-21
This commit is contained in:
+2
-1
@@ -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 |
|
||||
@@ -184,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`
|
||||
|
||||
|
||||
@@ -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` 为空(实例已关闭)拒绝;键落在当前实例但不可见则拒绝;键不属于当前实例、且调用方已能查看该实例文档时允许只读。
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,109 @@ 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 {
|
||||
if strings.Contains(input, "查看") || strings.Contains(input, "读取") {
|
||||
return true
|
||||
}
|
||||
// 「看看」常跟在「出一版…看看」后面,表示写完给看,不是只读查看。
|
||||
if !strings.Contains(input, "看看") {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{"编写", "编制", "生成", "起草", "创建", "撰写", "重写", "更新", "修改", "完善", "补充", "刷新", "重做", "出", "做", "跑"} {
|
||||
if strings.Contains(input, marker) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/intent"
|
||||
"chat-agent-system/internal/workflowscope"
|
||||
)
|
||||
|
||||
// ----------------------------------------------------------------------------------
|
||||
@@ -117,6 +118,60 @@ func TestWorkflowScope_LegacyNoActiveWorkflowUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowScope_WeightedInScopeBeatsCrossPhrase(t *testing.T) {
|
||||
workflowscope.SetModeForTest(workflowscope.ModeWeighted)
|
||||
t.Cleanup(workflowscope.ResetModeForTest)
|
||||
|
||||
rec := intent.NewRecognizer()
|
||||
ctx := &intent.ContextSnapshot{SnapshotID: "s_w", ActiveWorkflowKey: "main"}
|
||||
res, err := rec.Recognize(context.Background(), "生成调研报告,不要写成项目建议书", ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" {
|
||||
t.Fatalf("in-scope research must not be hijacked, slots=%v", res.TopIntent.FilledSlots)
|
||||
}
|
||||
if got := res.TopIntent.FilledSlots[intent.SlotTargetTask]; got != "research" {
|
||||
t.Fatalf("want research, got %q slots=%v", got, res.TopIntent.FilledSlots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowScope_WeightedReadCrossAllowed(t *testing.T) {
|
||||
workflowscope.SetModeForTest(workflowscope.ModeWeighted)
|
||||
t.Cleanup(workflowscope.ResetModeForTest)
|
||||
|
||||
rec := intent.NewRecognizer()
|
||||
ctx := &intent.ContextSnapshot{SnapshotID: "s_r", ActiveWorkflowKey: "main"}
|
||||
res, err := rec.Recognize(context.Background(), "查看项目建议书", ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" {
|
||||
t.Fatalf("weighted read must not block, slots=%v", res.TopIntent.FilledSlots)
|
||||
}
|
||||
if res.TopIntent.FilledSlots[intent.SlotCrossWorkflowOwner] != "rdev" {
|
||||
t.Fatalf("want rdev owner, slots=%v", res.TopIntent.FilledSlots)
|
||||
}
|
||||
if !strings.Contains(res.TopIntent.FilledSlots[intent.SlotTargetDoc], "项目建议书") {
|
||||
t.Fatalf("want target doc, slots=%v", res.TopIntent.FilledSlots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowScope_WeightedWriteStillBlocked(t *testing.T) {
|
||||
workflowscope.SetModeForTest(workflowscope.ModeWeighted)
|
||||
t.Cleanup(workflowscope.ResetModeForTest)
|
||||
|
||||
rec := intent.NewRecognizer()
|
||||
ctx := &intent.ContextSnapshot{SnapshotID: "s_wb", ActiveWorkflowKey: "main"}
|
||||
res, err := rec.Recognize(context.Background(), "编写项目建议书", ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.TopIntent.FilledSlots[intent.SlotWorkflowScopeMismatch] != "true" {
|
||||
t.Fatalf("weighted write must still block, slots=%v intent=%s", res.TopIntent.FilledSlots, res.TopIntent.Intent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowScope_SnapshotCarriesActiveWorkflowKey(t *testing.T) {
|
||||
// ContextSnapshot 现在必须能携带激活工作流键,供消费端与识别器共同约束。
|
||||
snap := intent.ContextSnapshot{ActiveWorkflowKey: "main"}
|
||||
|
||||
@@ -1318,6 +1318,14 @@ func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent {
|
||||
return nil
|
||||
}
|
||||
|
||||
if workflowScopeUsesWeights() {
|
||||
flat := make([]struct{ text, key string }, 0, len(phrases))
|
||||
for _, item := range phrases {
|
||||
flat = append(flat, struct{ text, key string }{text: item.text, key: item.key})
|
||||
}
|
||||
return weightedTriggerTask(input, flat, userCtx)
|
||||
}
|
||||
|
||||
for _, item := range phrases {
|
||||
if strings.Contains(input, item.text) {
|
||||
if cand := scopedTriggerTaskCandidate(input, item.text, item.key, userCtx); cand != nil {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, "报告") {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 叙事增强;默认仅确定性回退"},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
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}
|
||||
}
|
||||
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -369,6 +369,7 @@ export default function App() {
|
||||
const [projectWorkflowsState, setProjectWorkflowsState] = useState<
|
||||
Record<number, { activeKey: string; version: number; creatorId: number; lifecycleStatus: string }>
|
||||
>({});
|
||||
const [workflowScopeMode, setWorkflowScopeMode] = useState<'strict' | 'weighted'>('strict');
|
||||
const [projectLineages, setProjectLineages] = useState<DocumentLineage[]>([]);
|
||||
|
||||
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,
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -42,7 +42,47 @@ const WORKFLOW_LABELS: Record<string, string> = {
|
||||
software_delivery: '软件交付迭代流程',
|
||||
};
|
||||
|
||||
/** 交付物池外的他流文档别名(激活 main 却点名研发/产品经理文档时用于拦截)。 */
|
||||
/** 与 internal/workflowscope 保持同一组常数,避免前后端对「他流能否压过激活流」判断分叉。 */
|
||||
const CATALOG_ORDER: Record<string, number> = {
|
||||
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<ResolvedDocumentTask['wrongWorkflow']>): 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 };
|
||||
}
|
||||
|
||||
@@ -97,6 +97,8 @@ export interface SendMessageInput {
|
||||
currentProjectDeliverables: DeliverableItem[];
|
||||
/** 当前项目激活工作流键;用于文档任务匹配池收窄。 */
|
||||
activeWorkflowKey?: string;
|
||||
/** 来自文档列表的 WORKFLOW_SCOPE_MODE;缺省 strict。 */
|
||||
workflowScopeMode?: 'strict' | 'weighted';
|
||||
sessions: ProjectSession[];
|
||||
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
|
||||
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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<string>;
|
||||
editorHydrationGateRef: MutableRefObject<Gate>;
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface TaskRunAttemptsInput {
|
||||
visibleDeliverables: DeliverableItem[];
|
||||
currentProjectDeliverables: DeliverableItem[];
|
||||
activeWorkflowKey?: string;
|
||||
workflowScopeMode?: 'strict' | 'weighted';
|
||||
deliverablesRef: MutableRefObject<DeliverableItem[]>;
|
||||
activeDocIdRef: MutableRefObject<string>;
|
||||
editorHydrationGateRef: MutableRefObject<Gate>;
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user