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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 质量门禁未过时禁止任何正式受控投影。
|
||||
|
||||
@@ -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 运行指定成熟度阶段请求
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user