Add dialoguectx packaging, conflictresolve adjudication with HITL routing, workspace context/dialogue/ injection (default on), and goose 00071.
306 lines
9.1 KiB
Go
306 lines
9.1 KiB
Go
// 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
|
||
}
|