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

182 lines
5.4 KiB
Go

package jevdecide
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
DefaultTimeout = 1500 * time.Millisecond
defaultEndpoint = "http://127.0.0.1:8767"
maxResponseBytes = 8 << 20
MinAssistConf = 0.9
MinAssistMargin = 0.15
)
// Client talks to jev-service /v1/decide with a process-local circuit breaker.
type Client struct {
Endpoint string
APIKey string
HTTP *http.Client
Breaker *CircuitBreaker
Metrics *RolloutMetrics
}
// NewClientFromEnv builds a client from JEV_* env vars and optional ~/.secrets/jev files.
func NewClientFromEnv() *Client {
endpoint := strings.TrimSpace(os.Getenv("JEV_BASE_URL"))
if endpoint == "" {
endpoint = readSecretFile("base-url")
}
if endpoint == "" {
endpoint = defaultEndpoint
}
key := strings.TrimSpace(os.Getenv("JEV_API_KEY"))
if key == "" {
key = readSecretFile("api.key")
}
return NewClient(endpoint, key)
}
// NewClient constructs a client with default timeout and breaker.
func NewClient(endpoint, apiKey string) *Client {
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
return &Client{
Endpoint: endpoint,
APIKey: apiKey,
HTTP: &http.Client{Timeout: DefaultTimeout},
Breaker: NewCircuitBreaker(defaultFailThreshold, defaultOpenFor),
Metrics: Global,
}
}
func readSecretFile(name string) string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
raw, err := os.ReadFile(home + "/.secrets/jev/" + name)
if err != nil {
return ""
}
return strings.TrimSpace(string(raw))
}
// Decide posts context + questions to /v1/decide.
func (c *Client) Decide(ctx context.Context, situation string, questions map[string]Question) (*Response, error) {
if c == nil || c.Endpoint == "" {
return nil, fmt.Errorf("jevdecide: client not configured")
}
if !c.Breaker.Allow() {
if c.Metrics != nil {
c.Metrics.JevFail.Add(1)
}
return nil, fmt.Errorf("jevdecide: circuit open")
}
start := time.Now()
body := map[string]any{"context": situation, "questions": questions}
raw, err := c.postJSON(ctx, c.Endpoint+"/v1/decide", body)
elapsed := time.Since(start).Milliseconds()
if c.Metrics != nil {
c.Metrics.RecordCall(elapsed)
}
if err != nil {
c.Breaker.RecordFailure()
if c.Metrics != nil {
c.Metrics.JevFail.Add(1)
}
return nil, err
}
var out Response
if err := json.Unmarshal(raw, &out); err != nil {
c.Breaker.RecordFailure()
if c.Metrics != nil {
c.Metrics.JevFail.Add(1)
}
return nil, fmt.Errorf("jevdecide: decode: %w", err)
}
c.Breaker.RecordSuccess()
return &out, nil
}
// DecideRealtime asks a noul realtime-query question.
func (c *Client) DecideRealtime(ctx context.Context, userInput string) (yes bool, ans Answer, err error) {
resp, err := c.Decide(ctx, userInput, map[string]Question{
"realtime": {
Type: "noul",
Question: "Does this user message need live external information such as weather, news, stock price, or exchange rate?",
},
})
if err != nil {
return false, Answer{}, err
}
a := resp.Answers["realtime"]
return a.Yes || strings.EqualFold(a.Choice, "yes"), a, nil
}
// DecideProfileBucket asks a choice complexity bucket question.
func (c *Client) DecideProfileBucket(ctx context.Context, userInput string) (bucket string, ans Answer, err error) {
situation := "Task complexity buckets (match the project's deterministic ProfileTask rules):\n" +
"- low (score 0-29): greetings, single-fact Q&A, or ONLY a light retrieval cue (检索/最新法规/标准号) without a document/table deliverable\n" +
"- mid (score 30-59): explicit short deliverable (清单/大纲/说明书/评估表) OR one deliverable + light extras, but NOT cross-domain heavy reports\n" +
"- high (score 60+): document/table deliverable PLUS cross-domain and/or multi-object comparison and/or depth words (详尽/成体系/立项级)\n\n" +
"Examples:\n" +
"Request: 你好,你是谁? → low\n" +
"Request: 帮我查一下最新法规要求 → low\n" +
"Request: 需要做一份简单清单 → mid\n" +
"Request: 帮我做一份掌上超声与进口竞品的横向对标分析报告,覆盖法规注册、工程架构与商务定价,要详尽成体系 → high\n\n" +
"Request: " + userInput
resp, err := c.Decide(ctx, situation, map[string]Question{
"bucket": {
Type: "choice",
Question: "Which complexity bucket fits this request under the rules above?",
Options: []string{"low", "mid", "high"},
},
})
if err != nil {
return "", Answer{}, err
}
a := resp.Answers["bucket"]
return a.Choice, a, nil
}
func (c *Client) postJSON(ctx context.Context, url string, body any) ([]byte, error) {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if c.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.APIKey)
}
httpc := c.HTTP
if httpc == nil {
httpc = &http.Client{Timeout: DefaultTimeout}
}
resp, err := httpc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if resp.StatusCode >= 300 {
preview := strings.TrimSpace(string(raw))
if len(preview) > 200 {
preview = preview[:200]
}
return nil, fmt.Errorf("jevdecide: http %s: %s", resp.Status, preview)
}
return raw, nil
}