orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package skills
2
3import (
4 "context"
5 "fmt"
6 "strings"
7)
8
9// None is the routing answer meaning no skill claims this, go and search.
10const None = "none"
11
12// Model is the slice of the LLM the router needs. Narrow on purpose, so the
13// package does not depend on the client and a test can hand over a stub.
14type Model interface {
15 Structured(ctx context.Context, system, user string, maxTokens int, schema any, out any) error
16}
17
18// The two things a question can want. This is the first half of the decision
19// and it is an enum rather than free text, which is the whole trick: a 4B asked
20// to write its reasoning writes a paragraph that argues its way to one answer
21// and then emits another, and capping the paragraph only truncates it before it
22// concludes. Both halves constrained means the second is conditioned on a token
23// the model actually committed to rather than on prose it can contradict.
24const (
25 WantsValue = "a current value, price, reading, or the score or result of a match"
26 WantsFact = "a fact, definition, explanation, opinion or set of instructions"
27)
28
29// Claimer is a skill that decides for itself, before any model call. A question
30// carrying a web address is not a judgment call, and a handler that can only
31// fire on one is noise in the routing prompt of every question that has none,
32// so a claiming skill is kept out of the prompt and the enum entirely.
33type Claimer interface {
34 Claims(question string) bool
35}
36
37// Route is the router's verdict.
38type Route struct {
39 Wants string `json:"wants"`
40 Skill string `json:"skill"`
41}
42
43// Why is what the decision came down to, for the log line.
44func (r Route) Why() string { return r.Wants }
45
46// Decide picks the skill for a question, or None.
47//
48// This is the first model call in the pipeline. Classification is the thing a
49// 4B is genuinely good at, and the enum is enforced in the grammar rather than
50// asked for in the prompt, so the model cannot name a skill that does not
51// exist. What it can still do is pick the wrong one, which is what the
52// negative triggers on each card are for.
53func (r *Registry) Decide(ctx context.Context, m Model, question string) Route {
54 for _, s := range r.skills {
55 if c, ok := s.(Claimer); ok && c.Claims(question) {
56 return Route{Skill: s.Card().Name, Wants: "the question says which page to read"}
57 }
58 }
59
60 names := append(r.routable(), None)
61 schema := map[string]any{
62 "type": "object",
63 "properties": map[string]any{
64 "wants": map[string]any{"type": "string", "enum": []string{WantsValue, WantsFact}},
65 "skill": map[string]any{"type": "string", "enum": names},
66 },
67 // wants first, so the model commits to what the question is before the
68 // handler names are in front of it.
69 "required": []string{"wants", "skill"},
70 "additionalProperties": false,
71 }
72
73 var out Route
74 if err := m.Structured(ctx, r.systemPrompt(), question, 160, schema, &out); err != nil {
75 return Route{Skill: r.match(question), Wants: "routing call failed, matched on keywords"}
76 }
77 out.Skill = strings.TrimSpace(out.Skill)
78 if out.Skill == "" || (out.Skill != None && r.Get(out.Skill) == nil) {
79 return Route{Skill: r.match(question), Wants: "routing returned an unknown skill"}
80 }
81 // The two fields can still disagree, and when they do the first one is the
82 // one to trust, because it was decided before the handler names were in
83 // front of it.
84 if out.Wants == WantsFact {
85 out.Skill = None
86 }
87 return out
88}
89
90// systemPrompt is built from the cards rather than written out, so a skill
91// cannot be added without the router learning about it.
92func (r *Registry) systemPrompt() string {
93 var b strings.Builder
94 b.WriteString("You route a question to the one handler that answers it from a live source, or to none.\n\n")
95 b.WriteString("Handlers:\n\n")
96 for _, s := range r.skills {
97 if _, ok := s.(Claimer); ok {
98 continue
99 }
100 c := s.Card()
101 fmt.Fprintf(&b, "%s: %s\n", c.Name, c.Does)
102 if len(c.Fires) > 0 {
103 fmt.Fprintf(&b, " picks up: %s\n", strings.Join(quoteAll(c.Fires), "; "))
104 }
105 if len(c.NotFor) > 0 {
106 fmt.Fprintf(&b, " not this one: %s\n", strings.Join(quoteAll(c.NotFor), "; "))
107 }
108 b.WriteString("\n")
109 }
110 b.WriteString(None + ": everything else, which is most questions. Anything needing explanation, background, instructions, opinion or more than one source.\n\n")
111 b.WriteString(strings.Join([]string{
112 "First say what the question wants.",
113 "\"" + WantsValue + "\" is a question a handler above can answer outright, and it is the minority of questions.",
114 "\"" + WantsFact + "\" is everything else, and it always means " + None + ".",
115 "Naming a thing a handler deals in is not enough. Which currency a country uses is a fact and not a conversion, how many calories are in a banana is a fact and not arithmetic, and how betting odds work is a fact and not a price.",
116 "Then name the handler.",
117 "Choose one only when it answers the whole question on its own, and when the question matches one of its \"not this one\" examples choose " + None + ".",
118 "When two could fit, choose " + None + ".",
119 }, " "))
120 return b.String()
121}
122
123func quoteAll(in []string) []string {
124 out := make([]string, len(in))
125 for i, s := range in {
126 out[i] = `"` + s + `"`
127 }
128 return out
129}
130
131// routable is the enum the model chooses from, which is every skill that needs
132// a judgment call to fire.
133func (r *Registry) routable() []string {
134 out := make([]string, 0, len(r.skills))
135 for _, s := range r.skills {
136 if _, ok := s.(Claimer); ok {
137 continue
138 }
139 out = append(out, s.Card().Name)
140 }
141 return out
142}
143
144// match is the offline fallback. It runs when the model is unreachable, and
145// being blunt is the point: a wrong skill here is recoverable because every
146// skill still verifies it can actually answer before claiming the question.
147func (r *Registry) match(question string) string {
148 l := strings.ToLower(question)
149 for _, s := range r.skills {
150 if cl, ok := s.(Claimer); ok && cl.Claims(question) {
151 return s.Card().Name
152 }
153 c := s.Card()
154 for _, k := range c.Keywords {
155 if strings.Contains(l, k) {
156 return c.Name
157 }
158 }
159 }
160 return None
161}
162
163// Run routes and then executes, and reports which skill answered.
164//
165// A skill returning no result is not an error, it is a skill declining, and
166// the caller falls through to the web. That happens when the router was right
167// about the subject and the upstream had nothing, which is common enough that
168// treating it as a failure would show an error for a question the pipeline can
169// still answer.
170func (r *Registry) Run(ctx context.Context, m Model, question string, d Deps) (*Result, string) {
171 route := r.Decide(ctx, m, question)
172 if route.Skill == None || route.Skill == "" {
173 return nil, None
174 }
175 s := r.Get(route.Skill)
176 if s == nil {
177 return nil, None
178 }
179 res, err := s.Run(ctx, question, d)
180 if err != nil || res == nil {
181 return nil, route.Skill
182 }
183 return res, route.Skill
184}