Files
weichen 45f21e84c7 feat(jevdecide): product shadow client with offline eval gates
Add typed jevdecide HTTP client (circuit, metrics) and coordinator
DecideAssist wiring behind JEV_DECIDE_MODE (default off). Offline eval
and synthetic soak evidence gate realtime/profile before assist rollout.
2026-09-23 17:40:40 +00:00

88 lines
2.8 KiB
Go

package coordinator
import (
"context"
"log/slog"
"time"
"chat-agent-system/internal/jevdecide"
)
// DecideAssist is the narrow port for typed jev decisions (nil-safe).
// Implemented by *jevdecide.Client; injected from the app composition root.
type DecideAssist interface {
DecideRealtime(ctx context.Context, userInput string) (yes bool, ans jevdecide.Answer, err error)
DecideProfileBucket(ctx context.Context, userInput string) (bucket string, ans jevdecide.Answer, err error)
}
// SetDecideAssist injects the optional jev decide client; nil disables product integration.
func (e *CoordinatorEngine) SetDecideAssist(d DecideAssist) {
if e != nil {
e.decideAssist = d
}
}
// resolveRealtimeQuery applies rule + optional jev shadow/assist.
// Rules always run. In assist mode, high-confidence jev "yes" can only ADD recall
// (rule || jevYes); it never turns a rule-true into false.
func (e *CoordinatorEngine) resolveRealtimeQuery(ctx context.Context, userInput string) bool {
rule := IsRealtimeInformationQuery(userInput)
mode := jevdecide.CurrentMode()
if mode == jevdecide.ModeOff || e == nil || e.decideAssist == nil {
return rule
}
yes, ans, err := e.decideAssist.DecideRealtime(ctx, userInput)
if err != nil {
jevdecide.Global.JevFail.Add(1)
slog.Info("jevdecide realtime fail-open", "err", err.Error(), "rule", rule)
return rule
}
agree := yes == rule
if agree {
jevdecide.Global.Agree.Add(1)
} else {
jevdecide.Global.Disagree.Add(1)
}
slog.Info("jevdecide realtime shadow",
"mode", string(mode), "rule", rule, "jev", yes,
"confidence", ans.Confidence, "margin", ans.Margin(), "agree", agree)
if mode != jevdecide.ModeAssist {
return rule
}
// assist: conservative synthesis — rules are the floor
if yes && ans.Reliable(jevdecide.MinAssistConf, jevdecide.MinAssistMargin) {
if !rule {
jevdecide.Global.AssistHit.Add(1)
}
return true
}
return rule
}
// shadowProfile observes jev complexity bucket without changing routing.
func (e *CoordinatorEngine) shadowProfile(ctx context.Context, userInput string, profile TaskProfile) {
mode := jevdecide.CurrentMode()
if mode == jevdecide.ModeOff || e == nil || e.decideAssist == nil {
return
}
start := time.Now()
bucket, ans, err := e.decideAssist.DecideProfileBucket(ctx, userInput)
_ = start
ruleBucket := jevdecide.ProfileBucket(profile.ComplexityScore)
if err != nil {
jevdecide.Global.JevFail.Add(1)
slog.Info("jevdecide profile fail-open", "err", err.Error(), "rule_bucket", ruleBucket)
return
}
agree := bucket == ruleBucket
if agree {
jevdecide.Global.Agree.Add(1)
} else {
jevdecide.Global.Disagree.Add(1)
}
slog.Info("jevdecide profile shadow",
"mode", string(mode), "rule_bucket", ruleBucket, "jev_bucket", bucket,
"score", profile.ComplexityScore, "confidence", ans.Confidence, "agree", agree)
}