internal/jev loads the API key from the environment or .env files, caches answers by question so nothing is asked twice, queues new questions, and converts typed SystemOne answers. A live test, gated by JEV_LIVE_TEST=1, checks the real service.
- commit
- 7adf89844e5374f83c4022f22f9493e295379de6
- parent
- b54bf5e1a810e456a6981f9826b9690fda4caea2
- author
- Dami <dami.etoile@gmail.com>
| M | go.mod | 1 | + |
| M | go.sum | 2 | + |
| A | internal/ | 262 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 128 | +++++++++++++++ |
| A | internal/ | 40 | +++++ |
@@ -10,6 +10,7 @@ require (
1010 )1111 1212 require (13+ github.com/FelineStateMachine/typesafe-go v0.1.0 // indirect1314 github.com/charmbracelet/colorprofile v0.4.3 // indirect1415 github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect1516 github.com/charmbracelet/x/term v0.2.2 // indirect@@ -2,6 +2,8 @@ charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=
22 charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=33 charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=44 charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=5+github.com/FelineStateMachine/typesafe-go v0.1.0 h1:zJm2QvIGipgC8HiybTqeSj41EVyEjbwKL2aDX2rDPF0=6+github.com/FelineStateMachine/typesafe-go v0.1.0/go.mod h1:+8AvGJI/KbRkfIjRawUc3Y+PjmhnfmttA2m9HQg3sa8=57 github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=68 github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=79 github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=@@ -0,0 +1,262 @@
1+// Package jev answers the spreadsheet's JEV functions with TypeSafe's2+// hosted model. It caches answers by question, so recalculating never asks3+// twice, and hands queued questions to the UI to send in the background.4+// It is only used when an API key is configured.5+package jev6+7+import (8+ "bufio"9+ "context"10+ "encoding/json"11+ "fmt"12+ "os"13+ "path/filepath"14+ "strings"15+ "sync"16+17+ typesafe "github.com/FelineStateMachine/typesafe-go"18+19+ "012/internal/sheet"20+)21+22+// Config is how to reach the service.23+type Config struct {24+ APIKey, BaseURL, Model string25+}26+27+// LoadConfig reads TYPESAFE_API_KEY, TYPESAFE_BASE_URL and28+// TYPESAFE_DEFAULT_MODEL from the environment, falling back to .env files29+// in dirs, first found wins. It reports false when there is no key, which30+// turns JEV functions off.31+func LoadConfig(dirs ...string) (Config, bool) {32+ vals := map[string]string{}33+ for _, dir := range dirs {34+ for k, v := range readDotEnv(filepath.Join(dir, ".env")) {35+ if _, seen := vals[k]; !seen {36+ vals[k] = v37+ }38+ }39+ }40+ get := func(k string) string {41+ if v := strings.TrimSpace(os.Getenv(k)); v != "" {42+ return v43+ }44+ return vals[k]45+ }46+ c := Config{APIKey: get("TYPESAFE_API_KEY"), BaseURL: get("TYPESAFE_BASE_URL"), Model: get("TYPESAFE_DEFAULT_MODEL")}47+ return c, c.APIKey != ""48+}49+50+// readDotEnv reads the TYPESAFE_ settings from a .env file: KEY=value51+// lines, optionally quoted, ignoring comments and anything else.52+func readDotEnv(path string) map[string]string {53+ f, err := os.Open(path)54+ if err != nil {55+ return nil56+ }57+ defer f.Close()58+ out := map[string]string{}59+ sc := bufio.NewScanner(f)60+ for sc.Scan() {61+ line := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(sc.Text()), "export "))62+ k, v, ok := strings.Cut(line, "=")63+ if !ok || !strings.HasPrefix(k, "TYPESAFE_") {64+ continue65+ }66+ v = strings.TrimSpace(v)67+ if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] {68+ v = v[1 : len(v)-1]69+ }70+ out[strings.TrimSpace(k)] = v71+ }72+ return out73+}74+75+// Client is the part of the TypeSafe SDK used here, so tests can fake it.76+type Client interface {77+ SystemOne(context.Context, typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error)78+}79+80+// NewClient connects to the service with c.81+func NewClient(c Config) (Client, error) {82+ opts := []typesafe.Option{typesafe.WithAPIKey(c.APIKey)}83+ if c.BaseURL != "" {84+ opts = append(opts, typesafe.WithBaseURL(c.BaseURL))85+ }86+ if c.Model != "" {87+ opts = append(opts, typesafe.WithModel(c.Model))88+ }89+ return typesafe.NewClient(opts...)90+}91+92+// Key identifies a question by its content.93+func Key(c sheet.RemoteCall) string {94+ b, err := json.Marshal(c)95+ if err != nil {96+ return fmt.Sprintf("%#v", c)97+ }98+ return string(b)99+}100+101+// Cache holds answers and the questions still to ask. It implements102+// sheet.RemoteSource.103+type Cache struct {104+ mu sync.Mutex105+ answers map[string]sheet.RemoteAnswer106+ queue []sheet.RemoteCall107+ waiting map[string]bool // queued or in flight108+ inFlight int109+}110+111+// NewCache returns an empty cache.112+func NewCache() *Cache {113+ return &Cache{answers: map[string]sheet.RemoteAnswer{}, waiting: map[string]bool{}}114+}115+116+// Lookup returns the answer to c, queuing c if it hasn't been asked.117+func (m *Cache) Lookup(c sheet.RemoteCall) (sheet.RemoteAnswer, bool) {118+ m.mu.Lock()119+ defer m.mu.Unlock()120+ k := Key(c)121+ if a, ok := m.answers[k]; ok {122+ return a, true123+ }124+ if !m.waiting[k] {125+ m.waiting[k] = true126+ m.queue = append(m.queue, c)127+ }128+ return sheet.RemoteAnswer{}, false129+}130+131+// Answer returns the answer to c without queuing it.132+func (m *Cache) Answer(c sheet.RemoteCall) (sheet.RemoteAnswer, bool) {133+ m.mu.Lock()134+ defer m.mu.Unlock()135+ a, ok := m.answers[Key(c)]136+ return a, ok137+}138+139+// Take removes up to n queued questions, counting them as in flight.140+func (m *Cache) Take(n int) []sheet.RemoteCall {141+ m.mu.Lock()142+ defer m.mu.Unlock()143+ n = min(n, len(m.queue))144+ out := m.queue[:n:n]145+ m.queue = m.queue[n:]146+ m.inFlight += n147+ return out148+}149+150+// Store records the answer to a question that was in flight.151+func (m *Cache) Store(c sheet.RemoteCall, a sheet.RemoteAnswer) {152+ m.mu.Lock()153+ defer m.mu.Unlock()154+ k := Key(c)155+ m.answers[k] = a156+ delete(m.waiting, k)157+ m.inFlight--158+}159+160+// Forget drops answers so the questions are asked again.161+func (m *Cache) Forget(calls []sheet.RemoteCall) {162+ m.mu.Lock()163+ defer m.mu.Unlock()164+ for _, c := range calls {165+ delete(m.answers, Key(c))166+ }167+}168+169+// Busy returns how many questions are in flight and queued.170+func (m *Cache) Busy() (inFlight, queued int) {171+ m.mu.Lock()172+ defer m.mu.Unlock()173+ return m.inFlight, len(m.queue)174+}175+176+// Ask sends one question and converts the reply. Failures are answers177+// too, with Failed set, so they're shown rather than retried in a loop.178+func Ask(ctx context.Context, client Client, c sheet.RemoteCall) sheet.RemoteAnswer {179+ q, err := question(c)180+ if err != nil {181+ return sheet.RemoteAnswer{Failed: err.Error()}182+ }183+ resp, err := client.SystemOne(ctx, typesafe.SystemOneRequest{184+ State: map[string]any{"value": c.State},185+ Questions: map[string]typesafe.Question{"answer": q},186+ })187+ if err != nil {188+ return sheet.RemoteAnswer{Failed: err.Error()}189+ }190+ switch c.Kind {191+ case "noul":192+ a, err := resp.Noul("answer")193+ if err != nil {194+ return sheet.RemoteAnswer{Failed: err.Error()}195+ }196+ return sheet.RemoteAnswer{Noul: a.Noul}197+ case "choice":198+ a, err := resp.Choice("answer")199+ if err != nil {200+ return sheet.RemoteAnswer{Failed: err.Error()}201+ }202+ return sheet.RemoteAnswer{Choice: a.Choice, Confidence: a.Confidence}203+ default:204+ a, err := resp.Score("answer")205+ if err != nil {206+ return sheet.RemoteAnswer{Failed: err.Error()}207+ }208+ return sheet.RemoteAnswer{Score: a.Score, Confidence: a.Confidence}209+ }210+}211+212+// question builds the SDK question for a call.213+func question(c sheet.RemoteCall) (typesafe.Question, error) {214+ switch c.Kind {215+ case "noul":216+ q := typesafe.NoulQuestion{Instructions: c.Instructions}217+ if means, _ := c.Criteria.([2]string); means[0] != "" || means[1] != "" {218+ q.Criteria = &typesafe.NoulCriteria{219+ True: orDefault(means[0], "Yes."),220+ False: orDefault(means[1], "No."),221+ }222+ }223+ return q, nil224+ case "choice":225+ labels, _ := c.Criteria.(map[string]string)226+ criteria := make(map[string]any, len(labels))227+ for l, d := range labels {228+ criteria[l] = d229+ }230+ return typesafe.ChoiceQuestion{Instructions: c.Instructions, Criteria: criteria}, nil231+ case "score":232+ levels, _ := c.Criteria.([]string)233+ criteria := make([]any, len(levels))234+ for i, l := range levels {235+ criteria[i] = l236+ }237+ return typesafe.ScoreQuestion{Instructions: c.Instructions, Criteria: criteria}, nil238+ }239+ return nil, fmt.Errorf("unknown question kind %q", c.Kind)240+}241+242+func orDefault(s, def string) string {243+ if s == "" {244+ return def245+ }246+ return s247+}248+249+// Describe summarizes an answer for the context line.250+func Describe(c sheet.RemoteCall, a sheet.RemoteAnswer) string {251+ pct := func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) }252+ switch {253+ case a.Failed != "":254+ return "JEV couldn't answer: " + a.Failed255+ case c.Kind == "noul":256+ return "JEV: " + pct(a.Noul) + " likely yes"257+ case c.Kind == "choice":258+ return "JEV: " + a.Choice + ", " + pct(a.Confidence) + " confident"259+ }260+ levels, _ := c.Criteria.([]string)261+ return fmt.Sprintf("JEV: score %.2g on 0 to %d, %s confident", a.Score, len(levels)-1, pct(a.Confidence))262+}@@ -0,0 +1,128 @@
1+package jev2+3+import (4+ "context"5+ "errors"6+ "os"7+ "path/filepath"8+ "testing"9+10+ typesafe "github.com/FelineStateMachine/typesafe-go"11+12+ "012/internal/sheet"13+)14+15+func TestLoadConfig(t *testing.T) {16+ dir := t.TempDir()17+ os.WriteFile(filepath.Join(dir, ".env"), []byte("# settings\nOTHER=x\nexport TYPESAFE_API_KEY=\"from-file\"\nTYPESAFE_DEFAULT_MODEL=jev-small\n"), 0o600)18+ t.Setenv("TYPESAFE_API_KEY", "")19+ t.Setenv("TYPESAFE_BASE_URL", "")20+ t.Setenv("TYPESAFE_DEFAULT_MODEL", "")21+22+ c, ok := LoadConfig(dir)23+ if !ok || c.APIKey != "from-file" || c.Model != "jev-small" {24+ t.Errorf("from .env: %+v %v", c, ok)25+ }26+ t.Setenv("TYPESAFE_API_KEY", "from-env")27+ if c, _ := LoadConfig(dir); c.APIKey != "from-env" {28+ t.Errorf("environment should win: %q", c.APIKey)29+ }30+ t.Setenv("TYPESAFE_API_KEY", "")31+ if _, ok := LoadConfig(t.TempDir()); ok {32+ t.Error("no key anywhere, but JEV is on")33+ }34+}35+36+func TestCacheQueuesOnce(t *testing.T) {37+ m := NewCache()38+ q := sheet.RemoteCall{Kind: "noul", State: "x", Instructions: "Q", Criteria: [2]string{}}39+ for range 3 {40+ if _, ok := m.Lookup(q); ok {41+ t.Fatal("answered before asking")42+ }43+ }44+ if in, queued := m.Busy(); in != 0 || queued != 1 {45+ t.Fatalf("busy %d %d: duplicate questions queued", in, queued)46+ }47+ taken := m.Take(4)48+ if len(taken) != 1 {49+ t.Fatalf("took %d", len(taken))50+ }51+ m.Lookup(q) // asking again while in flight doesn't re-queue52+ if in, queued := m.Busy(); in != 1 || queued != 0 {53+ t.Fatalf("busy %d %d", in, queued)54+ }55+ m.Store(q, sheet.RemoteAnswer{Noul: 0.7})56+ if a, ok := m.Lookup(q); !ok || a.Noul != 0.7 {57+ t.Errorf("after store: %+v %v", a, ok)58+ }59+ m.Forget([]sheet.RemoteCall{q})60+ if _, ok := m.Lookup(q); ok {61+ t.Error("forgotten answer still there")62+ }63+ if _, queued := m.Busy(); queued != 1 {64+ t.Error("forgotten question not re-queued")65+ }66+}67+68+type fakeClient struct {69+ resp *typesafe.SystemOneResponse70+ err error71+ got typesafe.SystemOneRequest72+}73+74+func (f *fakeClient) SystemOne(_ context.Context, r typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error) {75+ f.got = r76+ return f.resp, f.err77+}78+79+func TestAsk(t *testing.T) {80+ answers := func(a typesafe.Answer) *typesafe.SystemOneResponse {81+ return &typesafe.SystemOneResponse{Answers: map[string]typesafe.Answer{"answer": a}}82+ }83+ tests := []struct {84+ call sheet.RemoteCall85+ resp *typesafe.SystemOneResponse86+ want sheet.RemoteAnswer87+ desc string88+ }{89+ {sheet.RemoteCall{Kind: "noul", State: 42.0, Instructions: "Q", Criteria: [2]string{"big", ""}},90+ answers(typesafe.NoulAnswer{Noul: 0.25}), sheet.RemoteAnswer{Noul: 0.25}, "JEV: 25% likely yes"},91+ {sheet.RemoteCall{Kind: "choice", State: "x", Instructions: "Q", Criteria: map[string]string{"a": "A", "b": "B"}},92+ answers(typesafe.ChoiceAnswer{Choice: "b", Confidence: 0.9}), sheet.RemoteAnswer{Choice: "b", Confidence: 0.9}, "JEV: b, 90% confident"},93+ {sheet.RemoteCall{Kind: "score", State: "x", Instructions: "Q", Criteria: []string{"low", "mid", "high"}},94+ answers(typesafe.ScoreAnswer{Score: 1.5, Confidence: 0.6}), sheet.RemoteAnswer{Score: 1.5, Confidence: 0.6}, "JEV: score 1.5 on 0 to 2, 60% confident"},95+ {sheet.RemoteCall{Kind: "score", State: "x", Instructions: "Q", Criteria: []string{"low", "high"}},96+ answers(typesafe.NoulAnswer{Noul: 1}), sheet.RemoteAnswer{Failed: "x"}, ""}, // wrong answer type97+ }98+ for _, tt := range tests {99+ c := &fakeClient{resp: tt.resp}100+ got := Ask(context.Background(), c, tt.call)101+ if tt.want.Failed != "" {102+ if got.Failed == "" {103+ t.Errorf("%s: expected a failure, got %+v", tt.call.Kind, got)104+ }105+ continue106+ }107+ if got != tt.want {108+ t.Errorf("%s: %+v, want %+v", tt.call.Kind, got, tt.want)109+ }110+ if d := Describe(tt.call, got); d != tt.desc {111+ t.Errorf("%s: describe %q, want %q", tt.call.Kind, d, tt.desc)112+ }113+ // State always travels as an object, which the API requires.114+ if st, ok := c.got.State.(map[string]any); !ok || st["value"] != tt.call.State {115+ t.Errorf("%s: state %#v", tt.call.Kind, c.got.State)116+ }117+ }118+ // Noul criteria fill in the missing side.119+ c := &fakeClient{resp: answers(typesafe.NoulAnswer{})}120+ Ask(context.Background(), c, tests[0].call)121+ if q := c.got.Questions["answer"].(typesafe.NoulQuestion); q.Criteria.True != "big" || q.Criteria.False != "No." {122+ t.Errorf("noul criteria %+v", q.Criteria)123+ }124+ // Network failures become answers with Failed set.125+ if a := Ask(context.Background(), &fakeClient{err: errors.New("offline")}, tests[0].call); a.Failed != "offline" {126+ t.Errorf("failure %+v", a)127+ }128+}@@ -0,0 +1,40 @@
1+package jev2+3+import (4+ "context"5+ "os"6+ "testing"7+ "time"8+9+ "012/internal/sheet"10+)11+12+// TestLive asks the real service one question of each kind. It only runs13+// with JEV_LIVE_TEST=1 and a key in the environment or the repo's .env.14+func TestLive(t *testing.T) {15+ if os.Getenv("JEV_LIVE_TEST") != "1" {16+ t.Skip("set JEV_LIVE_TEST=1 to call the live JEV service")17+ }18+ cfg, ok := LoadConfig("../..")19+ if !ok {20+ t.Skip("no TYPESAFE_API_KEY")21+ }22+ client, err := NewClient(cfg)23+ if err != nil {24+ t.Fatal(err)25+ }26+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)27+ defer cancel()28+ text := "The package arrived crushed and support hasn't replied in a week."29+ for _, c := range []sheet.RemoteCall{30+ {Kind: "noul", State: text, Instructions: "Is this a complaint?", Criteria: [2]string{}},31+ {Kind: "choice", State: text, Instructions: "Sentiment", Criteria: map[string]string{"positive": "positive", "negative": "negative", "neutral": "neutral"}},32+ {Kind: "score", State: text, Instructions: "How urgent is this?", Criteria: []string{"low", "medium", "high"}},33+ } {34+ a := Ask(ctx, client, c)35+ if a.Failed != "" {36+ t.Fatalf("%s: %s", c.Kind, a.Failed)37+ }38+ t.Logf("%s: %s", c.Kind, Describe(c, a))39+ }40+}