feat(ctx): surface dialogue conflict confirmation cards to triggering user

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.
This commit is contained in:
weichen
2026-09-25 21:01:37 +00:00
parent f6ad3344f8
commit 76ddf4dd35
15 changed files with 599 additions and 13 deletions
+1
View File
@@ -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 |
@@ -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 清理
## 四、结果登记
(完成后回填)
+35
View File
@@ -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
}
+60
View File
@@ -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")
}
}
+43 -4
View File
@@ -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 质量门禁未过时禁止任何正式受控投影。
+13
View File
@@ -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 运行指定成熟度阶段请求
+4 -9
View File
@@ -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 {
@@ -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
}
@@ -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")
}
}
+31
View File
@@ -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]
+74
View File
@@ -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()
}
@@ -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)
}
}
}
+5
View File
@@ -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
}
+5
View File
@@ -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)
}
}
}
+8
View File
@@ -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
}