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 }