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.
182 lines
5.4 KiB
Go
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
|
|
}
|