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.
176 lines
5.5 KiB
Go
176 lines
5.5 KiB
Go
package jevdecide
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestClientDecideSuccess(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/decide" {
|
|
t.Fatalf("path=%s", r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
|
|
t.Fatalf("auth=%q", got)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(Response{
|
|
Model: "test",
|
|
Answers: map[string]Answer{
|
|
"realtime": {Type: "noul", Choice: "yes", Yes: true, Confidence: 0.95,
|
|
Probabilities: map[string]float64{"yes": 0.95, "no": 0.05}},
|
|
},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := NewClient(srv.URL, "test-key")
|
|
c.Metrics = &RolloutMetrics{}
|
|
yes, ans, err := c.DecideRealtime(context.Background(), "今天北京天气怎么样")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !yes || ans.Confidence < 0.9 {
|
|
t.Fatalf("got yes=%v ans=%+v", yes, ans)
|
|
}
|
|
if c.Metrics.Calls.Load() != 1 {
|
|
t.Fatalf("calls=%d", c.Metrics.Calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestClientUnauthorized(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
|
}))
|
|
defer srv.Close()
|
|
c := NewClient(srv.URL, "bad")
|
|
c.Metrics = &RolloutMetrics{}
|
|
_, _, err := c.DecideRealtime(context.Background(), "x")
|
|
if err == nil || !strings.Contains(err.Error(), "401") {
|
|
t.Fatalf("want 401, got %v", err)
|
|
}
|
|
if c.Metrics.JevFail.Load() != 1 {
|
|
t.Fatalf("fail=%d", c.Metrics.JevFail.Load())
|
|
}
|
|
}
|
|
|
|
func TestClientServerError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_, _ = w.Write([]byte("upstream down"))
|
|
}))
|
|
defer srv.Close()
|
|
c := NewClient(srv.URL, "k")
|
|
_, err := c.Decide(context.Background(), "x", map[string]Question{"a": {Type: "noul", Question: "?"}})
|
|
if err == nil || !strings.Contains(err.Error(), "502") {
|
|
t.Fatalf("want 502, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClientBadJSON(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("not-json"))
|
|
}))
|
|
defer srv.Close()
|
|
c := NewClient(srv.URL, "k")
|
|
_, err := c.Decide(context.Background(), "x", map[string]Question{"a": {Type: "noul", Question: "?"}})
|
|
if err == nil || !strings.Contains(err.Error(), "decode") {
|
|
t.Fatalf("want decode error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestClientTimeout(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
time.Sleep(200 * time.Millisecond)
|
|
_, _ = w.Write([]byte(`{"answers":{}}`))
|
|
}))
|
|
defer srv.Close()
|
|
c := NewClient(srv.URL, "k")
|
|
c.HTTP = &http.Client{Timeout: 50 * time.Millisecond}
|
|
_, err := c.Decide(context.Background(), "x", map[string]Question{"a": {Type: "noul", Question: "?"}})
|
|
if err == nil {
|
|
t.Fatal("expected timeout")
|
|
}
|
|
}
|
|
|
|
func TestCircuitOpensAndRecovers(t *testing.T) {
|
|
var hits atomic.Int64
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
hits.Add(1)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte("fail"))
|
|
}))
|
|
defer srv.Close()
|
|
c := NewClient(srv.URL, "k")
|
|
c.Breaker = NewCircuitBreaker(3, 50*time.Millisecond)
|
|
c.Metrics = &RolloutMetrics{}
|
|
for i := 0; i < 3; i++ {
|
|
_, _ = c.Decide(context.Background(), "x", map[string]Question{"a": {Type: "noul", Question: "?"}})
|
|
}
|
|
if !c.Breaker.Open() {
|
|
t.Fatal("circuit should be open")
|
|
}
|
|
_, err := c.Decide(context.Background(), "x", map[string]Question{"a": {Type: "noul", Question: "?"}})
|
|
if err == nil || !strings.Contains(err.Error(), "circuit open") {
|
|
t.Fatalf("want circuit open, got %v", err)
|
|
}
|
|
before := hits.Load()
|
|
time.Sleep(60 * time.Millisecond)
|
|
_, _ = c.Decide(context.Background(), "x", map[string]Question{"a": {Type: "noul", Question: "?"}})
|
|
if hits.Load() <= before {
|
|
t.Fatal("expected half-open probe")
|
|
}
|
|
}
|
|
|
|
func TestDecideProfileBucket(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(Response{
|
|
Answers: map[string]Answer{
|
|
"bucket": {Type: "choice", Choice: "high", Confidence: 0.8,
|
|
Probabilities: map[string]float64{"high": 0.8, "mid": 0.15, "low": 0.05}},
|
|
},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
c := NewClient(srv.URL, "k")
|
|
bucket, ans, err := c.DecideProfileBucket(context.Background(), "写一份详尽报告")
|
|
if err != nil || bucket != "high" {
|
|
t.Fatalf("bucket=%q err=%v ans=%+v", bucket, err, ans)
|
|
}
|
|
if ans.Margin() < 0.5 {
|
|
t.Fatalf("margin=%v", ans.Margin())
|
|
}
|
|
}
|
|
|
|
func TestAnswerReliable(t *testing.T) {
|
|
ok := Answer{Choice: "yes", Confidence: 0.95, Probabilities: map[string]float64{"yes": 0.95, "no": 0.05}}
|
|
if !ok.Reliable(MinAssistConf, MinAssistMargin) {
|
|
t.Fatal("expected reliable")
|
|
}
|
|
low := Answer{Choice: "yes", Confidence: 0.7, Probabilities: map[string]float64{"yes": 0.7, "no": 0.3}}
|
|
if low.Reliable(MinAssistConf, MinAssistMargin) {
|
|
t.Fatal("expected unreliable")
|
|
}
|
|
}
|
|
|
|
func TestParseMode(t *testing.T) {
|
|
if ParseMode("") != ModeOff || ParseMode("SHADOW") != ModeShadow || ParseMode("assist") != ModeAssist {
|
|
t.Fatal("parse mismatch")
|
|
}
|
|
if ParseMode("weird") != ModeOff {
|
|
t.Fatal("unknown must fail closed to off")
|
|
}
|
|
}
|
|
|
|
func TestProfileBucket(t *testing.T) {
|
|
if ProfileBucket(10) != "low" || ProfileBucket(45) != "mid" || ProfileBucket(60) != "high" {
|
|
t.Fatal("bucket mapping wrong")
|
|
}
|
|
}
|