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:
+4
-4
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 交互卡片按钮动作
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DeliverableItem } from '../types/workbench';
|
||||
const READ_ONLY_INTENT =
|
||||
/(?:总结|概括|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/;
|
||||
const WRITE_INTENT =
|
||||
// 弱写动词(整理/梳理/汇总等)故意不含:走聊天通道,由后端阶段感知 + 确认门禁引导。
|
||||
/(?:开始|编写|编制|生成|起草|创建|撰写|重写|更新|修改|完善|补充|刷新|重做|改写)/;
|
||||
const CURRENT_DOC_DEIXIS = /当前受控文档|当前文档|本文件|这份文档|本文档|本受控文档/;
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user