// Package conflictresolve adjudicates conflicts between dialogue facts and // higher-authority sources (blackboard / approved docs) with optional jev assist. package conflictresolve import ( "context" "fmt" "strings" "time" "chat-agent-system/internal/dialoguectx" "chat-agent-system/internal/jevdecide" ) const ( ResolutionAutoSupersede = "auto_supersede" ResolutionAutoRetain = "auto_retain_higher" ResolutionNeedsConfirm = "needs_confirm" ResolutionCardLimitAuto = "auto_adjudicated_card_limit" RouteTriggerUser = "trigger_user" RouteDocAuthor = "doc_author" AmbiguousWindow = 30 * time.Minute MaxCardsPerTask = 3 ) // Authority ranks (lower = stronger). const ( RankBlackboard = 0 RankApprovedDoc = 1 RankDialogue = 4 ) // FactClaim is a comparable claim from any authority tier. type FactClaim struct { Key string Value string AuthorityRank int SourceLabel string StatedAt time.Time ExplicitEvidence bool SpeakerID int64 SpeakerName string MessageID int64 DocID string // set when claim comes from / conflicts with a document DocAuthorID int64 DocAuthorClaimed bool } // Verdict is one conflict adjudication outcome. type Verdict struct { FactKey string Resolution string Winning FactClaim Losers []FactClaim Reason string NeedsHITL bool HITLRoute string // trigger_user | doc_author CardEligible bool } // DecideAssist is the narrow jev port (nil-safe). type DecideAssist interface { Decide(ctx context.Context, situation string, questions map[string]jevdecide.Question) (*jevdecide.Response, error) } // Engine runs deterministic rules then optional jev assist. type Engine struct { Assist DecideAssist Now func() time.Time } // Adjudicate compares dialogue facts against higher-authority claims. func (e *Engine) Adjudicate(ctx context.Context, dialogue []dialoguectx.DialogueFact, higher []FactClaim) []Verdict { now := time.Now() if e != nil && e.Now != nil { now = e.Now() } _ = now higherByKey := map[string][]FactClaim{} for _, h := range higher { higherByKey[h.Key] = append(higherByKey[h.Key], h) } // Also detect dialogue-internal conflicts (same key, different values). byKey := map[string][]dialoguectx.DialogueFact{} for _, f := range dialogue { byKey[f.Key] = append(byKey[f.Key], f) } var verdicts []Verdict for key, facts := range byKey { claims := make([]FactClaim, 0, len(facts)+len(higherByKey[key])) for _, f := range facts { claims = append(claims, FactClaim{ Key: f.Key, Value: f.Value, AuthorityRank: RankDialogue, SourceLabel: "dialogue", StatedAt: f.StatedAt, ExplicitEvidence: f.ExplicitEvidence, SpeakerID: f.SpeakerID, SpeakerName: f.SpeakerName, MessageID: f.SourceMessageID, }) } claims = append(claims, higherByKey[key]...) if len(claims) < 2 { continue } // Skip if all values agree. if allSameValue(claims) { continue } v := e.resolveGroup(ctx, key, claims) verdicts = append(verdicts, v) } return ApplyCardLimit(verdicts) } func allSameValue(claims []FactClaim) bool { if len(claims) == 0 { return true } base := strings.TrimSpace(claims[0].Value) for _, c := range claims[1:] { if strings.TrimSpace(c.Value) != base { return false } } return true } func (e *Engine) resolveGroup(ctx context.Context, key string, claims []FactClaim) Verdict { // Pick strongest authority; among same rank, newer + explicit evidence wins. best := claims[0] for _, c := range claims[1:] { if better(c, best) { best = c } } losers := make([]FactClaim, 0) for _, c := range claims { if c.Value == best.Value && c.AuthorityRank == best.AuthorityRank && c.MessageID == best.MessageID && c.SourceLabel == best.SourceLabel { continue } if strings.TrimSpace(c.Value) == strings.TrimSpace(best.Value) { continue } losers = append(losers, c) } v := Verdict{ FactKey: key, Winning: best, Losers: losers, Resolution: ResolutionAutoRetain, Reason: fmt.Sprintf("权威级 %d 胜出(来源 %s)", best.AuthorityRank, best.SourceLabel), } if best.AuthorityRank == RankDialogue { v.Resolution = ResolutionAutoSupersede v.Reason = "同级对话取更新证据" } // Ambiguous: same authority rank and close timestamps. ambiguous := false for _, l := range losers { if l.AuthorityRank == best.AuthorityRank { dt := best.StatedAt.Sub(l.StatedAt) if dt < 0 { dt = -dt } if dt <= AmbiguousWindow && !best.ExplicitEvidence && !l.ExplicitEvidence { ambiguous = true break } } } if !ambiguous { // Clear authority gap: dialogue loses to blackboard/doc → auto, no HITL. return v } // Ask jev assist; fail-open to needs_confirm. decision := ResolutionNeedsConfirm if e != nil && e.Assist != nil && jevdecide.CurrentMode() == jevdecide.ModeAssist { if res, err := e.Assist.Decide(ctx, buildSituation(key, best, losers), map[string]jevdecide.Question{ "action": { Type: "choice", Question: "How should this conflicting fact be resolved?", Options: []string{ResolutionAutoSupersede, ResolutionNeedsConfirm}, }, "is_ambiguous": {Type: "noul", Question: "Is this conflict ambiguous and time-proximate?"}, }); err == nil && res != nil { if ans, ok := res.Answers["action"]; ok && ans.Reliable(jevdecide.MinAssistConf, jevdecide.MinAssistMargin) { if s := strings.TrimSpace(ans.Choice); s == ResolutionAutoSupersede || s == ResolutionNeedsConfirm { decision = s } } } } if decision == ResolutionAutoSupersede { v.Resolution = ResolutionAutoSupersede v.Reason = "jev assist 高置信自动采信更新证据;" + v.Reason return v } v.Resolution = ResolutionNeedsConfirm v.NeedsHITL = true v.CardEligible = true v.Reason = "同级时间相近且无明确证据,需人工确认" v.HITLRoute = RouteTriggerUser // Document-level: any loser or winner tied to a doc → author route. for _, c := range append([]FactClaim{best}, losers...) { if c.DocID != "" || c.AuthorityRank == RankApprovedDoc { v.HITLRoute = RouteDocAuthor if c.DocAuthorID > 0 { v.Winning.DocAuthorID = c.DocAuthorID v.Winning.DocAuthorClaimed = c.DocAuthorClaimed v.Winning.DocID = c.DocID } break } } return v } func better(a, b FactClaim) bool { if a.AuthorityRank != b.AuthorityRank { return a.AuthorityRank < b.AuthorityRank } if a.ExplicitEvidence != b.ExplicitEvidence { return a.ExplicitEvidence } return a.StatedAt.After(b.StatedAt) } func buildSituation(key string, best FactClaim, losers []FactClaim) string { var sb strings.Builder sb.WriteString(fmt.Sprintf("Conflict on fact key %s. Candidate winner: %s=%s (rank %d, at %s). Losers: ", key, best.Key, best.Value, best.AuthorityRank, best.StatedAt.Format(time.RFC3339))) for i, l := range losers { if i > 0 { sb.WriteString("; ") } sb.WriteString(fmt.Sprintf("%s=%s rank=%d at=%s", l.Key, l.Value, l.AuthorityRank, l.StatedAt.Format(time.RFC3339))) } return sb.String() } // ApplyCardLimit keeps at most MaxCardsPerTask HITL cards; excess auto-adjudicated. func ApplyCardLimit(verdicts []Verdict) []Verdict { cards := 0 out := make([]Verdict, 0, len(verdicts)) for _, v := range verdicts { if v.NeedsHITL && v.CardEligible { cards++ if cards > MaxCardsPerTask { v.NeedsHITL = false v.CardEligible = false v.Resolution = ResolutionCardLimitAuto v.Reason = "超出单任务确认卡限额,自动采信规则矩阵胜者;" + v.Reason } } out = append(out, v) } return out } // RenderConfirmationsMD writes adjudication audit for context/dialogue/. func RenderConfirmationsMD(verdicts []Verdict, at time.Time) string { var sb strings.Builder sb.WriteString("# 上下文冲突裁决记录\n\n") sb.WriteString(fmt.Sprintf("- 生成时间: %s\n\n", at.Format(time.RFC3339))) if len(verdicts) == 0 { sb.WriteString("本轮未检出对话与高权威来源之间的冲突。\n") return sb.String() } for i, v := range verdicts { sb.WriteString(fmt.Sprintf("## %d. `%s`\n\n", i+1, v.FactKey)) sb.WriteString(fmt.Sprintf("- 决议: **%s**\n", v.Resolution)) sb.WriteString(fmt.Sprintf("- 理由: %s\n", v.Reason)) sb.WriteString(fmt.Sprintf("- 胜者: %s=%s(%s, rank=%d)\n", v.Winning.Key, v.Winning.Value, v.Winning.SourceLabel, v.Winning.AuthorityRank)) if v.NeedsHITL { sb.WriteString(fmt.Sprintf("- HITL: 是(路由 %s)\n", v.HITLRoute)) } for _, l := range v.Losers { sb.WriteString(fmt.Sprintf("- 并列矛盾: %s=%s(%s, rank=%d)\n", l.Key, l.Value, l.SourceLabel, l.AuthorityRank)) } sb.WriteString("\n") } return sb.String() } // BuildTriggerExpectation creates a PendingExpectation-compatible payload map. func BuildTriggerExpectation(v Verdict) (title, prompt, snippet string, payload map[string]string) { title = fmt.Sprintf("确认事实冲突:%s", v.FactKey) prompt = fmt.Sprintf("对话与现有事实对「%s」说法不一致。推荐采信:%s。请确认或纠正。", v.FactKey, v.Winning.Value) snippet = v.Reason payload = map[string]string{ "fact_key": v.FactKey, "winning_value": v.Winning.Value, "resolution": v.Resolution, "hitl_route": v.HITLRoute, } return title, prompt, snippet, payload }