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

111 lines
3.9 KiB
Go

package coordinator
import (
"context"
"errors"
"testing"
"chat-agent-system/internal/jevdecide"
)
type stubDecide struct {
yes bool
bucket string
ans jevdecide.Answer
err error
}
func (s *stubDecide) DecideRealtime(context.Context, string) (bool, jevdecide.Answer, error) {
return s.yes, s.ans, s.err
}
func (s *stubDecide) DecideProfileBucket(context.Context, string) (string, jevdecide.Answer, error) {
return s.bucket, s.ans, s.err
}
func TestResolveRealtimeQuery_ModeOffIgnoresJev(t *testing.T) {
jevdecide.SetModeForTest(jevdecide.ModeOff)
defer jevdecide.ResetModeForTest()
e := &CoordinatorEngine{decideAssist: &stubDecide{yes: true, ans: jevdecide.Answer{Choice: "yes", Confidence: 0.99}}}
// business lexicon → rule false; jev says yes but mode off → false
if e.resolveRealtimeQuery(context.Background(), "型检温度指标怎么定") {
t.Fatal("mode=off must keep rule false")
}
// weather → rule true
if !e.resolveRealtimeQuery(context.Background(), "今天北京天气怎么样") {
t.Fatal("mode=off must keep rule true")
}
}
func TestResolveRealtimeQuery_ShadowDoesNotChangeRule(t *testing.T) {
jevdecide.SetModeForTest(jevdecide.ModeShadow)
defer jevdecide.ResetModeForTest()
jevdecide.Global.Reset()
e := &CoordinatorEngine{decideAssist: &stubDecide{
yes: true,
ans: jevdecide.Answer{Choice: "yes", Confidence: 0.99, Probabilities: map[string]float64{"yes": 0.99, "no": 0.01}},
}}
got := e.resolveRealtimeQuery(context.Background(), "项目进度怎么样")
if got {
t.Fatal("shadow must not flip rule false→true")
}
if jevdecide.Global.Disagree.Load() != 1 {
t.Fatalf("expected disagree recorded, got %+v", jevdecide.Global.Snapshot())
}
}
func TestResolveRealtimeQuery_AssistAddsRecallOnly(t *testing.T) {
jevdecide.SetModeForTest(jevdecide.ModeAssist)
defer jevdecide.ResetModeForTest()
jevdecide.Global.Reset()
e := &CoordinatorEngine{decideAssist: &stubDecide{
yes: true,
ans: jevdecide.Answer{Choice: "yes", Yes: true, Confidence: 0.96, Probabilities: map[string]float64{"yes": 0.96, "no": 0.04}},
}}
// paraphrase weather without marker — rule false, assist may add
if !e.resolveRealtimeQuery(context.Background(), "外面冷不冷啊今天") {
t.Fatal("assist high-conf yes should add recall")
}
if jevdecide.Global.AssistHit.Load() != 1 {
t.Fatal("expected assist hit")
}
// assist must not suppress rule true
e2 := &CoordinatorEngine{decideAssist: &stubDecide{
yes: false,
ans: jevdecide.Answer{Choice: "no", Confidence: 0.99, Probabilities: map[string]float64{"no": 0.99, "yes": 0.01}},
}}
if !e2.resolveRealtimeQuery(context.Background(), "今天北京天气怎么样") {
t.Fatal("assist must not turn rule true into false")
}
}
func TestResolveRealtimeQuery_FailOpen(t *testing.T) {
jevdecide.SetModeForTest(jevdecide.ModeAssist)
defer jevdecide.ResetModeForTest()
e := &CoordinatorEngine{decideAssist: &stubDecide{err: errors.New("down")}}
if e.resolveRealtimeQuery(context.Background(), "今天北京天气怎么样") != true {
t.Fatal("fail-open must keep rule true")
}
if e.resolveRealtimeQuery(context.Background(), "项目进度怎么样") != false {
t.Fatal("fail-open must keep rule false")
}
}
func TestShadowProfile_RecordsWithoutChanging(t *testing.T) {
jevdecide.SetModeForTest(jevdecide.ModeShadow)
defer jevdecide.ResetModeForTest()
jevdecide.Global.Reset()
e := &CoordinatorEngine{decideAssist: &stubDecide{
bucket: "high",
ans: jevdecide.Answer{Choice: "high", Confidence: 0.8, Probabilities: map[string]float64{"high": 0.8, "mid": 0.1, "low": 0.1}},
}}
profile := ProfileTask("你好", nil)
e.shadowProfile(context.Background(), "你好", profile)
if jevdecide.ProfileBucket(profile.ComplexityScore) != "low" {
t.Fatal("rule bucket should stay low")
}
if jevdecide.Global.Disagree.Load() != 1 {
t.Fatalf("expected disagree, got %+v", jevdecide.Global.Snapshot())
}
}