Files
weichen 76ddf4dd35 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.
2026-09-25 21:01:37 +00:00

165 lines
5.6 KiB
Go

package bizdev
import (
"context"
"fmt"
"log/slog"
"time"
"chat-agent-system/internal/conflictresolve"
"chat-agent-system/internal/dialoguectx"
"chat-agent-system/internal/intent"
"chat-agent-system/internal/notification"
"chat-agent-system/internal/squad"
)
// buildDialoguePackageForRun collects cleaned project dialogue, runs conflict
// adjudication, and returns a squad.DialoguePackage ready for workspace inject.
func (s *Service) buildDialoguePackageForRun(ctx context.Context, req TaskRunRequest, blackboard map[string]string) (*squad.DialoguePackage, []*intent.PendingExpectation, error) {
if s == nil || req.ProjectID <= 0 || !dialoguectx.InjectionEnabled() {
return nil, nil, nil
}
collector := &dialoguectx.Collector{Pool: s.pool, MemoryFallback: s.ctxMgr}
pkg, err := collector.CollectAndPackage(ctx, dialoguectx.CollectOptions{
ProjectID: req.ProjectID,
AllowedStageKey: req.StageKey,
})
if err != nil {
return nil, nil, err
}
if pkg == nil {
return nil, nil, nil
}
higher := make([]conflictresolve.FactClaim, 0, len(blackboard))
for k, v := range blackboard {
if v == "" {
continue
}
claim := conflictresolve.FactClaim{
Key: k, Value: v, AuthorityRank: conflictresolve.RankBlackboard,
SourceLabel: "blackboard", StatedAt: time.Now().Add(-time.Hour),
}
// When a controlled document is in play, conflicts involving these keys
// route to the document author (tiered HITL).
if req.DocID != "" && req.AuthorID > 0 {
claim.AuthorityRank = conflictresolve.RankApprovedDoc
claim.SourceLabel = "approved_document_slot"
claim.DocID = req.DocID
claim.DocAuthorID = req.AuthorID
claim.DocAuthorClaimed = true
}
higher = append(higher, claim)
}
engine := &conflictresolve.Engine{}
verdicts := engine.Adjudicate(ctx, pkg.Facts, higher)
confirmMD := conflictresolve.RenderConfirmationsMD(verdicts, time.Now())
store := &conflictresolve.Store{Pool: s.pool}
var expectations []*intent.PendingExpectation
for _, v := range verdicts {
_ = store.Persist(ctx, conflictresolve.AdjudicationRecord{
TaskRunID: req.RunID,
ProjectID: req.ProjectID,
FactKey: v.FactKey,
Resolution: v.Resolution,
HITLRoute: v.HITLRoute,
WinningValue: v.Winning.Value,
DecidedBy: "rule",
EvidenceDigest: conflictresolve.EvidenceDigest(req.RunID, v.FactKey, v.Winning.Value, v.Resolution),
})
if !v.NeedsHITL || !v.CardEligible {
continue
}
switch v.HITLRoute {
case conflictresolve.RouteTriggerUser:
title, prompt, snippet, payload := conflictresolve.BuildTriggerExpectation(v)
expectations = append(expectations, &intent.PendingExpectation{
ExpectationID: fmt.Sprintf("ctx_conflict_%s_%s", req.RunID, v.FactKey),
ExpectedIntent: intent.IntentConfirmAction,
TargetAction: "confirm_context_fact",
Prompt: prompt,
AffirmativeWord: "确认",
DeclineWord: "纠正",
ExpiresAt: time.Now().Add(24 * time.Hour),
Payload: payload,
ContextSnippet: snippet,
AllowAffirmative: true,
CreatedAt: time.Now(),
DefaultOption: &intent.ExpectationOption{
OptionID: "accept_winning",
Title: title,
Intent: intent.IntentConfirmAction,
Slots: map[string]string{"fact_key": v.FactKey, "value": v.Winning.Value},
},
})
case conflictresolve.RouteDocAuthor:
authorID := v.Winning.DocAuthorID
docID := v.Winning.DocID
if authorID == 0 {
authorID = req.AuthorID
}
if docID == "" {
docID = req.DocID
}
if s.notify != nil && authorID > 0 && docID != "" {
if err := s.notify.UpsertDocumentDigest(ctx, notification.UpsertDigestParams{
UserID: authorID,
ProjectID: req.ProjectID,
DigestKind: notification.DigestKindUnclaimedEdit,
DocID: docID,
DocTitle: req.Title,
Severity: notification.SeverityInfo,
ActorID: req.UserID,
Summary: fmt.Sprintf("对话与文档事实冲突待确认:%s(推荐 %s)", v.FactKey, v.Winning.Value),
}); err != nil {
slog.Warn("dialogue_ctx_author_notify_failed", "error", err.Error(), "fact", v.FactKey)
}
}
}
}
return &squad.DialoguePackage{
DigestMD: pkg.DigestMD,
TurnsMD: pkg.TurnsMD,
FactsJSON: dialoguectx.FactsJSON(pkg.Facts),
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
}