diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 8526046c..9ea03059 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,16 +5,16 @@ ## 焦点 -1. **任务执行状态卡**:竖向时间线 `TaskRunCard` + `task_phase_snapshot` 刷新不退化。见 [报告](../test-reports/2026-09-21-taskrun-status-card-persistence-test-report.md)。test/main 未晋升。 -2. **意图填槽工作流作用域**:激活流匹配池收窄 + 跨流拦截引导,已合入 `dev`。见 [报告](../test-reports/2026-09-21-intent-workflow-scoping-test-report.md)。test/main 未晋升。 -2. **工作流文档键已收成 7 条**:项目 6579 的旧键 `intake` 已归档,管理员列表为 7 条 `bd_*`。见 [报告](../test-reports/2026-09-21-workflow-doc-key-unification-test-report.md)。test/main 未晋升。 -3. **dev 四问题修复**:近似说法可启动摸底表;刷新后重放任务进度;继续任务走 resume;模板态显示「模板预览」。见 [报告](../test-reports/2026-09-21-dev-deep-test-four-issues-test-report.md)。test/main 未晋升。 +1. **意图确认优先与阶段感知**:弱写「整理需求」→ intake 确认卡片。见 [报告](../test-reports/2026-09-21-intent-confirm-stage-aware-test-report.md)。test/main 未晋升。 +2. **任务执行状态卡**:竖向时间线 `TaskRunCard` + `task_phase_snapshot` 刷新不退化。见 [报告](../test-reports/2026-09-21-taskrun-status-card-persistence-test-report.md)。test/main 未晋升。 +3. **意图填槽工作流作用域**:激活流匹配池收窄 + 跨流拦截引导,已合入 `dev`。见 [报告](../test-reports/2026-09-21-intent-workflow-scoping-test-report.md)。test/main 未晋升。 4. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。 ## 并行认领 | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| 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 | in_progress | | 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 | | Cursor | 2026-09-21-intent-workflow-scoping | fix/intent-workflow-scoping-2026-09-21 | - | intent+web+docgov | done | | Codex | 2026-09-21-agent-workflow-governance-adoption | feature/agent-workflow-governance-plan | .worktrees/feature-agent-workflow-governance-plan | agent-workflow-governance | in_progress | diff --git a/.doc/ops/short-term/2026-09-21-intent-confirm-stage-aware.md b/.doc/ops/short-term/2026-09-21-intent-confirm-stage-aware.md new file mode 100644 index 00000000..d737073a --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-intent-confirm-stage-aware.md @@ -0,0 +1,41 @@ +--- +title: "意图确认优先与阶段感知消解" +status: in_progress +created: 2026-09-21 +updated: 2026-09-21 +owner: Cursor +branch: fix/intent-confirm-stage-aware-2026-09-21 +worktree: .worktrees/intent-confirm-stage-aware-2026-09-21 +base_dev_sha: 49fb68fec1ea56f00616c23d08efb15950c2300d +mutex: intent+web+docgov +--- + +# 意图确认优先与阶段感知消解 + +## 目标 + +商务流初期「整理一下需求」等弱写表述 → 阶段感知消解为 intake,经确认卡片拍板后再执行;不强指令误伤。扩展已合入的工作流作用域修复。 + +## 范围 + +- 批次5 `feat(intent)`:弱写动词 + `stageAwareRequirementsTask` + `needs_confirmation` +- 批次6 `feat(coordinator)`:智能确认门禁(PendingExpectation) +- 批次7 `feat(web)`:阶段错误降级聊天一次 +- 批次8:验收报告 + 合回 `dev` + +## 步骤 + +1. [x] 批次5–7 实现与单元门禁 +2. [ ] 批次8:合入 `dev` + rebuild + 真实链路抽样 + +## 验收标准 + +- [x] V0.1「整理一下需求」→ 确认 →「好」→ intake(单元 E2E) +- [x] 强指令不强制确认;「编写用户需求表」无 rdev 误拦 +- [ ] 合入后真实 PI 抽样 + +## 相关链接 + +- Plan: `~/.cursor/plans/意图确认优先与阶段感知_7ab2c4d5.plan.md` +- Prior (done): `.doc/archive/ops/2026-09-21-intent-workflow-scoping.md` +- Test report: `.doc/test-reports/2026-09-21-intent-confirm-stage-aware-test-report.md` diff --git a/.doc/test-reports/2026-09-21-intent-confirm-stage-aware-test-report.md b/.doc/test-reports/2026-09-21-intent-confirm-stage-aware-test-report.md new file mode 100644 index 00000000..65e38b4d --- /dev/null +++ b/.doc/test-reports/2026-09-21-intent-confirm-stage-aware-test-report.md @@ -0,0 +1,78 @@ +# 意图确认优先与阶段感知消解 测试报告 + +## 一、 测试背景 (Background) + +商务对接流初期用户说「整理一下需求」时,系统要么落入闲聊兜底,要么被研发短别名「用户需求」截走并跨流拦截/阶段门禁失败。需要:按当前阶段消解到《用户初始需求收集表》(intake),并以确认卡片拍板后再执行。 + +本报告覆盖扩展任务 `fix/intent-confirm-stage-aware-2026-09-21`(批次5–7);批次1–3 工作流作用域此前已合入 `dev`。 + +## 二、 测试目标 (Test Objectives) + +- 弱写动词 + 需求宾语 → `TRIGGER_TASK/intake` + `needs_confirmation` +- 协调层确认门禁下发 `PendingExpectation`;回复「好」后执行 +- 强指令(如「生成需求表」)不强制确认 +- 「编写用户需求表」在 main 流不再误作 rdev 跨流拦截 +- 任务直通道遇 `TASK_SCOPE_UNRESOLVED` / `DOC_TASK_NOT_IN_ACTIVE_STAGE` 降级聊天一次 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:linux;分支 `fix/intent-confirm-stage-aware-2026-09-21`;base `dev@9798b130` +- 单元:`go test ./internal/intent ./internal/coordinator`;`web npm test` / `npm run build` +- 真实容器 rebuild:待合入 `dev` 后由集成槽位持有者执行(本报告撰写时槽位由 taskrun-status-card 占用) +- 证据:`.doc/test-reports/assets/2026-09-21-intent-confirm-stage-aware/` + +## 四、 预期结果 (Expected Results) + +| 输入 | 预期 | +|------|------| +| V0.1「整理一下需求」 | 确认话术含《用户初始需求收集表》;默认选项 intake | +| 回复「好」 | `TRIGGER_TASK/intake`,StatusAccepted(无 runner 时 stub) | +| 「生成需求表」 | 直接 TRIGGER_TASK/intake,无 needs_confirmation | +| 「编写用户需求表」 | TRIGGER_TASK/user_requirements,无 workflow_scope_mismatch | +| 阶段错误码 | 前端 `isTaskStageDegradeCode` 为 true,hook 含降级路径 | + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:弱写需求 → 确认 → 拍板 + +- **测试输入**:`整理一下需求` → 确认后 `好` +- **实际输出**:见 `assets/.../go-unit.txt` — `TestProcessIntent_WeakRequirementsConfirmsIntake` PASS;首轮 conf=0.78 ClarificationRequired;拍板后 EXPECTATION_HIT conf=1 Accepted +- **评价判定**:PASS +- **性能与耗时**:约 10ms 内 + +### 用例 2:强指令不确认 + +- **测试输入**:`生成需求表`;协调层 `Confidence=0.86` intake +- **实际输出**:`TestStageAwareRequirements_StrongIntakeDirect` / `TestConfirmGate_StrongHighConfidenceDirect` PASS +- **评价判定**:PASS + +### 用例 3:用户需求表不再跨流误拦 + +- **测试输入**:main + V0.1「编写用户需求表」 +- **实际输出**:`TestStageAwareRequirements_WriteUserRequirementsV01` → TRIGGER_TASK/user_requirements,无 mismatch +- **评价判定**:PASS + +### 用例 4:前端降级 + +- **测试输入**:源码/单测 `isTaskStageDegradeCode` +- **实际输出**:`useTaskRunAttempts.test.ts` PASS;含「改由对话确认引导」 +- **评价判定**:PASS + +### 用例 5:全量回归 + +- **实际输出**:`web npm test` 471+33 PASS,`npm run build` PASS;`go test -short ./internal/intent ./internal/coordinator` PASS。全量 `./...` 中 `notification`/`storage` 既有 PG 唯一约束失败,与本改动无关(与既有报告一致)。 +- **评价判定**:PASS(本改动路径) + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +```text +go test ./internal/intent ./internal/coordinator -run 'StageAware|ConfirmGate|ProcessIntent_Weak' → PASS +web npm test → 471 + vitest 33 PASS +npm run build → PASS +``` + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +- 代码与单元/前端门禁达到合入标准。 +- **合入与真实链路**:集成槽位释放后 `--no-ff` 合入 `dev`,`./scripts/env-service.sh dev rebuild`,在商务对接项目实测「整理一下需求」确认卡片与拍板执行。 +- test/main 不晋升;不改 `production_ready`;项目 143 未改写。 diff --git a/.doc/test-reports/assets/2026-09-21-intent-confirm-stage-aware/go-unit.txt b/.doc/test-reports/assets/2026-09-21-intent-confirm-stage-aware/go-unit.txt new file mode 100644 index 00000000..255f7d30 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-intent-confirm-stage-aware/go-unit.txt @@ -0,0 +1,30 @@ +=== RUN TestStageAwareRequirements_WeakVerbIntakeConfirm +2026/09/21 08:27:04 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.78 +2026/09/21 08:27:04 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.78 +2026/09/21 08:27:04 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.78 +--- PASS: TestStageAwareRequirements_WeakVerbIntakeConfirm (0.00s) +=== RUN TestStageAwareRequirements_WriteUserRequirementsV01 +2026/09/21 08:27:04 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.86 +--- PASS: TestStageAwareRequirements_WriteUserRequirementsV01 (0.00s) +=== RUN TestStageAwareRequirements_StrongIntakeDirect +2026/09/21 08:27:04 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.86 +--- PASS: TestStageAwareRequirements_StrongIntakeDirect (0.00s) +=== RUN TestStageAwareRequirements_WeakNonRequirementsStaysCasual +2026/09/21 08:27:04 INFO intent_recognition intent=CASUAL_CHAT branch=AMBIGUOUS_FALLBACK confidence=0.2 +--- PASS: TestStageAwareRequirements_WeakNonRequirementsStaysCasual (0.00s) +PASS +ok chat-agent-system/internal/intent 0.008s +=== RUN TestConfirmGate_WeakVerbNeedsConfirmation +--- PASS: TestConfirmGate_WeakVerbNeedsConfirmation (0.00s) +=== RUN TestConfirmGate_StrongHighConfidenceDirect +--- PASS: TestConfirmGate_StrongHighConfidenceDirect (0.00s) +=== RUN TestConfirmGate_StageMismatchRemapsDefaultToIntake +--- PASS: TestConfirmGate_StageMismatchRemapsDefaultToIntake (0.00s) +=== RUN TestProcessIntent_WeakRequirementsConfirmsIntake +2026/09/21 08:27:04 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.78 +2026/09/21 08:27:04 INFO coordinator_route intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.78 channel=SQUAD_FIXED reason=标准任务/阶段推进/工作流运行/长任务续跑指令,进入小队受控执行 +2026/09/21 08:27:04 INFO intent_recognition intent=TRIGGER_TASK branch=EXPECTATION_HIT confidence=1 +2026/09/21 08:27:04 INFO coordinator_route intent=TRIGGER_TASK branch=EXPECTATION_HIT confidence=1 channel=SQUAD_FIXED reason=标准任务/阶段推进/工作流运行/长任务续跑指令,进入小队受控执行 +--- PASS: TestProcessIntent_WeakRequirementsConfirmsIntake (0.00s) +PASS +ok chat-agent-system/internal/coordinator 0.006s diff --git a/.doc/test-reports/assets/2026-09-21-intent-confirm-stage-aware/web-degrade.txt b/.doc/test-reports/assets/2026-09-21-intent-confirm-stage-aware/web-degrade.txt new file mode 100644 index 00000000..4c475024 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-21-intent-confirm-stage-aware/web-degrade.txt @@ -0,0 +1,13 @@ +✔ chat and task attempts live in one hook (0.843565ms) +✔ stage unresolved and not-in-active-stage degrade to chat once (0.105797ms) +✔ failed run with error_message surfaces TASK_FAILED not NETWORK_ERROR (0.514175ms) +✔ snake_case blocked_pending_governance is governance, not a network error (0.240533ms) +✔ unknown 4xx business code is not reported as a network error (0.236113ms) +ℹ tests 5 +ℹ suites 0 +ℹ pass 5 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 94.410096 diff --git a/internal/coordinator/confirm_gate.go b/internal/coordinator/confirm_gate.go new file mode 100644 index 00000000..b369a032 --- /dev/null +++ b/internal/coordinator/confirm_gate.go @@ -0,0 +1,203 @@ +package coordinator + +import ( + "context" + "fmt" + "strings" + "time" + + "chat-agent-system/internal/intent" + "chat-agent-system/internal/maturity" + "chat-agent-system/internal/projectinstance" +) + +// taskDisplayTitle 用户可见的交付物标题(确认话术用)。 +func taskDisplayTitle(taskKey string) string { + switch strings.TrimSpace(taskKey) { + case "intake", "bd_intake": + return "用户初始需求收集表" + case "user_requirements", "bd_user_requirements": + return "用户需求表" + case "research", "bd_research": + return "技术与市场调研报告" + case "feasibility_reg", "bd_feasibility_reg": + return "法规与注册论证报告" + case "feasibility_dev", "bd_feasibility_dev": + return "设计开发可行性评估" + case "prelim_design", "bd_prelim_design": + return "系统初步设计方案" + case "cdo_quote", "bd_cdo_quote": + return "CDO报价单" + default: + if taskKey == "" { + return "目标交付物" + } + return taskKey + } +} + +// confirmGate 智能确认门禁:弱写动词 / 阶段不匹配 / 中等置信时先下发 PendingExpectation,不盲目执行。 +// 返回非 nil 时 handleTriggerTask 应直接返回该结果。 +func (e *CoordinatorEngine) confirmGate( + ctx context.Context, + cand *intent.CandidateIntent, + userCtx *UserProjectContext, + taskKey string, +) *CoordinationResult { + if cand == nil || userCtx == nil { + return nil + } + if cand.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { + return nil + } + + needsConfirm := cand.FilledSlots[intent.SlotNeedsConfirmation] == "true" + midConfidence := cand.Confidence >= 0.6 && cand.Confidence <= 0.85 + + stageMismatch := false + if e != nil && e.instSvc != nil && e.instSvc.Available() { + projectID := parseDecimalID(userCtx.Project.ID) + userID := parseDecimalID(userCtx.User.ID) + if projectID > 0 && userID > 0 && taskKey != "" { + isCreator := userCtx.Project.CreatorUserID > 0 && userID == userCtx.Project.CreatorUserID + d := e.instSvc.CheckDocTaskAccess(ctx, projectID, userID, taskKey, projectinstance.DocAccessActionRun, isCreator) + if !d.Allowed && d.Code == projectinstance.AccessCodeNotCurrentStage { + stageMismatch = true + } + } + } + + if !needsConfirm && !stageMismatch && !midConfidence { + return nil + } + + title := taskDisplayTitle(taskKey) + exp := buildTriggerConfirmExpectation(taskKey, title, userCtx, stageMismatch) + userCtx.applyPending(exp) + + prompt := formatTriggerConfirmPrompt(title, needsConfirm, stageMismatch, midConfidence, userCtx) + return &CoordinationResult{ + Status: StatusClarificationRequired, + Intent: cand.Intent, + Branch: cand.Branch, + ClarificationPrompt: prompt, + NaturalReply: prompt, + UpdatedContext: userCtx, + NextExpectation: exp, + } +} + +func buildTriggerConfirmExpectation( + taskKey, title string, + userCtx *UserProjectContext, + stageMismatch bool, +) *intent.PendingExpectation { + optRun := intent.ExpectationOption{ + OptionID: "1", + Title: fmt.Sprintf("开始整理并编写《%s》", title), + Description: "确认后启动正式文档任务,写入受控交付物", + Intent: intent.IntentTriggerTask, + Slots: map[string]string{ + intent.SlotTargetTask: taskKey, + }, + } + // 阶段不匹配时:默认改为当前阶段摸底表,避免再次撞门禁。 + if stageMismatch { + stage := userCtx.Project.CurrentStage + if stage == "" || stage == maturity.StageV01_Triage { + optRun = intent.ExpectationOption{ + OptionID: "1", + Title: "改为编制当前阶段《用户初始需求收集表》", + Description: "当前仍在 V0.1 摸底期,先完成初始需求收集更合适", + Intent: intent.IntentTriggerTask, + Slots: map[string]string{ + intent.SlotTargetTask: "intake", + }, + } + } + } + + optTalk := intent.ExpectationOption{ + OptionID: "2", + Title: "先在对话中梳理,暂不启动文档任务", + Description: "继续对话细化需求要点,稍后再决定是否生成正式文档", + Intent: intent.IntentCasualChat, + } + + options := []intent.ExpectationOption{optRun, optTalk} + if stageMismatch { + options = append(options, intent.ExpectationOption{ + OptionID: "3", + Title: "先推进到下一成熟度阶段", + Description: "确认阶段准入后再编制后续文档", + Intent: intent.IntentAdvanceStage, + Slots: map[string]string{ + intent.SlotTargetStage: string(nextStageAfter(userCtx.Project.CurrentStage)), + }, + }) + } + + return &intent.PendingExpectation{ + ExpectationID: fmt.Sprintf("confirm-trigger-%d", time.Now().UnixNano()), + ExpectedIntent: intent.IntentTriggerTask, + TargetAction: "confirm_trigger_task", + Prompt: fmt.Sprintf("是否开始编写《%s》?", title), + AffirmativeWord: "好", + DeclineWord: "先不", + ExpiresAt: time.Now().Add(30 * time.Minute), + ContextSnippet: fmt.Sprintf("候选任务:%s(%s)", title, taskKey), + DefaultOption: &optRun, + Options: options, + AllowAffirmative: true, + CreatedAt: time.Now(), + } +} + +func nextStageAfter(stage maturity.MaturityStage) maturity.MaturityStage { + switch stage { + case maturity.StageV01_Triage, "": + return maturity.StageV05_Feasibility + case maturity.StageV05_Feasibility: + return maturity.StageV10_Quoting + default: + return maturity.StageV05_Feasibility + } +} + +func formatTriggerConfirmPrompt( + title string, + needsConfirm, stageMismatch, midConfidence bool, + userCtx *UserProjectContext, +) string { + var b strings.Builder + switch { + case stageMismatch: + stageLabel := string(userCtx.Project.CurrentStage) + if stageLabel == "" { + stageLabel = "初期" + } + b.WriteString(fmt.Sprintf( + "您提到的《%s》不在当前活动阶段(%s)。为避免启动后失败,请先确认下一步:\n\n", + title, stageLabel, + )) + b.WriteString("1. 改为编制当前阶段《用户初始需求收集表》(推荐,回复「好」或「1」)\n") + b.WriteString("2. 先在对话中梳理,暂不启动文档任务\n") + b.WriteString("3. 先推进到下一成熟度阶段\n") + case needsConfirm: + b.WriteString(fmt.Sprintf( + "我理解您希望先整理需求。更稳妥的做法是开始整理并编写《%s》。请确认:\n\n", + title, + )) + b.WriteString(fmt.Sprintf("1. 开始整理并编写《%s》(推荐,回复「好」或「1」)\n", title)) + b.WriteString("2. 先在对话中梳理,暂不启动文档任务\n") + default: // midConfidence + b.WriteString(fmt.Sprintf( + "我倾向于为您启动《%s》的编制任务,但表述略有歧义。请确认:\n\n", + title, + )) + b.WriteString(fmt.Sprintf("1. 确认启动《%s》(回复「好」或「1」)\n", title)) + b.WriteString("2. 先在对话中说明,暂不启动\n") + } + _ = midConfidence + return b.String() +} diff --git a/internal/coordinator/confirm_gate_test.go b/internal/coordinator/confirm_gate_test.go new file mode 100644 index 00000000..307b51fb --- /dev/null +++ b/internal/coordinator/confirm_gate_test.go @@ -0,0 +1,91 @@ +package coordinator + +import ( + "context" + "strings" + "testing" + + "chat-agent-system/internal/intent" + "chat-agent-system/internal/maturity" +) + +func TestConfirmGate_WeakVerbNeedsConfirmation(t *testing.T) { + eng := NewCoordinatorEngine(nil) + userCtx := &UserProjectContext{ + User: UserInfo{ID: "1", Name: "u"}, + Project: ProjectInfo{ID: "9", Name: "P", CurrentStage: maturity.StageV01_Triage, ActiveWorkflowKey: "main"}, + } + cand := &intent.CandidateIntent{ + Intent: intent.IntentTriggerTask, + Confidence: 0.78, + Branch: intent.BranchNewIntentPreempt, + FilledSlots: map[string]string{ + intent.SlotTargetTask: "intake", + intent.SlotNeedsConfirmation: "true", + }, + } + res, err := eng.handleTriggerTask(context.Background(), cand, userCtx) + if err != nil { + t.Fatal(err) + } + if res.Status != StatusClarificationRequired { + t.Fatalf("status=%s want CLARIFICATION_REQUIRED", res.Status) + } + if res.NextExpectation == nil || len(res.NextExpectation.Options) < 2 { + t.Fatalf("missing confirm expectation: %+v", res.NextExpectation) + } + if res.NextExpectation.Options[0].Slots[intent.SlotTargetTask] != "intake" { + t.Fatalf("default option task=%v", res.NextExpectation.Options[0].Slots) + } + if !strings.Contains(res.NaturalReply, "用户初始需求收集表") { + t.Fatalf("reply missing intake title: %s", res.NaturalReply) + } + if userCtx.PendingExpectation == nil { + t.Fatal("pending expectation must be applied") + } +} + +func TestConfirmGate_StrongHighConfidenceDirect(t *testing.T) { + eng := NewCoordinatorEngine(nil) + userCtx := &UserProjectContext{ + User: UserInfo{ID: "1", Name: "u"}, + Project: ProjectInfo{ID: "9", Name: "P", CurrentStage: maturity.StageV01_Triage, ActiveWorkflowKey: "main"}, + } + cand := &intent.CandidateIntent{ + Intent: intent.IntentTriggerTask, + Confidence: 0.86, + Branch: intent.BranchNewIntentPreempt, + FilledSlots: map[string]string{ + intent.SlotTargetTask: "intake", + }, + } + res, err := eng.handleTriggerTask(context.Background(), cand, userCtx) + if err != nil { + t.Fatal(err) + } + // nil tasks runner → accepted stub reply, not clarification + if res.Status != StatusAccepted { + t.Fatalf("status=%s want ACCEPTED (no confirm for strong high-conf)", res.Status) + } + if strings.Contains(res.NaturalReply, "请确认") { + t.Fatalf("must not ask confirm: %s", res.NaturalReply) + } +} + +func TestConfirmGate_StageMismatchRemapsDefaultToIntake(t *testing.T) { + // Force stage-mismatch path without instSvc by calling confirmGate helper with a + // synthetic candidate; exercise buildTriggerConfirmExpectation via public path + // using mid-confidence + manual expectation builder check. + exp := buildTriggerConfirmExpectation("user_requirements", "用户需求表", &UserProjectContext{ + Project: ProjectInfo{CurrentStage: maturity.StageV01_Triage}, + }, true) + if exp.Options[0].Slots[intent.SlotTargetTask] != "intake" { + t.Fatalf("stage mismatch default must remap to intake, got %v", exp.Options[0].Slots) + } + if len(exp.Options) < 3 { + t.Fatalf("want advance-stage option, got %d options", len(exp.Options)) + } + if exp.Options[2].Intent != intent.IntentAdvanceStage { + t.Fatalf("option 3 want ADVANCE_STAGE, got %s", exp.Options[2].Intent) + } +} diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index ab466d78..6524b319 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -972,6 +972,11 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent. } } + // 智能确认门禁:弱写动词 / 阶段不匹配 / 中等置信 → 先确认,不盲目执行。 + if gated := e.confirmGate(ctx, cand, userCtx, taskKey); gated != nil { + return gated, nil + } + nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) userCtx.applyPending(nextExp) diff --git a/internal/coordinator/stage_aware_confirm_e2e_test.go b/internal/coordinator/stage_aware_confirm_e2e_test.go new file mode 100644 index 00000000..199e4917 --- /dev/null +++ b/internal/coordinator/stage_aware_confirm_e2e_test.go @@ -0,0 +1,59 @@ +package coordinator_test + +import ( + "context" + "strings" + "testing" + + "chat-agent-system/internal/coordinator" + "chat-agent-system/internal/intent" + "chat-agent-system/internal/maturity" +) + +func TestProcessIntent_WeakRequirementsConfirmsIntake(t *testing.T) { + eng := coordinator.NewCoordinatorEngine(nil) + userCtx := &coordinator.UserProjectContext{ + User: coordinator.UserInfo{ID: "1", Name: "u"}, + Project: coordinator.ProjectInfo{ + ID: "9", + Name: "商务对接演示", + CurrentStage: maturity.StageV01_Triage, + ActiveWorkflowKey: "main", + }, + } + rec := intent.NewRecognizer() + recRes, err := rec.Recognize(context.Background(), "整理一下需求", userCtx.ToSnapshot()) + if err != nil { + t.Fatal(err) + } + res, err := eng.ProcessIntent(context.Background(), recRes, userCtx) + if err != nil { + t.Fatal(err) + } + if res.Status != coordinator.StatusClarificationRequired { + t.Fatalf("status=%s want CLARIFICATION_REQUIRED", res.Status) + } + if res.NextExpectation == nil || res.NextExpectation.Options[0].Slots[intent.SlotTargetTask] != "intake" { + t.Fatalf("default option want intake: %+v", res.NextExpectation) + } + if !strings.Contains(res.NaturalReply, "用户初始需求收集表") { + t.Fatalf("reply=%q", res.NaturalReply) + } + + // 用户拍板「好」→ 兑现 TriggerTask/intake,不再要求确认(无 tasks runner 时 Accepted stub) + userCtx.PendingExpectation = res.NextExpectation + aff, err := rec.Recognize(context.Background(), "好", userCtx.ToSnapshot()) + if err != nil { + t.Fatal(err) + } + if aff.TopIntent.Intent != intent.IntentTriggerTask || aff.TopIntent.FilledSlots[intent.SlotTargetTask] != "intake" { + t.Fatalf("affirm want TRIGGER_TASK/intake, got %s %v", aff.TopIntent.Intent, aff.TopIntent.FilledSlots) + } + done, err := eng.ProcessIntent(context.Background(), aff, userCtx) + if err != nil { + t.Fatal(err) + } + if done.Status != coordinator.StatusAccepted { + t.Fatalf("after affirm status=%s want ACCEPTED", done.Status) + } +} diff --git a/internal/intent/recognizer.go b/internal/intent/recognizer.go index ca64939c..fed5044d 100644 --- a/internal/intent/recognizer.go +++ b/internal/intent/recognizer.go @@ -1199,6 +1199,65 @@ func hasTaskWriteVerb(input string) bool { return false } +// hasWeakTaskWriteVerb 弱写动词:倾向整理/梳理,默认需确认后再启动正式任务。 +func hasWeakTaskWriteVerb(input string) bool { + for _, verb := range []string{"整理", "梳理", "汇总", "归纳", "收集"} { + if strings.Contains(input, verb) { + return true + } + } + return false +} + +func isRequirementsTopic(input string) bool { + if strings.Contains(input, "需求") || strings.Contains(input, "摸底") { + return true + } + return strings.Contains(input, "用户") && (hasTaskWriteVerb(input) || hasWeakTaskWriteVerb(input)) +} + +// hasURSStrongMarker URS / 用户需求规格书级强标记(非「初始/摸底」时才算)。 +func hasURSStrongMarker(input string) bool { + if hasIntakeSemanticMarker(input) { + return false + } + return strings.Contains(input, "URS") || + strings.Contains(input, "用户需求规格") || + strings.Contains(input, "用户需求表") +} + +// stageAwareRequirementsTask 按当前阶段消解需求类任务键,与前端 resolveRequirementsTaskKey 对齐: +// V0.1 / 空阶段且无 URS 强标记 → intake;含 URS 强标记 → user_requirements。 +func stageAwareRequirementsTask(input string, userCtx *ContextSnapshot) string { + if hasIntakeSemanticMarker(input) { + return "intake" + } + stage := "" + if userCtx != nil { + stage = userCtx.CurrentStage + } + if hasURSStrongMarker(input) { + return "user_requirements" + } + // 含糊「需求」在初期默认摸底表,避免误触 V0.5 门禁失败。 + if stage == "" || stage == string(maturity.StageV01_Triage) { + return "intake" + } + if strings.Contains(input, "用户需求") || strings.Contains(input, "需求表") { + return "user_requirements" + } + return "intake" +} + +func requirementsTriggerPhrase(taskKey string) string { + switch taskKey { + case "user_requirements": + return "用户需求表" + default: + return "用户初始需求收集表" + } +} + func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent { type phrase struct { text string @@ -1232,16 +1291,33 @@ func matchTriggerTask(input string, userCtx *ContextSnapshot) *CandidateIntent { return phrases[i].text < phrases[j].text }) - if !hasTaskWriteVerb(input) { + strong := hasTaskWriteVerb(input) + weak := hasWeakTaskWriteVerb(input) + if !strong && !weak { return nil } - // 「初始 / 首轮 / 摸底」是 V0.1 摸底,不能被更短的研发别名「用户需求」截走。 - if hasIntakeSemanticMarker(input) && strings.Contains(input, "需求") { - if cand := scopedTriggerTaskCandidate(input, "需求表", "intake", userCtx); cand != nil { + + // 需求类话题优先阶段感知消解,避免研发短别名「用户需求」截走商务流初期表述。 + if isRequirementsTopic(input) { + taskKey := stageAwareRequirementsTask(input, userCtx) + phrase := requirementsTriggerPhrase(taskKey) + if cand := scopedTriggerTaskCandidate(input, phrase, taskKey, userCtx); cand != nil { return cand } - return triggerTaskCandidate("需求表", "intake") + cand := triggerTaskCandidate(phrase, taskKey) + if !strong && weak { + cand.Confidence = 0.78 + cand.FilledSlots[SlotNeedsConfirmation] = "true" + cand.Reasoning = fmt.Sprintf("弱写动词触发需求整理,阶段感知消解为 %s,需用户确认后执行", taskKey) + } + return cand } + + if !strong { + // 弱写动词仅覆盖需求类宾语;其他话题仍走对话兜底。 + return nil + } + 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/stage_aware_requirements_test.go b/internal/intent/stage_aware_requirements_test.go new file mode 100644 index 00000000..560bae37 --- /dev/null +++ b/internal/intent/stage_aware_requirements_test.go @@ -0,0 +1,96 @@ +package intent_test + +import ( + "context" + "testing" + + "chat-agent-system/internal/intent" + "chat-agent-system/internal/maturity" +) + +func TestStageAwareRequirements_WeakVerbIntakeConfirm(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + ProjectName: "P", + CurrentStage: string(maturity.StageV01_Triage), + ActiveWorkflowKey: "main", + } + for _, in := range []string{"整理一下需求", "帮我梳理需求", "汇总一下需求要点"} { + res, err := rec.Recognize(context.Background(), in, ctx) + if err != nil { + t.Fatalf("%s: %v", in, err) + } + top := res.TopIntent + if top.Intent != intent.IntentTriggerTask { + t.Fatalf("%s want TRIGGER_TASK, got %s", in, top.Intent) + } + if top.FilledSlots[intent.SlotTargetTask] != "intake" { + t.Fatalf("%s want intake, got %s", in, top.FilledSlots[intent.SlotTargetTask]) + } + if top.FilledSlots[intent.SlotNeedsConfirmation] != "true" { + t.Fatalf("%s want needs_confirmation, slots=%v", in, top.FilledSlots) + } + if top.Confidence < 0.7 || top.Confidence > 0.85 { + t.Fatalf("%s conf=%.2f want mid-band for confirm gate", in, top.Confidence) + } + } +} + +func TestStageAwareRequirements_WriteUserRequirementsV01(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + ProjectName: "P", + CurrentStage: string(maturity.StageV01_Triage), + ActiveWorkflowKey: "main", + } + res, err := rec.Recognize(context.Background(), "编写用户需求表", ctx) + if err != nil { + t.Fatal(err) + } + top := res.TopIntent + // V0.1 期显式点名用户需求表 → user_requirements(由协调层阶段门禁确认引导,不作跨流拦截) + if top.Intent != intent.IntentTriggerTask || top.FilledSlots[intent.SlotTargetTask] != "user_requirements" { + t.Fatalf("want TRIGGER_TASK/user_requirements, got %s %v", top.Intent, top.FilledSlots) + } + if top.FilledSlots[intent.SlotWorkflowScopeMismatch] == "true" { + t.Fatalf("must not treat 用户需求表 as rdev mismatch: %v", top.FilledSlots) + } + if top.ClarificationQuestion != "" { + t.Fatalf("must not clarify for workflow scope: %q", top.ClarificationQuestion) + } +} + +func TestStageAwareRequirements_StrongIntakeDirect(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + ProjectName: "P", + CurrentStage: string(maturity.StageV01_Triage), + ActiveWorkflowKey: "main", + } + res, err := rec.Recognize(context.Background(), "生成需求表", ctx) + if err != nil { + t.Fatal(err) + } + top := res.TopIntent + if top.Intent != intent.IntentTriggerTask || top.FilledSlots[intent.SlotTargetTask] != "intake" { + t.Fatalf("want TRIGGER_TASK/intake, got %s %v", top.Intent, top.FilledSlots) + } + if top.FilledSlots[intent.SlotNeedsConfirmation] == "true" { + t.Fatal("strong write verb must not force needs_confirmation") + } +} + +func TestStageAwareRequirements_WeakNonRequirementsStaysCasual(t *testing.T) { + rec := intent.NewRecognizer() + ctx := &intent.ContextSnapshot{ + CurrentStage: string(maturity.StageV01_Triage), + ActiveWorkflowKey: "main", + } + res, err := rec.Recognize(context.Background(), "整理一下会议纪要", ctx) + if err != nil { + t.Fatal(err) + } + if res.TopIntent.Intent == intent.IntentTriggerTask { + t.Fatalf("non-requirements weak verb must not TRIGGER_TASK, got %v", res.TopIntent.FilledSlots) + } +} diff --git a/internal/intent/types.go b/internal/intent/types.go index 7ce3e7bb..e655e0da 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -166,6 +166,9 @@ const ( SlotWorkDir = "work_dir" SlotRunAction = "run_action" // status | resume | restart SlotSupplementAction = "supplement_action" // add | correct | attach + + // 智能确认:弱写动词等场景由协调层下发 PendingExpectation,用户拍板后再执行 + SlotNeedsConfirmation = "needs_confirmation" // "true" ) // CardAction 交互卡片按钮动作 diff --git a/web/src/engine/templateMatch.ts b/web/src/engine/templateMatch.ts index d589e9f2..67ed3d67 100644 --- a/web/src/engine/templateMatch.ts +++ b/web/src/engine/templateMatch.ts @@ -3,6 +3,7 @@ import { DeliverableItem } from '../types/workbench'; const READ_ONLY_INTENT = /(?:总结|概括|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/; const WRITE_INTENT = + // 弱写动词(整理/梳理/汇总等)故意不含:走聊天通道,由后端阶段感知 + 确认门禁引导。 /(?:开始|编写|编制|生成|起草|创建|撰写|重写|更新|修改|完善|补充|刷新|重做|改写)/; const CURRENT_DOC_DEIXIS = /当前受控文档|当前文档|本文件|这份文档|本文档|本受控文档/; diff --git a/web/src/hooks/useTaskRunAttempts.test.ts b/web/src/hooks/useTaskRunAttempts.test.ts index 580dae47..d4fdf54e 100644 --- a/web/src/hooks/useTaskRunAttempts.test.ts +++ b/web/src/hooks/useTaskRunAttempts.test.ts @@ -5,6 +5,7 @@ import { isBlockedGovernanceStatus } from '../lib/blockedGovernanceCopy'; import { buildFailedTaskErrorInfo, buildNetworkErrorInfo, + isTaskStageDegradeCode, readRemoteTaskRun, } from '../lib/taskRunErrorInfo'; @@ -22,6 +23,15 @@ test('chat and task attempts live in one hook', () => { assert.equal(hook.includes('chatWindow.allMessages.find'), true); assert.equal(hook.includes('buildFailedTaskErrorInfo'), true); assert.equal(hook.includes('buildCancelledTaskErrorInfo'), true); + assert.equal(hook.includes('isTaskStageDegradeCode'), true); + assert.equal(hook.includes('改由对话确认引导'), true); +}); + +test('stage unresolved and not-in-active-stage degrade to chat once', () => { + assert.equal(isTaskStageDegradeCode('TASK_SCOPE_UNRESOLVED'), true); + assert.equal(isTaskStageDegradeCode('DOC_TASK_NOT_IN_ACTIVE_STAGE'), true); + assert.equal(isTaskStageDegradeCode('DOC_BUSY'), false); + assert.equal(isTaskStageDegradeCode(''), false); }); test('failed run with error_message surfaces TASK_FAILED not NETWORK_ERROR', () => { diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index 82bcc5b6..c09ec1a6 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -29,6 +29,7 @@ import { buildFailedTaskErrorInfo, buildInterruptedTaskErrorInfo, buildNetworkErrorInfo, + isTaskStageDegradeCode, readRemoteTaskRun, } from '../lib/taskRunErrorInfo'; import { @@ -790,6 +791,27 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { ? Number((_err as { httpStatus?: number }).httpStatus) : undefined; const errMeta = _err as { errorCode?: string; detail?: string; message?: string }; + // 阶段/作用域未解析:降级聊天一次,由后端意图确认引导,替代仅弹错误气泡。 + if ( + mode === 'run' && + attemptIndex === 0 && + isTaskStageDegradeCode(errMeta.errorCode) && + task.userMessage?.trim() + ) { + settled = true; + patchAsst({ + status: 'pending', + livePhaseLabel: '改由对话确认引导…', + errorInfo: undefined, + taskRequest: undefined, + content: '', + }); + taskRunStream.setThinking(sessionId, false); + runStreamAttempt(task.userMessage.trim(), asstId, 0, { + clientTurnId: task.clientTurnId, + }); + return; + } failOrRetry( buildNetworkErrorInfo(httpStatus, { errorCode: errMeta.errorCode, diff --git a/web/src/lib/taskRunErrorInfo.ts b/web/src/lib/taskRunErrorInfo.ts index ece1444c..328f14c3 100644 --- a/web/src/lib/taskRunErrorInfo.ts +++ b/web/src/lib/taskRunErrorInfo.ts @@ -35,6 +35,11 @@ const fatalDenialCopy: Record = { TASK_SCOPE_UNRESOLVED: '无法定位该文档任务的工作流阶段,请检查任务名或联系管理员', }; +/** 任务直通道失败后应降级到聊天确认引导的错误码(仅降级一次,防循环)。 */ +export function isTaskStageDegradeCode(code: string | undefined | null): boolean { + return code === 'TASK_SCOPE_UNRESOLVED' || code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE'; +} + export function buildNetworkErrorInfo( httpStatus?: number, opts?: { errorCode?: string; detail?: string },