repos
/ orchard main

orchard

mirror

Every 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

6.7 KB · 185 lines · Go Raw History
  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}