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.
165 lines
5.6 KiB
Go
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
|
|
}
|