From 76ddf4dd3579f86563ba4db47f9180e3ee8b2c19 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 25 Sep 2026 21:01:37 +0000 Subject: [PATCH] feat(ctx): surface dialogue conflict confirmation cards to triggering user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture trigger_user HITL expectations from dialogue adjudication (previously dropped at the bizdev call site), project them as chat confirmation cards via taskrun OnTerminal (exactly-once), arm the session PendingExpectation, and handle confirm_context_fact hits through the supplement-info blackboard path. Decline (纠正) guides the user to state the correct fact. --- .doc/ops/CURRENT.md | 1 + .../2026-09-26-dialogue-ctx-closeout.md | 68 ++++++++++ internal/bizdev/dialogue_inject.go | 35 +++++ internal/bizdev/dialogue_inject_test.go | 60 +++++++++ internal/bizdev/service_task_run.go | 47 ++++++- internal/bizdev/types.go | 13 ++ internal/coordinator/engine.go | 13 +- internal/coordinator/engine_ctx_conflict.go | 56 ++++++++ .../coordinator/engine_ctx_conflict_test.go | 125 ++++++++++++++++++ internal/coordinator/engine_stage.go | 31 +++++ internal/server/task_conflict_cards.go | 74 +++++++++++ internal/server/task_conflict_cards_test.go | 71 ++++++++++ internal/server/task_finalize.go | 5 + internal/server/task_run_handlers.go | 5 + internal/taskrun/finalize.go | 8 ++ 15 files changed, 599 insertions(+), 13 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-26-dialogue-ctx-closeout.md create mode 100644 internal/bizdev/dialogue_inject_test.go create mode 100644 internal/coordinator/engine_ctx_conflict.go create mode 100644 internal/coordinator/engine_ctx_conflict_test.go create mode 100644 internal/server/task_conflict_cards.go create mode 100644 internal/server/task_conflict_cards_test.go diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 5f174bdd..ae9a71dd 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| pi | dialogue-ctx-closeout-2026-09-26-r2 | feature/dialogue-ctx-closeout-2026-09-26 | .worktrees/dialogue-ctx-closeout-2026-09-26 | dialogue-ctx | in_progress | | pi | worktree-final-closeout-2026-09-26 | feature/worktree-final-closeout-2026-09-26 | (已清理) | git:dev+docs | done | | cursor-auto | cleanup-audit-r3-2026-09-25 | feature/cleanup-audit-p11-2026-09-25 | .worktrees/cleanup-audit-p11-2026-09-25 | docs-cleanup-audit | done | | Cursor | admin-settings-maint-b-2026-09-25 | feature/admin-env-settings-maintenance-2026-09-25 | .worktrees/admin-env-settings-maintenance-2026-09-25 | admin-settings-maintenance | done | diff --git a/.doc/ops/short-term/2026-09-26-dialogue-ctx-closeout.md b/.doc/ops/short-term/2026-09-26-dialogue-ctx-closeout.md new file mode 100644 index 00000000..726e0f55 --- /dev/null +++ b/.doc/ops/short-term/2026-09-26-dialogue-ctx-closeout.md @@ -0,0 +1,68 @@ +# dialogue-ctx-injection 收口(short-term) + +- **task_id**: dialogue-ctx-closeout-2026-09-26 +- **owner**: pi +- **status**: in_progress +- **授权来源**: 用户会话指令「把 FEATURE-MATRIX 中的 dialogue-ctx-injection、jevdecide-shadow、chat-semantic-replace 完成;一个 feature 一个 feature 完成;严格按规范」 +- **write_scope**: internal/bizdev, internal/server, internal/coordinator, .doc +- **mutex**: dialogue-ctx +- **base_dev_sha**: f6ad3344f81ce4fadba660b5abdde5190c059bdb +- **capabilities**: docs:publish, docs:approve, env:dev +- **policy_revision**: AGENTS 2026-09 / agent-worktree-workflow r1 / validation-contract 2026-09-21 +- **review_state**: pending +- **integration_state**: pending +- **deployment**: pending(dev rebuild + 迁移核验) +- **cleanup**: pending +- **remote_sync**: not_requested + +## 一、当前进度分析 + +已完成(dev 已合入,dc29b84c + 1da17a28): + +- `internal/dialoguectx` 清洗/分层/打包;`internal/conflictresolve` 权威裁决 + 卡片限额;squad 工作区 `context/dialogue/` 注入;bizdev 全任务默认注入(`DIALOGUE_CTX_INJECTION` 紧急关闭);MemoryComposer 近况段;goose `00072_2026-09-25-context-conflict-adjudications.sql`(原设计 00071,因 maintenance-windows 占号顺延为 00072)。 +- 单测全绿(dialoguectx / conflictresolve / squad 落盘 / 关联包)。 + +未完成(FEATURE-MATRIX 关闭条件): + +1. **dev 迁移未应用**:dev 环境当前停机,`context_conflict_adjudications` 表未在 dev DB 落地。 +2. **真实 PI 落盘核验缺失**:无真实项目任务跑过 `context/dialogue/` 落盘链路。 +3. **冲突确认卡链路断裂(本轮发现的缺口)**:`internal/bizdev/service_task_run.go:646` 对 `buildDialoguePackageForRun` 返回的 expectations 直接丢弃(`_`),trigger_user 路由的 PendingExpectation 卡片从未到达用户;`confirm_context_fact` 也无 coordinator 处理。HITL 分级路由中仅 doc_author(digest 通知)已接线。 + +## 二、方案 + +### 2.1 代码补齐(使「冲突确认卡」真实可达) + +1. `internal/bizdev/types.go`:`TaskRunResult` 增加 `PendingContextConflicts []ContextConflictCard`(fact_key/winning_value/prompt/snippet/expectation_id)。 +2. `internal/bizdev/service_task_run.go`:捕获 expectations → 转卡片(≤3,与卡片限额一致)挂到结果(成功与失败路径均挂,裁决与任务成败无关)。 +3. `internal/server`(任务处理器,持有 sessions):run 完成后若有 PendingContextConflicts 且 req.SessionID 对应活跃会话 → 向该会话追加 assistant 消息(确认卡文案,参照 confirmGate 文案形态)+ 通过 `coordinator.ApplyContextUpdate` 设置 `PendingExpectation`(首个 expectation)+ 经 ctxmgr 落消息记录。 +4. `internal/coordinator`: + - 期望命中 `confirm_context_fact`(Intent=CONFIRM_ACTION + pending.TargetAction):复用 `handleSupplementInfo` 语义——按 payload slots 写黑板(内存 + blackboard.json + OnSupplement 持久化钩子),回复「已采信…」。 + - `handleDeclineExpectation` 对 `confirm_context_fact`:回复引导用户直接给出正确事实(下一条 SUPPLEMENT_INFO 自然吸收),不再用阶段推进话术。 + +不改 web(卡片以 chat 消息文案呈现,确认走既有期望命中通道);不新增迁移;不动 dialoguectx/conflictresolve 已验收逻辑。 + +### 2.2 dev 运行时验证(关闭条件) + +1. `env:dev` 租约下从主目录 rebuild dev(部署当前 dev HEAD)→ 核验 `/health`、`/ready`、`goose_db_version` ≥ 72 且 `context_conflict_adjudications` 存在。 +2. **真实 PI 落盘核验**:真实项目成员对话(含事实陈述)→ 触发任务(真实 PI Runner)→ 核验工作区 `context/dialogue/`(digest.md/turns.md/facts.json/CONFIRMATIONS.md/README)+ TASK.md 导航 + DB 裁决记录。 +3. **冲突确认卡冒烟**:同 key 不同值、30 分钟内、无明确证据词的两条用户对话 → 跑任务 → 会话出现确认卡消息 → 回复「确认」→ 期望命中、黑板更新、回复确认采信。 + +### 2.3 验证契约对照 + +- Go/接口变更:单测 + `arch-check --mode block` + `go vet ./...` + `go test -short ./...`。 +- 产品 Agent/工作流:受影响真实 PI 链路(任务注入 + 冲突卡冒烟)。 +- 迁移:dev 实际升级 + schema 版本证据。 + +## 三、计划 + +1. [x] 分析进度(本文件§一) +2. [ ] 代码补齐 §2.1 + 单测 +3. [ ] 工程验证(vet/short/arch-check) +4. [ ] 候选集成 dev +5. [ ] dev rebuild + 迁移核验 + 真实 PI 落盘 + 冲突卡冒烟 +6. [ ] 测试报告 + 矩阵行更新(关闭条件满足则升 done,保留 before/after) +7. [ ] 任务收口 + worktree 清理 + +## 四、结果登记 + +(完成后回填) diff --git a/internal/bizdev/dialogue_inject.go b/internal/bizdev/dialogue_inject.go index 657822ba..3efeafef 100644 --- a/internal/bizdev/dialogue_inject.go +++ b/internal/bizdev/dialogue_inject.go @@ -127,3 +127,38 @@ func (s *Service) buildDialoguePackageForRun(ctx context.Context, req TaskRunReq ConfirmationsMD: confirmMD, }, expectations, nil } + +// ContextConflictExpectation rebuilds the chat-side pending expectation for a +// conflict card projected onto a run result. It mirrors the expectation built +// during adjudication so the server transport can arm the session without +// re-running the engine. +func ContextConflictExpectation(card ContextConflictCard) *intent.PendingExpectation { + if card.ExpectationID == "" || card.FactKey == "" { + return nil + } + exp := &intent.PendingExpectation{ + ExpectationID: card.ExpectationID, + ExpectedIntent: intent.IntentConfirmAction, + TargetAction: "confirm_context_fact", + Prompt: card.Prompt, + AffirmativeWord: "确认", + DeclineWord: "纠正", + ExpiresAt: time.Now().Add(24 * time.Hour), + Payload: map[string]string{ + "fact_key": card.FactKey, + "winning_value": card.WinningValue, + "resolution": "needs_confirm", + "hitl_route": conflictresolve.RouteTriggerUser, + }, + ContextSnippet: card.Snippet, + AllowAffirmative: true, + CreatedAt: time.Now(), + } + exp.DefaultOption = &intent.ExpectationOption{ + OptionID: "accept_winning", + Title: fmt.Sprintf("采信 %s=%s", card.FactKey, card.WinningValue), + Intent: intent.IntentConfirmAction, + Slots: map[string]string{"fact_key": card.FactKey, "value": card.WinningValue}, + } + return exp +} diff --git a/internal/bizdev/dialogue_inject_test.go b/internal/bizdev/dialogue_inject_test.go new file mode 100644 index 00000000..4c520872 --- /dev/null +++ b/internal/bizdev/dialogue_inject_test.go @@ -0,0 +1,60 @@ +package bizdev + +import ( + "testing" + "time" + + "chat-agent-system/internal/intent" +) + +func TestAppendContextConflictCards_CapsAndDedupes(t *testing.T) { + mk := func(id, key string) *intent.PendingExpectation { + return &intent.PendingExpectation{ + ExpectationID: id, + TargetAction: "confirm_context_fact", + Payload: map[string]string{"fact_key": key, "winning_value": "v-" + key}, + Prompt: "p-" + key, + ContextSnippet: "s-" + key, + } + } + out := &TaskRunResult{} + appendContextConflictCards(out, TaskRunRequest{}, []*intent.PendingExpectation{ + mk("e1", "device_type"), + mk("e2", "device_type"), // duplicate fact key + mk("e3", "budget"), + nil, + {ExpectationID: "other", TargetAction: "other_action"}, // non-conflict expectation + }) + if len(out.PendingContextConflicts) != 2 { + t.Fatalf("expected 2 cards after dedupe, got %d", len(out.PendingContextConflicts)) + } + if out.PendingContextConflicts[0].FactKey != "device_type" || out.PendingContextConflicts[1].FactKey != "budget" { + t.Fatalf("unexpected cards: %+v", out.PendingContextConflicts) + } +} + +func TestContextConflictExpectation_Roundtrip(t *testing.T) { + card := ContextConflictCard{ + ExpectationID: "ctx_conflict_run1_device_type", + FactKey: "device_type", + WinningValue: "一次性使用采血管", + Prompt: "对话与现有事实对「device_type」说法不一致。", + Snippet: "同级时间相近且无明确证据,需人工确认", + } + exp := ContextConflictExpectation(card) + if exp == nil { + t.Fatal("expected expectation") + } + if exp.TargetAction != "confirm_context_fact" || exp.AffirmativeWord != "确认" || exp.DeclineWord != "纠正" { + t.Fatalf("unexpected expectation shape: %+v", exp) + } + if exp.Payload["fact_key"] != "device_type" || exp.Payload["winning_value"] != "一次性使用采血管" { + t.Fatalf("payload mismatch: %+v", exp.Payload) + } + if exp.ExpiresAt.Before(time.Now()) { + t.Fatal("expectation must not be expired at creation") + } + if ContextConflictExpectation(ContextConflictCard{}) != nil { + t.Fatal("empty card must return nil") + } +} diff --git a/internal/bizdev/service_task_run.go b/internal/bizdev/service_task_run.go index 9170f637..0a19f0a6 100644 --- a/internal/bizdev/service_task_run.go +++ b/internal/bizdev/service_task_run.go @@ -10,9 +10,11 @@ import ( "strings" "time" + "chat-agent-system/internal/conflictresolve" "chat-agent-system/internal/contracts/vocabulary" "chat-agent-system/internal/docduty" "chat-agent-system/internal/docgov" + "chat-agent-system/internal/intent" "chat-agent-system/internal/notification" "chat-agent-system/internal/docrevision" "chat-agent-system/internal/kb" @@ -643,7 +645,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun } } - dialoguePkg, _, dlgErr := s.buildDialoguePackageForRun(ctx, req, blackboardFacts) + dialoguePkg, conflictExps, dlgErr := s.buildDialoguePackageForRun(ctx, req, blackboardFacts) if dlgErr != nil { log.Printf("dialogue_ctx_package_failed project_id=%d err=%v", req.ProjectID, dlgErr) } @@ -682,7 +684,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun res, err := s.squad.ExecuteTask(ctx, squadReq) if err != nil { if res != nil { - return &TaskRunResult{ + out := &TaskRunResult{ TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, WorkDir: res.WorkDir, @@ -691,12 +693,49 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun Success: false, ErrorReason: res.ErrorReason, AdvisorBrief: res.AdvisorBrief, - }, err + } + appendContextConflictCards(out, req, conflictExps) + return out, err } return nil, err } - return s.finalizeSquadDeliverable(ctx, req, res, docID, docRec, appliedProposalID, upstreamSRDoc) + outRes, finErr := s.finalizeSquadDeliverable(ctx, req, res, docID, docRec, appliedProposalID, upstreamSRDoc) + if outRes != nil { + appendContextConflictCards(outRes, req, conflictExps) + } + return outRes, finErr +} + +// appendContextConflictCards projects trigger_user HITL verdicts onto the run +// result so the transport layer can surface confirmation cards to the user. +// Verdicts exist regardless of task success; cards are capped by the engine's +// MaxCardsPerTask and deduped per (run, fact key). +func appendContextConflictCards(out *TaskRunResult, req TaskRunRequest, exps []*intent.PendingExpectation) { + if out == nil || len(exps) == 0 { + return + } + seen := map[string]struct{}{} + for _, exp := range exps { + if exp == nil || exp.TargetAction != "confirm_context_fact" { + continue + } + factKey := exp.Payload["fact_key"] + if _, dup := seen[factKey]; dup { + continue + } + seen[factKey] = struct{}{} + out.PendingContextConflicts = append(out.PendingContextConflicts, ContextConflictCard{ + ExpectationID: exp.ExpectationID, + FactKey: factKey, + WinningValue: exp.Payload["winning_value"], + Prompt: exp.Prompt, + Snippet: exp.ContextSnippet, + }) + if len(out.PendingContextConflicts) >= conflictresolve.MaxCardsPerTask { + break + } + } } // qualityBlocksControlledProjection 质量门禁未过时禁止任何正式受控投影。 diff --git a/internal/bizdev/types.go b/internal/bizdev/types.go index bb2ea531..df467e1c 100644 --- a/internal/bizdev/types.go +++ b/internal/bizdev/types.go @@ -98,6 +98,19 @@ type TaskRunResult struct { PendingChangeReportID int64 `json:"pending_change_report_id,omitempty"` PendingRiskLevel string `json:"pending_risk_level,omitempty"` PendingConfirmationNote string `json:"pending_confirmation_note,omitempty"` + // PendingContextConflicts: dialogue-fact conflicts routed to the triggering + // user (HITL trigger_user). Server projects them as chat confirmation cards. + PendingContextConflicts []ContextConflictCard `json:"pending_context_conflicts,omitempty"` +} + +// ContextConflictCard is the user-facing projection of one needs_confirm +// adjudication verdict (conflictresolve route trigger_user, card-eligible). +type ContextConflictCard struct { + ExpectationID string `json:"expectation_id"` + FactKey string `json:"fact_key"` + WinningValue string `json:"winning_value"` + Prompt string `json:"prompt"` + Snippet string `json:"snippet"` } // StageRunRequest 运行指定成熟度阶段请求 diff --git a/internal/coordinator/engine.go b/internal/coordinator/engine.go index 8930fece..fa18589a 100644 --- a/internal/coordinator/engine.go +++ b/internal/coordinator/engine.go @@ -393,6 +393,9 @@ func (e *CoordinatorEngine) ProcessIntent( case intent.IntentRunRestart: coordRes, coordErr = e.handleRunRestart(ctx, top, userCtx) + case intent.IntentConfirmAction: + coordRes, coordErr = e.handleConfirmActionHit(top, userCtx, recRes.Branch) + case intent.IntentImpactAnalyze: coordRes, coordErr = e.handleImpactAnalyze(ctx, top, userCtx, recRes.RawInput) @@ -400,15 +403,7 @@ func (e *CoordinatorEngine) ProcessIntent( coordRes, coordErr = e.handleRevisePropose(ctx, top, userCtx, recRes.RawInput) default: - nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) - coordRes = &CoordinationResult{ - Status: StatusAccepted, - Intent: top.Intent, - Branch: recRes.Branch, - NaturalReply: "我已收到您的指示。针对当前项目,请问需要我为您查询进展、补充技术事实,还是推进方案论证?随时吩咐我。", - UpdatedContext: userCtx, - NextExpectation: nextExp, - } + coordRes = e.genericAcknowledgement(top, userCtx, recRes.Branch) } if coordErr == nil && coordRes != nil { diff --git a/internal/coordinator/engine_ctx_conflict.go b/internal/coordinator/engine_ctx_conflict.go new file mode 100644 index 00000000..b679615b --- /dev/null +++ b/internal/coordinator/engine_ctx_conflict.go @@ -0,0 +1,56 @@ +package coordinator + +import ( + "fmt" + "strings" + + "chat-agent-system/internal/intent" +) + +// TargetActionContextFact is the pending-expectation action produced by the +// dialogue-context conflict adjudication (bizdev buildDialoguePackageForRun). +const TargetActionContextFact = "confirm_context_fact" + +// handleConfirmActionHit serves the CONFIRM_ACTION expectation hit. Today the +// only async-armed expectation of this shape is the dialogue-context conflict +// card: confirming absorbs the winning fact into the project blackboard via +// the supplement-info path (memory + blackboard.json + OnSupplement hook). +// Unknown or un-armed confirmations fall back to the generic acknowledgement. +func (e *CoordinatorEngine) handleConfirmActionHit(cand *intent.CandidateIntent, userCtx *UserProjectContext, branch intent.ResolutionBranch) (*CoordinationResult, error) { + pending := userCtx.PendingExpectation + if pending == nil || pending.TargetAction != TargetActionContextFact { + return e.genericAcknowledgement(cand, userCtx, branch), nil + } + + factKey := strings.TrimSpace(pending.Payload["fact_key"]) + factVal := strings.TrimSpace(pending.Payload["winning_value"]) + if factKey == "" || factVal == "" { + userCtx.applyPending(nil) + return &CoordinationResult{ + Status: StatusAccepted, + Intent: intent.IntentConfirmAction, + Branch: branch, + NaturalReply: "确认卡信息已失效(缺少事实键或推荐值),本次不做黑板变更。如仍有冲突请重新触发任务。", + UpdatedContext: userCtx, + ClearExpectation: true, + }, nil + } + + patched := &intent.CandidateIntent{ + Intent: intent.IntentSupplementInfo, + Confidence: cand.Confidence, + Branch: branch, + FilledSlots: map[string]string{ + intent.SlotFactKey: factKey, + intent.SlotFactValue: factVal, + }, + Reasoning: fmt.Sprintf("用户确认对话冲突采信 [%s=%s]", factKey, factVal), + } + res, err := e.handleSupplementInfo(patched, userCtx) + if err != nil || res == nil { + return res, err + } + res.Intent = intent.IntentConfirmAction + res.NaturalReply = fmt.Sprintf("✅ 冲突确认完成:已按您的确认采信「%s」=「%s」,并同步项目黑板。\n\n%s", factKey, factVal, res.NaturalReply) + return res, nil +} diff --git a/internal/coordinator/engine_ctx_conflict_test.go b/internal/coordinator/engine_ctx_conflict_test.go new file mode 100644 index 00000000..ce63b224 --- /dev/null +++ b/internal/coordinator/engine_ctx_conflict_test.go @@ -0,0 +1,125 @@ +package coordinator + +import ( + "strings" + "testing" + + "chat-agent-system/internal/intent" +) + +func conflictTestContext() *UserProjectContext { + return &UserProjectContext{ + Project: ProjectInfo{Name: "便携式掌上超声项目"}, + BlackboardFacts: map[string]string{}, + PendingExpectation: &intent.PendingExpectation{ + ExpectationID: "ctx_conflict_run1_device_type", + ExpectedIntent: intent.IntentConfirmAction, + TargetAction: TargetActionContextFact, + Payload: map[string]string{ + "fact_key": "device_type", + "winning_value": "一次性使用采血管", + }, + AffirmativeWord: "确认", + DeclineWord: "纠正", + }, + } +} + +func TestConfirmContextFactHit_AppliesWinningFact(t *testing.T) { + engine := NewCoordinatorEngine(nil) + userCtx := conflictTestContext() + + recRes := &intent.IntentRecognitionResult{ + Branch: intent.BranchExpectationHit, + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentConfirmAction, + Branch: intent.BranchExpectationHit, + Confidence: 1.0, + FilledSlots: map[string]string{"target_action": TargetActionContextFact}, + }, + } + + res, err := engine.ProcessIntent(t.Context(), recRes, userCtx) + if err != nil { + t.Fatalf("process intent failed: %v", err) + } + if res.Status != StatusAccepted { + t.Fatalf("expected ACCEPTED, got %s", res.Status) + } + if got := userCtx.BlackboardFacts["device_type"]; got != "一次性使用采血管" { + t.Fatalf("blackboard device_type = %q, want 一次性使用采血管", got) + } + if !strings.Contains(res.NaturalReply, "冲突确认完成") { + t.Fatalf("reply should confirm conflict resolution, got: %s", res.NaturalReply) + } + // The conflict expectation must be replaced by the next-stage expectation + // (handleSupplementInfo semantics), never left armed. + if userCtx.PendingExpectation != nil && userCtx.PendingExpectation.TargetAction == TargetActionContextFact { + t.Fatal("conflict expectation must not stay armed after confirm") + } +} + +func TestDeclineContextFact_GuidesCorrection(t *testing.T) { + engine := NewCoordinatorEngine(nil) + userCtx := conflictTestContext() + + recRes := &intent.IntentRecognitionResult{ + Branch: intent.BranchExpectationHit, + TopIntent: &intent.CandidateIntent{ + Intent: intent.IntentDeclineExpectation, + Branch: intent.BranchExpectationHit, + IsDecline: true, + }, + } + + res, err := engine.ProcessIntent(t.Context(), recRes, userCtx) + if err != nil { + t.Fatalf("process intent failed: %v", err) + } + if !res.ClearExpectation { + t.Fatal("expected ClearExpectation") + } + if !strings.Contains(res.NaturalReply, "正确的事实") { + t.Fatalf("decline reply should guide correction, got: %s", res.NaturalReply) + } + if userCtx.PendingExpectation != nil { + t.Fatal("expected PendingExpectation cleared") + } +} + +func TestConfirmActionWithoutArmedConflict_FallsBackGeneric(t *testing.T) { + engine := NewCoordinatorEngine(nil) + userCtx := &UserProjectContext{Project: ProjectInfo{Name: "测试项目"}} + + recRes := &intent.IntentRecognitionResult{ + TopIntent: &intent.CandidateIntent{Intent: intent.IntentConfirmAction}, + } + res, err := engine.ProcessIntent(t.Context(), recRes, userCtx) + if err != nil { + t.Fatalf("process intent failed: %v", err) + } + if res.Status != StatusAccepted || !strings.Contains(res.NaturalReply, "我已收到您的指示") { + t.Fatalf("expected generic acknowledgement, got status=%s reply=%s", res.Status, res.NaturalReply) + } +} + +func TestConfirmContextFactHit_MissingPayload_Clears(t *testing.T) { + engine := NewCoordinatorEngine(nil) + userCtx := conflictTestContext() + userCtx.PendingExpectation.Payload = map[string]string{} + + recRes := &intent.IntentRecognitionResult{ + Branch: intent.BranchExpectationHit, + TopIntent: &intent.CandidateIntent{Intent: intent.IntentConfirmAction, Branch: intent.BranchExpectationHit}, + } + res, err := engine.ProcessIntent(t.Context(), recRes, userCtx) + if err != nil { + t.Fatalf("process intent failed: %v", err) + } + if !res.ClearExpectation || userCtx.PendingExpectation != nil { + t.Fatal("stale card must clear expectation without blackboard change") + } + if len(userCtx.BlackboardFacts) != 0 { + t.Fatal("blackboard must stay unchanged on stale card") + } +} diff --git a/internal/coordinator/engine_stage.go b/internal/coordinator/engine_stage.go index 15f28cb8..e54beaa6 100644 --- a/internal/coordinator/engine_stage.go +++ b/internal/coordinator/engine_stage.go @@ -25,6 +25,23 @@ func (e *CoordinatorEngine) handleDeclineExpectation(userCtx *UserProjectContext if projName == "" { projName = "当前项目" } + // 对话冲突确认卡的「纠正」:不采信推荐值,引导用户直接给出正确事实; + // 下一条 SUPPLEMENT_INFO 会自然吸收并写入项目黑板。 + if p := userCtx.PendingExpectation; p != nil && p.TargetAction == TargetActionContextFact { + factKey := p.Payload["fact_key"] + userCtx.applyPending(nil) + if userCtx.InteractiveCard != nil { + userCtx.dismissCard("cancelled") + } + return &CoordinationResult{ + Status: StatusAccepted, + Intent: intent.IntentDeclineExpectation, + Branch: intent.BranchExpectationHit, + NaturalReply: fmt.Sprintf("好的,暂不采信推荐值。请直接回复正确的事实(例如「%s:一次性使用采血管」),我会立即更新项目黑板。", factKey), + UpdatedContext: userCtx, + ClearExpectation: true, + }, nil + } userCtx.applyPending(nil) if userCtx.InteractiveCard != nil { userCtx.dismissCard("cancelled") @@ -42,6 +59,20 @@ func (e *CoordinatorEngine) handleDeclineExpectation(userCtx *UserProjectContext }, nil } +// genericAcknowledgement is the default branch reply when no specific handler +// matches the recognized intent. +func (e *CoordinatorEngine) genericAcknowledgement(top *intent.CandidateIntent, userCtx *UserProjectContext, branch intent.ResolutionBranch) *CoordinationResult { + nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name) + return &CoordinationResult{ + Status: StatusAccepted, + Intent: top.Intent, + Branch: branch, + NaturalReply: "我已收到您的指示。针对当前项目,请问需要我为您查询进展、补充技术事实,还是推进方案论证?随时吩咐我。", + UpdatedContext: userCtx, + NextExpectation: nextExp, + } +} + func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) { factKey := cand.FilledSlots[intent.SlotFactKey] factVal := cand.FilledSlots[intent.SlotFactValue] diff --git a/internal/server/task_conflict_cards.go b/internal/server/task_conflict_cards.go new file mode 100644 index 00000000..18ef1944 --- /dev/null +++ b/internal/server/task_conflict_cards.go @@ -0,0 +1,74 @@ +package server + +import ( + "context" + "fmt" + "strings" + "time" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/chat" + "chat-agent-system/internal/coordinator" + "chat-agent-system/internal/taskrun" +) + +// projectContextConflictCards surfaces dialogue-context conflict confirmation +// cards (HITL trigger_user route) to the user who triggered the run: +// 1. a durable assistant message in the run's chat session (history APIs); +// 2. the first card armed as PendingExpectation on the live session so a +// follow-up「确认/纠正」hits the expectation branch. +// +// The adjudication ledger (context_conflict_adjudications) and the workspace +// CONFIRMATIONS.md remain the authoritative record; this projection is UX. +func (s *Server) projectContextConflictCards(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) { + if s == nil || record == nil || result == nil || len(result.PendingContextConflicts) == 0 { + return + } + if strings.TrimSpace(record.SessionID) == "" { + return + } + content := renderContextConflictCardMessage(result.PendingContextConflicts) + + if cm := s.ctxMgr(); cm != nil { + meta := map[string]any{ + "run_id": record.RunID, + "project_id": record.ProjectID, + "client_turn_id": record.ClientTurnID, + "ctx_conflict_card": true, + } + _, _ = cm.RecordUserMessageWithID(ctx, record.SessionID, 0, "assistant", content, "CTX_CONFLICT_CARD", "EXPECTATION_HIT", meta) + } + + s.sessionsMu.RLock() + sess := s.sessions[record.SessionID] + s.sessionsMu.RUnlock() + if sess == nil { + return + } + if exp := bizdev.ContextConflictExpectation(result.PendingContextConflicts[0]); exp != nil && sess.UserCtx != nil { + _ = coordinator.ApplyContextUpdate(sess.UserCtx, coordinator.ContextUpdate{ + ExpectedVersion: sess.UserCtx.Version, + SetPendingExpectation: true, + PendingExpectation: exp, + }) + } + sess.AppendMessage(chat.Message{ + ID: fmt.Sprintf("msg_%d_cc", time.Now().UnixNano()), + Role: "assistant", + Content: content, + Timestamp: time.Now(), + }) +} + +// renderContextConflictCardMessage builds the chat-visible confirmation card +// text. Reply「确认」accepts the recommended winning value;「纠正」clears the +// card and the user states the correct fact, which SUPPLEMENT_INFO absorbs. +func renderContextConflictCardMessage(cards []bizdev.ContextConflictCard) string { + var sb strings.Builder + sb.WriteString("⚠️ 检测到项目对话与现有事实存在说法冲突,需要您确认:\n\n") + for i, c := range cards { + sb.WriteString(fmt.Sprintf("%d. %s\n 推荐采信:%s = %s\n 依据:%s\n", i+1, c.Prompt, c.FactKey, c.WinningValue, c.Snippet)) + } + sb.WriteString("\n回复「确认」采信推荐值(将同步项目黑板);回复「纠正」后直接告诉我正确的事实。") + return sb.String() +} diff --git a/internal/server/task_conflict_cards_test.go b/internal/server/task_conflict_cards_test.go new file mode 100644 index 00000000..ac4f4c7a --- /dev/null +++ b/internal/server/task_conflict_cards_test.go @@ -0,0 +1,71 @@ +package server + +import ( + "strings" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/chat" + "chat-agent-system/internal/coordinator" + "chat-agent-system/internal/taskrun" +) + +func TestProjectContextConflictCards_ArmsSessionExpectation(t *testing.T) { + s := &Server{sessions: map[string]*chat.Session{}} + sess := chat.NewSession("sess-cc", &coordinator.UserProjectContext{ + Project: coordinator.ProjectInfo{Name: "便携式掌上超声项目"}, + BlackboardFacts: map[string]string{}, + }, nil, nil) + s.sessions[sess.ID] = sess + + record := &taskrun.RunRecord{RunID: "run_1", SessionID: sess.ID, ProjectID: 42} + result := &bizdev.TaskRunResult{ + Success: true, + PendingContextConflicts: []bizdev.ContextConflictCard{{ + ExpectationID: "ctx_conflict_run_1_device_type", + FactKey: "device_type", + WinningValue: "一次性使用采血管", + Prompt: "对话与现有事实对「device_type」说法不一致。", + Snippet: "同级时间相近且无明确证据", + }}, + } + + s.projectContextConflictCards(t.Context(), record, result) + + if sess.UserCtx.PendingExpectation == nil { + t.Fatal("expected session PendingExpectation armed") + } + exp := sess.UserCtx.PendingExpectation + if exp.TargetAction != "confirm_context_fact" || exp.Payload["fact_key"] != "device_type" { + t.Fatalf("unexpected armed expectation: %+v", exp) + } + msgs := sess.SnapshotMessages() + if len(msgs) == 0 || !strings.Contains(msgs[len(msgs)-1].Content, "确认") { + t.Fatalf("expected card message appended, got %+v", msgs) + } +} + +func TestProjectContextConflictCards_NoSession_SkipsSilently(t *testing.T) { + s := &Server{sessions: map[string]*chat.Session{}} + record := &taskrun.RunRecord{RunID: "run_2", SessionID: "sess-missing", ProjectID: 7} + result := &bizdev.TaskRunResult{PendingContextConflicts: []bizdev.ContextConflictCard{{ + ExpectationID: "e", FactKey: "budget", WinningValue: "15万", Prompt: "p", Snippet: "s", + }}} + s.projectContextConflictCards(t.Context(), record, result) // must not panic +} + +func TestProjectContextConflictCards_EmptySkips(t *testing.T) { + s := &Server{sessions: map[string]*chat.Session{}} + s.projectContextConflictCards(t.Context(), &taskrun.RunRecord{SessionID: "x"}, &bizdev.TaskRunResult{}) +} + +func TestRenderContextConflictCardMessage(t *testing.T) { + msg := renderContextConflictCardMessage([]bizdev.ContextConflictCard{{ + FactKey: "device_type", WinningValue: "一次性使用采血管", Prompt: "冲突提示", Snippet: "同级相近", + }}) + for _, want := range []string{"冲突", "device_type", "一次性使用采血管", "确认", "纠正"} { + if !strings.Contains(msg, want) { + t.Fatalf("card message missing %q: %s", want, msg) + } + } +} diff --git a/internal/server/task_finalize.go b/internal/server/task_finalize.go index e5a11f18..130cc2f3 100644 --- a/internal/server/task_finalize.go +++ b/internal/server/task_finalize.go @@ -40,6 +40,11 @@ func (s *Server) finalizeTaskRun(ctx context.Context, record *taskrun.RunRecord, summaryContent := result.CompletionSummary() s.ingestAssistantReply(ctx, record, summaryContent, status) } + // Dialogue-context conflict cards are independent of task outcome; the + // OnTerminal hook covers the cmds path, this covers the finalizer path. + if s.cmds() == nil { + s.projectContextConflictCards(ctx, record, result) + } return status, nil } diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index f59a2756..601248f1 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -850,4 +850,9 @@ func (s *Server) initTaskRunExecutors() { s.taskRuns().SetFinalizer(func(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult) (string, error) { return s.finalizeTaskRun(ctx, record, result) }) + if c := s.cmds(); c != nil { + c.OnTerminal = func(ctx context.Context, record *taskrun.RunRecord, result *bizdev.TaskRunResult, _ string) { + s.projectContextConflictCards(ctx, record, result) + } + } } diff --git a/internal/taskrun/finalize.go b/internal/taskrun/finalize.go index b1373353..65ad3b23 100644 --- a/internal/taskrun/finalize.go +++ b/internal/taskrun/finalize.go @@ -69,6 +69,11 @@ type Commands struct { Fav *artifactvault.Ingest Env string StorageIdentity string + // OnTerminal runs after the terminal projection has been applied exactly + // once (CAS-guarded). Optional; used by the server composition to project + // extra chat artifacts such as dialogue-context conflict confirmation + // cards. Hook errors are best-effort and must not fail finalize. + OnTerminal func(ctx context.Context, record *RunRecord, result *bizdev.TaskRunResult, status string) mu sync.Mutex mem map[string]*FinalizeResult @@ -180,6 +185,9 @@ func (c *Commands) applyLocked(ctx context.Context, record *RunRecord, result *b out := &FinalizeResult{Status: want, Manifest: manifest, Applied: true} c.remember(record.RunID, out) + if c.OnTerminal != nil { + c.OnTerminal(ctx, record, result, want) + } return out, nil }