Files
weichen dc29b84cdf feat(ctx): inject cleaned project dialogue into squad and assistant
Add dialoguectx packaging, conflictresolve adjudication with HITL routing,
workspace context/dialogue/ injection (default on), and goose 00071.
2026-09-24 17:04:04 +00:00

306 lines
9.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}