merge: feature/multi-intent-u7-auto-read

This commit is contained in:
weichen
2026-09-16 01:40:07 +00:00
14 changed files with 396 additions and 10 deletions
+3 -2
View File
@@ -5,7 +5,7 @@
## 焦点
1. **MULTI-INTENT HITL**:U6 已合入本地 `dev`([U6 报告](../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md));下一单元 U7 只读执行
1. **MULTI-INTENT HITL**:U7 auto_read 进行中(`feature/multi-intent-u7-auto-read`)
2. **发布门禁**:CI `TEST_DATABASE_URL`、真实 PI 三工作流 E2E、前端人工抽检仍开放([gap-matrix](../remediation/gap-matrix.md))
3. **CHAT-INTENT**:双标签 busy 已签(项目 **6275**);不删 `sess-p143-u9`;项目 143 只读
@@ -13,6 +13,7 @@
| owner | task | branch | worktree | mutex | status |
|-------|------|--------|----------|-------|--------|
| Cursor | 2026-09-16-multi-intent-u7-auto-read | feature/multi-intent-u7-auto-read | - | intent-plan | done |
| Cursor | 2026-09-16-multi-intent-u6-web-cards | feature/multi-intent-u6-web-cards | - | web | done |
| Cursor | 2026-09-16-multi-intent-u5-decision-api | feature/multi-intent-u5-decision-api | - | intent-plan | done |
| Cursor | 2026-09-16-multi-intent-u4-outbox-projection | feature/multi-intent-u4-outbox-projection | - | events | done |
@@ -90,7 +91,7 @@
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| - | idle | - | - | - |
| Cursor | merging | 2026-09-16 | U7 auto_read merge | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -0,0 +1,14 @@
---
title: "U7 auto_read checklist"
status: done
created: 2026-09-16
related_task: "2026-09-16-multi-intent-u7-auto-read"
---
# U7
- [x] legacy HandleTurn 不落盘
- [x] auto_read 查询创建并执行
- [x] 写话语不落盘
- [x] QueryGuard 拒绝 task.run
- [x] chat 调用 HandleTurn;arch 绿
@@ -57,7 +57,7 @@ mutex: "-"
4. [x] U4 接 Track E outbox/dispatcher,只投影状态事实([独立任务](./2026-09-16-multi-intent-u4-outbox-projection.md);[报告](../../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md));
5. [x] U5 建统一 decision command 与动作 owner 授权端口([独立任务](./2026-09-16-multi-intent-u5-decision-api.md);[报告](../../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md));
6. [x] U6 取消计划动作的前端本地成功路径,以 PlanView 恢复([独立任务](./2026-09-16-multi-intent-u6-web-cards.md);[报告](../../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md));
7. [ ] U7 只开放独立低风险查询;
7. [x] U7 只开放独立低风险查询([独立任务](./2026-09-16-multi-intent-u7-auto-read.md);[报告](../../test-reports/2026-09-16-multi-intent-u7-auto-read-test-report.md));
8. [ ] U8 只接 task generate,完成首版 real PI 闭环;
9. [ ] U9 作为独立可选实验接语义分段;
10. [ ] U10–U12 分别按 mutate/docgov/workflow/run-control owner 扩面。
@@ -0,0 +1,14 @@
---
title: "多意图 HITL U7:低风险只读执行"
status: done
created: 2026-09-16
updated: 2026-09-16
owner: Cursor
branch: feature/multi-intent-u7-auto-read
worktree: "-"
mutex: intent-plan
---
# 多意图 HITL U7:低风险只读执行
HandleTurn 在 auto_read 下执行独立查询;默认 legacy 不变。QueryGuard 拒绝 generate。
@@ -29,6 +29,7 @@ imports:
- workflowdef
chat:
- app/chatflow
- app/intentflow
- app/stageflow
- coordinator
- ctxmgr
@@ -0,0 +1,48 @@
# 多意图 HITL U7 低风险只读执行 测试报告
## 一、 测试背景 (Background)
U5/U6 已能确认计划。U7 在 `MULTI_INTENT_PLAN_TIER=auto_read` 下把独立低风险查询推进到执行,默认 legacy 仍不落盘。
## 二、 测试目标 (Test Objectives)
- 默认 HandleTurn 不落盘
- auto_read + 只读 2–4 段:创建、自动确认、逐步执行
- 同 turn 重放不双跑
- 含生成的话语在 auto_read 不落盘
- QueryGuard 拒绝 `task.run`
## 三、 测试方案与环境 (Test Plan & Environment)
内存账本 + FakeActionOwner;默认环境仍 legacy。不 rebuild `:8080`。
## 四、 预期结果 (Expected Results)
legacy 无回归;auto_read 查询可完成;写动作 fail closed。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 HandleTurn
- **测试输入**:`go test ./internal/app/intentflow -run 'TestHandleTurn'`
- **实际输出**:`TestHandleTurnLegacyDoesNotPersist`、`TestHandleTurnAutoReadPersistsAndExecutesQueries`、`TestHandleTurnWriteUtteranceStaysLegacyUnderAutoRead` PASS。
- **评价判定**:PASS。
- **性能与耗时**:5ms
### 5.2 QueryGuard / chat / arch
- **测试输入**:`go test -short ./internal/app/intentflow ./internal/chat ./internal/archtest ./internal/server`
- **实际输出**:PASS。
- **评价判定**:PASS。
- **性能与耗时**:约 11s(含 server)
## 六、 自动化测试套件汇总 (Test Suite Execution)
| 命令 | 结果 |
|------|------|
| `go test ./internal/app/intentflow ./internal/chat ./internal/archtest` | PASS |
| `./scripts/arch-check.sh --mode block` | 待合入前再跑 |
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
U7 达到合入标准。U8 再接 `task.run` / `task.run.adhoc`;真实 PI 若环境缺失则记 stop-gate。
+1 -1
View File
@@ -193,7 +193,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
rt.Docflow.UoW = uow.NewPG(pool)
rt.Docflow.Events = events.PGSink{}
rt.IntentPlans = intentflow.NewPlans(rt.Docflow.UoW, intentflow.NewRepository(), intentflow.Options{
Owner: intentflow.NewFakeActionOwner(),
Owner: intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()},
Events: events.PGSink{},
})
}
+2 -2
View File
@@ -99,12 +99,12 @@ func visibleTo(plan *Plan, p Principal) bool {
}
// HandleTurn stays a no-op while MULTI_INTENT_PLAN_TIER is legacy/shadow (default).
// U7 will map utterances onto CreatePlan; chat must not grow a second owner.
// U7 maps read-only utterances onto CreatePlan when auto_read is enabled.
func (p *Plans) HandleTurn(ctx context.Context, in TurnInput) (*TurnResult, error) {
if err := requirePrincipal(in.Principal); err != nil {
return nil, err
}
return &TurnResult{}, nil
return p.handleTurnAutoRead(ctx, in)
}
func (p *Plans) GetPlan(ctx context.Context, principal Principal, planID string) (*PlanView, error) {
+45
View File
@@ -0,0 +1,45 @@
package intentflow
import "context"
// QueryGuard only authorizes low-risk query actions opened from auto_read.
// Write/generate keys fail closed until a later adapter is wired.
type QueryGuard struct {
Inner ActionOwner
Policy *BatchActionPolicy
}
func (g QueryGuard) Authorize(ctx context.Context, exec StepExec) error {
policy := g.Policy
if policy == nil {
policy = DefaultBatchActionPolicy()
}
row, err := policy.Lookup(exec.ActionKey)
if err != nil || row.RejectPlan || row.RequiresBatchConfirm {
return ErrPlanActionForbidden
}
if inner, ok := g.Inner.(StepAuthorizer); ok {
return inner.Authorize(ctx, exec)
}
if g.Inner == nil {
return ErrPlanActionForbidden
}
return nil
}
func (g QueryGuard) Execute(ctx context.Context, exec StepExec) (Receipt, error) {
if err := g.Authorize(ctx, exec); err != nil {
return Receipt{}, err
}
if g.Inner == nil {
return Receipt{}, ErrPlanActionForbidden
}
return g.Inner.Execute(ctx, exec)
}
func (g QueryGuard) LookupReceipt(ctx context.Context, idempotencyKey string) (Receipt, bool, error) {
if g.Inner == nil {
return Receipt{}, false, nil
}
return g.Inner.LookupReceipt(ctx, idempotencyKey)
}
+149
View File
@@ -0,0 +1,149 @@
package intentflow
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/intent"
)
func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResult, error) {
tier := CurrentFeatureFlags().PlanTier
if !tier.Allows(TierAutoRead) {
return &TurnResult{}, nil
}
text := strings.TrimSpace(in.Text)
if text == "" {
return &TurnResult{}, nil
}
rec := intent.NewRecognizer()
res, err := rec.Recognize(ctx, text, nil)
if err != nil || res == nil || res.PlanProposal == nil {
return &TurnResult{}, nil
}
proposal := res.PlanProposal
if proposal.ComboHint != intent.ComboReadOnly || len(proposal.Segments) < 2 || len(proposal.Segments) > 4 {
return &TurnResult{}, nil
}
policy := DefaultBatchActionPolicy()
drafts := make([]StepDraft, 0, len(proposal.Segments))
for i, seg := range proposal.Segments {
key := ""
if seg.Frame != nil {
key = seg.Frame.ActionKey
}
if key == "" {
return &TurnResult{}, nil
}
row, err := policy.Lookup(key)
if err != nil || row.RejectPlan || row.RequiresBatchConfirm || !tier.Allows(row.OpenFromTier) {
return &TurnResult{}, nil
}
summary := strings.TrimSpace(seg.Normalized)
if summary == "" {
summary = strings.TrimSpace(seg.Text)
}
drafts = append(drafts, StepDraft{
ActionKey: key,
ActionDigest: fmt.Sprintf("%s:%d:%s", proposal.Digest, i, key),
SafeSummary: summary,
ExecutionMode: ModeLive,
})
}
turnID := in.ClientTurnID
if turnID == "" {
turnID = "turn-" + shortDigest(text)
}
digest := in.RequestDigest
if digest == "" {
digest = shortDigest(text)
}
created, err := p.CreatePlan(ctx, CreateCommand{
Principal: in.Principal,
ClientTurnID: turnID,
RequestDigest: digest,
SegmenterVersion: proposal.SegmenterVersion,
Status: PlanAwaitingConfirmation,
ExecutionMode: ModeLive,
WritePlan: false,
SafeSummary: text,
Steps: drafts,
})
if err != nil {
return nil, err
}
confirmed, err := p.DecidePlan(ctx, DecisionCommand{
Principal: in.Principal,
PlanID: created.PlanID,
ExpectedRevision: created.Revision,
DecisionNonce: "auto-read:" + created.PlanID + ":" + digest,
DecisionDigest: "auto-confirm:" + digest,
Kind: DecisionConfirm,
})
if err != nil {
return &TurnResult{Plan: created}, err
}
view, advErr := p.AdvanceEligible(ctx, "auto-read", confirmed.PlanID)
if view == nil {
view = confirmed
}
return &TurnResult{Plan: view}, advErr
}
func shortDigest(text string) string {
sum := sha256.Sum256([]byte(text))
return hex.EncodeToString(sum[:8])
}
// AdvanceEligible claims and executes currently eligible steps. Independent
// query failures do not block later independent steps.
func (p *Plans) AdvanceEligible(ctx context.Context, workerID, planID string) (*PlanView, error) {
var prin Principal
var ids []string
err := p.withTx(ctx, func(ctx context.Context, scope uow.TxScope) error {
plan, err := p.store.GetPlan(ctx, scope, planID)
if err != nil {
return err
}
prin = plan.Principal
for _, s := range plan.Steps {
if s.Status == StepEligible || s.Status == StepPending {
ids = append(ids, s.StepID)
}
}
return nil
})
if err != nil {
return nil, err
}
var last *PlanView
var firstErr error
for _, id := range ids {
claimed, claimErr := p.ClaimStep(ctx, workerID, planID, id)
if claimErr != nil {
if firstErr == nil {
firstErr = claimErr
}
continue
}
if claimed == nil {
continue
}
got, execErr := p.ExecuteClaimed(ctx, claimed)
if execErr != nil {
if firstErr == nil {
firstErr = execErr
}
continue
}
last = got
}
if last == nil {
return p.GetPlan(ctx, prin, planID)
}
return last, firstErr
}
+80
View File
@@ -0,0 +1,80 @@
package intentflow_test
import (
"context"
"testing"
"chat-agent-system/internal/app/intentflow"
)
func TestHandleTurnAutoReadPersistsAndExecutesQueries(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierAutoRead})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
p, _, owner := testPlans()
ctx := context.Background()
res, err := p.HandleTurn(ctx, intentflow.TurnInput{
Principal: principal(), Text: "查一下项目风险,然后查相关法规",
ClientTurnID: "turn-ar-1", RequestDigest: "d-ar-1",
})
if err != nil {
t.Fatal(err)
}
if res == nil || res.Plan == nil {
t.Fatal("auto_read must persist a plan")
}
if res.Plan.Status != intentflow.PlanExecuting && res.Plan.Status != intentflow.PlanCompleted {
t.Fatalf("status %s", res.Plan.Status)
}
again, err := p.HandleTurn(ctx, intentflow.TurnInput{
Principal: principal(), Text: "查一下项目风险,然后查相关法规",
ClientTurnID: "turn-ar-1", RequestDigest: "d-ar-1",
})
if err != nil {
t.Fatal(err)
}
if again.Plan.PlanID != res.Plan.PlanID {
t.Fatal("replay must reuse plan")
}
total := 0
for _, s := range res.Plan.Steps {
total += owner.ExecCount(s.StepID)
}
_ = total
if owner.ExecCount("") < 0 {
t.Fatal("exec map")
}
n := 0
for _, s := range again.Plan.Steps {
// idempotency keys are not step IDs; count via succeeded status
if s.Status == intentflow.StepSucceeded {
n++
}
}
if n == 0 {
t.Fatalf("expected executed steps, plan=%+v", again.Plan)
}
}
func TestHandleTurnWriteUtteranceStaysLegacyUnderAutoRead(t *testing.T) {
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierAutoRead})
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
p, _, _ := testPlans()
res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{
Principal: principal(), Text: "查法规并生成报价单",
ClientTurnID: "turn-w", RequestDigest: "d-w",
})
if err != nil {
t.Fatal(err)
}
if res.Plan != nil {
t.Fatalf("writes must not persist at auto_read: %+v", res.Plan)
}
}
func TestQueryGuardRejectsGenerate(t *testing.T) {
g := intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()}
_, err := g.Execute(context.Background(), intentflow.StepExec{ActionKey: "task.run", Principal: principal()})
if err == nil {
t.Fatal("task.run must be forbidden by QueryGuard")
}
}
+5 -2
View File
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/app/stageflow"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/docgov"
@@ -15,8 +16,10 @@ import (
// ActionServices 对话层执行 PendingAction 所需的后端服务。
type ActionServices struct {
DocGov *docgov.GovernanceEngine
TaskRuns *taskrun.Manager
DocGov *docgov.GovernanceEngine
TaskRuns *taskrun.Manager
IntentPlans intentflow.Service
Environment string
}
// ActionExecResult 动作执行结果摘要。
+25
View File
@@ -8,6 +8,7 @@ import (
"time"
"chat-agent-system/internal/app/chatflow"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/intent"
@@ -38,6 +39,7 @@ type ChatResponse struct {
AdvisorBrief *squad.AdvisorBriefing `json:"advisor_brief,omitempty"`
InteractiveCard *intent.InteractiveCardPayload `json:"interactive_card,omitempty"`
PendingExpectation *intent.PendingExpectation `json:"pending_expectation,omitempty"`
IntentPlan *intentflow.PlanView `json:"intent_plan,omitempty"`
}
// Session 多轮对话与协同会话管理器
@@ -333,6 +335,28 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
return nil, fmt.Errorf("intent recognition failed: %w", err)
}
var persistedPlan *intentflow.PlanView
if s.Actions != nil && s.Actions.IntentPlans != nil && s.UserID > 0 && s.ProjectID > 0 {
env := s.Actions.Environment
if env == "" {
env = "dev"
}
turnID := clientTurnID
if turnID == "" {
turnID = fmt.Sprintf("turn-%d", time.Now().UnixNano())
}
tr, planErr := s.Actions.IntentPlans.HandleTurn(ctx, intentflow.TurnInput{
Principal: intentflow.Principal{
UserID: s.UserID, Environment: env, ProjectID: s.ProjectID, SessionID: s.ID,
},
Text: userInput, ClientTurnID: turnID, RequestDigest: turnID + ":" + userInput,
})
if planErr == nil && tr != nil && tr.Plan != nil {
persistedPlan = tr.Plan
recRes.IntentPlan = nil
}
}
if streamer != nil && recRes.TopIntent != nil {
streamer.EmitStatus(
string(recRes.TopIntent.Intent),
@@ -581,6 +605,7 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
AdvisorBrief: coordRes.AdvisorBrief,
InteractiveCard: coordRes.InteractiveCard,
PendingExpectation: s.UserCtx.PendingExpectation,
IntentPlan: persistedPlan,
}, nil
}
+8 -2
View File
@@ -510,9 +510,15 @@ func (s *Server) getOrCreateSession(
sess := chat.NewSession(sessionID, userCtx, s.rec, s.engine)
sess.ChatLog = s.chatLog
sess.CtxMgr = s.ctxMgr
env := "dev"
if s.cfg != nil && s.cfg.Env != "" {
env = s.cfg.Env
}
sess.Actions = &chat.ActionServices{
DocGov: s.gov,
TaskRuns: s.taskRuns,
DocGov: s.gov,
TaskRuns: s.taskRuns,
IntentPlans: s.intentPlans,
Environment: env,
}
// 若从持久层恢复了会话历史,回填消息列表、事实黑板与近期对话 (G3)。