merge: fix/intent-confirm-stage-aware-2026-09-21

Stage-aware requirements routing with smart confirm gate and chat degrade.
This commit is contained in:
weichen
2026-09-21 08:28:56 +00:00
16 changed files with 742 additions and 9 deletions
+4 -4
View File
@@ -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 |
@@ -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`
@@ -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 未改写。
@@ -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
@@ -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
+203
View File
@@ -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()
}
+91
View File
@@ -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)
}
}
+5
View File
@@ -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)
@@ -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)
}
}
+81 -5
View File
@@ -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 {
@@ -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)
}
}
+3
View File
@@ -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 交互卡片按钮动作
+1
View File
@@ -3,6 +3,7 @@ import { DeliverableItem } from '../types/workbench';
const READ_ONLY_INTENT =
/(?:总结|概括|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/;
const WRITE_INTENT =
// 弱写动词(整理/梳理/汇总等)故意不含:走聊天通道,由后端阶段感知 + 确认门禁引导。
/(?:开始|编写|编制|生成|起草|创建|撰写|重写|更新|修改|完善|补充|刷新|重做|改写)/;
const CURRENT_DOC_DEIXIS = /当前受控文档|当前文档|本文件|这份文档|本文档|本受控文档/;
+10
View File
@@ -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', () => {
+22
View File
@@ -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,
+5
View File
@@ -35,6 +35,11 @@ const fatalDenialCopy: Record<string, string> = {
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 },