feat(ai): add Jev shadow gates (triage before, review after the LLM diagnosis)
TypeSafe System One(Jev)への最小クライアントと、診断ループに並走する shadow ゲート 2 つを追加した。triage はアラートと事前取得サンプルからラベル・ 回復中か・層を判定し、review は診断の根拠・ランブック・target を検証して best_action を選ぶ。JEV_MODE=shadow のときだけ動き、答えは ai.jev_shadow と 診断結果の jev フィールドに記録するだけで、処理の分岐は一切変えない。
This commit is contained in:
1 parent
af1cb4f6dc
commit
91cdd9ada5
6 files changed
+479
-19
No files matched your search
@@ -0,0 +1,124 @@
|
||||
// Package jev is a minimal client for TypeSafe's System One API (model Jev): POST /v1/systemone with a state and a map
|
||||
// of typed questions (noul | choice | score), answered in parallel with calibrated probabilities. API reference:
|
||||
// https://docs.typesafe.ai/api (checked 2026-10-08). Used only in shadow mode here: answers are recorded next to the
|
||||
// LLM diagnosis and never change what the agent does.
|
||||
package jev
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNoKey = errors.New("typesafe api key is empty")
|
||||
|
||||
type Question struct {
|
||||
Type string `json:"type"` // noul | choice | score
|
||||
Instructions string `json:"instructions"` // the full question (the map key is not sent to the model)
|
||||
Criteria any `json:"criteria,omitempty"` // noul: {true:..., false:...}; choice: map[option]description; score: []level
|
||||
}
|
||||
|
||||
type Answer struct {
|
||||
Type string `json:"type"`
|
||||
Noul *float64 `json:"noul,omitempty"`
|
||||
Choice string `json:"choice,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Probabilities map[string]float64 `json:"probabilities,omitempty"`
|
||||
Confidence *float64 `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Model string `json:"model"`
|
||||
Answers map[string]Answer `json:"answers"`
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
base string
|
||||
model string
|
||||
key func() (string, error)
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New builds a client. base defaults to https://api.typesafe.ai; model should be pinned (jev-1.13.0) so recorded
|
||||
// answers stay comparable across campaigns (models page: aliases move on release).
|
||||
func New(base, model string, key func() (string, error)) *Client {
|
||||
if base == "" {
|
||||
base = "https://api.typesafe.ai"
|
||||
}
|
||||
if model == "" {
|
||||
model = "jev-1.13.0"
|
||||
}
|
||||
return &Client{base: strings.TrimRight(base, "/"), model: model, key: key, http: &http.Client{Timeout: 10 * time.Second}}
|
||||
}
|
||||
|
||||
func (c *Client) Model() string { return c.model }
|
||||
|
||||
// KeyPresent reports whether a non-empty key can be read (for /selfcheck).
|
||||
func (c *Client) KeyPresent() bool {
|
||||
k, err := c.key()
|
||||
return err == nil && strings.TrimSpace(k) != ""
|
||||
}
|
||||
|
||||
// Ask sends one request. 429/529 are retried once after the server's retry-after (or 1s); other errors return as is.
|
||||
func (c *Client) Ask(ctx context.Context, state any, questions map[string]Question) (*Response, error) {
|
||||
key, err := c.key()
|
||||
if err != nil || strings.TrimSpace(key) == "" {
|
||||
return nil, ErrNoKey
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{"model": c.model, "state": state, "questions": questions})
|
||||
var last error
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", c.base+"/v1/systemone", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(key))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 429 || resp.StatusCode == 529 {
|
||||
last = fmt.Errorf("typesafe status %d: %s", resp.StatusCode, truncate(string(rb), 200))
|
||||
wait := time.Second
|
||||
if ra := resp.Header.Get("retry-after"); ra != "" {
|
||||
if d, perr := time.ParseDuration(ra + "s"); perr == nil && d < 5*time.Second {
|
||||
wait = d
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return nil, fmt.Errorf("typesafe status %d: %s", resp.StatusCode, truncate(string(rb), 300))
|
||||
}
|
||||
var out Response
|
||||
if err := json.Unmarshal(rb, &out); err != nil {
|
||||
return nil, fmt.Errorf("typesafe decode: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
return nil, last
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
Reference in new issue
Block a user