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.5package jev67import (8 "context"9 "fmt"10 "sync"1112 typesafe "github.com/FelineStateMachine/typesafe-go"1314 "src.rest/cat/012/internal/config"15 "src.rest/cat/012/internal/sheet"16)1718// Config is how to reach the service. The key comes from ResolveKey;19// the base URL and model from the config file or environment20// (internal/config), never from files next to a sheet.21type Config struct {22 APIKey, BaseURL, Model string23}2425// Client is the part of the TypeSafe SDK used here, so tests can fake it.26type Client interface {27 SystemOne(context.Context, typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error)28}2930// NewClient connects to the service with c. The base URL must be https31// (or http on this machine), so the key is never sent in the clear or to32// an address a stray file chose.33func NewClient(c Config) (Client, error) {34 if c.BaseURL != "" {35 if err := config.CheckBaseURL(c.BaseURL); err != nil {36 return nil, err37 }38 }39 opts := []typesafe.Option{typesafe.WithAPIKey(c.APIKey)}40 if c.BaseURL != "" {41 opts = append(opts, typesafe.WithBaseURL(c.BaseURL))42 }43 if c.Model != "" {44 opts = append(opts, typesafe.WithModel(c.Model))45 }46 return typesafe.NewClient(opts...)47}4849// Cache holds answers and the questions still to ask. It implements50// sheet.RemoteSource.51type Cache struct {52 mu sync.Mutex53 answers map[string]sheet.RemoteAnswer54 queue []sheet.RemoteCall55 waiting map[string]bool // queued or in flight56 inFlight int57}5859// NewCache returns an empty cache.60func NewCache() *Cache {61 return &Cache{answers: map[string]sheet.RemoteAnswer{}, waiting: map[string]bool{}}62}6364// Lookup returns the answer to c, queuing c if it hasn't been asked.65func (m *Cache) Lookup(c sheet.RemoteCall) (sheet.RemoteAnswer, bool) {66 m.mu.Lock()67 defer m.mu.Unlock()68 k := Key(c)69 if a, ok := m.answers[k]; ok {70 return a, true71 }72 if !m.waiting[k] {73 m.waiting[k] = true74 m.queue = append(m.queue, c)75 }76 return sheet.RemoteAnswer{}, false77}7879// Answer returns the answer to c without queuing it.80func (m *Cache) Answer(c sheet.RemoteCall) (sheet.RemoteAnswer, bool) {81 m.mu.Lock()82 defer m.mu.Unlock()83 a, ok := m.answers[Key(c)]84 return a, ok85}8687// Take removes up to n queued questions, counting them as in flight.88func (m *Cache) Take(n int) []sheet.RemoteCall {89 m.mu.Lock()90 defer m.mu.Unlock()91 n = min(n, len(m.queue))92 out := m.queue[:n:n]93 m.queue = m.queue[n:]94 m.inFlight += n95 return out96}9798// Store records the answer to a question that was in flight.99func (m *Cache) Store(c sheet.RemoteCall, a sheet.RemoteAnswer) {100 m.mu.Lock()101 defer m.mu.Unlock()102 k := Key(c)103 m.answers[k] = a104 delete(m.waiting, k)105 m.inFlight--106}107108// Forget drops answers so the questions are asked again.109func (m *Cache) Forget(calls []sheet.RemoteCall) {110 m.mu.Lock()111 defer m.mu.Unlock()112 for _, c := range calls {113 delete(m.answers, Key(c))114 }115}116117// Busy returns how many questions are in flight and queued.118func (m *Cache) Busy() (inFlight, queued int) {119 m.mu.Lock()120 defer m.mu.Unlock()121 return m.inFlight, len(m.queue)122}123124// Ask sends one question and converts the reply. Failures are answers125// too, with Failed set, so they're shown rather than retried in a loop.126func Ask(ctx context.Context, client Client, c sheet.RemoteCall) sheet.RemoteAnswer {127 q, err := question(c)128 if err != nil {129 return sheet.RemoteAnswer{Failed: err.Error()}130 }131 resp, err := client.SystemOne(ctx, typesafe.SystemOneRequest{132 State: map[string]any{"value": c.State},133 Questions: map[string]typesafe.Question{"answer": q},134 })135 if err != nil {136 return sheet.RemoteAnswer{Failed: err.Error()}137 }138 switch c.Kind {139 case "noul":140 a, err := resp.Noul("answer")141 if err != nil {142 return sheet.RemoteAnswer{Failed: err.Error()}143 }144 return sheet.RemoteAnswer{Noul: a.Noul}145 case "choice":146 a, err := resp.Choice("answer")147 if err != nil {148 return sheet.RemoteAnswer{Failed: err.Error()}149 }150 return sheet.RemoteAnswer{Choice: a.Choice, Confidence: a.Confidence}151 default:152 a, err := resp.Score("answer")153 if err != nil {154 return sheet.RemoteAnswer{Failed: err.Error()}155 }156 return sheet.RemoteAnswer{Score: a.Score, Confidence: a.Confidence}157 }158}159160// question builds the SDK question for a call.161func question(c sheet.RemoteCall) (typesafe.Question, error) {162 switch c.Kind {163 case "noul":164 q := typesafe.NoulQuestion{Instructions: c.Instructions}165 if means, _ := c.Criteria.([2]string); means[0] != "" || means[1] != "" {166 q.Criteria = &typesafe.NoulCriteria{167 True: orDefault(means[0], "Yes."),168 False: orDefault(means[1], "No."),169 }170 }171 return q, nil172 case "choice":173 labels, _ := c.Criteria.(map[string]string)174 criteria := make(map[string]any, len(labels))175 for l, d := range labels {176 criteria[l] = d177 }178 return typesafe.ChoiceQuestion{Instructions: c.Instructions, Criteria: criteria}, nil179 case "score":180 levels, _ := c.Criteria.([]string)181 criteria := make([]any, len(levels))182 for i, l := range levels {183 criteria[i] = l184 }185 return typesafe.ScoreQuestion{Instructions: c.Instructions, Criteria: criteria}, nil186 }187 return nil, fmt.Errorf("unknown question kind %q", c.Kind)188}189190func orDefault(s, def string) string {191 if s == "" {192 return def193 }194 return s195}196197// Describe summarizes an answer for the context line.198func Describe(c sheet.RemoteCall, a sheet.RemoteAnswer) string {199 pct := func(f float64) string { return fmt.Sprintf("%.0f%%", f*100) }200 switch {201 case a.Failed != "":202 return "JEV couldn't answer: " + a.Failed203 case c.Kind == "noul":204 return "JEV: " + pct(a.Noul) + " likely yes"205 case c.Kind == "choice":206 return "JEV: " + a.Choice + ", " + pct(a.Confidence) + " confident"207 }208 levels, _ := c.Criteria.([]string)209 return fmt.Sprintf("JEV: score %.2g on 0 to %d, %s confident", a.Score, len(levels)-1, pct(a.Confidence))210}