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
1// Package skills holds the handlers that claim a question a plain web search
2// answers worse.
3//
4// Some questions have a right answer sitting behind an API, and searching for
5// them is slower and produces a paraphrase of a page that was itself reading
6// the same number. Others name the page to read, and searching for words
7// scraped off the question finds different pages and answers from those. Both
8// are skills, and the web is not the dividing line, since Page fetches a URL
9// off the open web and Markets reads a quote.
10//
11// The rule the whole package follows: a skill that guesses wrong is worse than
12// a web search that takes ten seconds. Anything ambiguous falls through.
13package skills
14
15import (
16 "context"
17 "net/http"
18 "time"
19)
20
21// Source is where a skill's numbers came from. It mirrors the pipeline's own
22// source type, kept separate so this package does not import the engine.
23type Source struct {
24 URL string
25 Title string
26 Site string
27}
28
29// Result is what a skill returns. Text is markdown, rendered by the caller,
30// since rendering belongs to whoever owns the template.
31type Result struct {
32 Skill string
33 Text string
34 Sources []Source
35 // Shape names the answer format for the caller's contract table. A skill
36 // writes its own prose, so this only decides how the answer is presented.
37 Shape string
38 Elapsed string
39
40 // URLs are pages the caller should read instead of searching, for a skill
41 // that knows where the answer is rather than what it says. A result
42 // carrying them has no Text, since the answer gets written from the pages
43 // once they are fetched and checked sentence by sentence like any other.
44 URLs []string
45}
46
47// Deps is everything a skill is allowed to reach. Nothing here is a global, so
48// a test hands over a stub clock and a stub transport and gets a deterministic
49// run.
50type Deps struct {
51 HTTP *http.Client
52 // UA has to be a real browser string. Yahoo and ESPN both answer a block
53 // page to anything else.
54 UA string
55 Now func() time.Time
56}
57
58func (d Deps) now() time.Time {
59 if d.Now != nil {
60 return d.Now()
61 }
62 return time.Now()
63}
64
65// Card is the routing metadata for one skill, and it is the only thing the
66// router shows the model.
67//
68// Written for routing rather than for a reader: what the skill does in the
69// third person, the phrasings that should fire it, and the near misses that
70// should not. The negative half matters as much as the positive half, because
71// the questions a router gets wrong are the ones that sit just outside a
72// skill rather than far away from every skill.
73type Card struct {
74 Name string
75 Does string
76 Fires []string
77 NotFor []string
78
79 // Keywords is the offline matcher. It runs only when the model is
80 // unreachable or the routing call fails, so it is allowed to be blunt.
81 Keywords []string
82}
83
84// Skill answers one kind of question from one source.
85type Skill interface {
86 Card() Card
87 Run(ctx context.Context, question string, d Deps) (*Result, error)
88}
89
90// Registry is the set of skills the router chooses between. Order is the order
91// they are shown to the model and the order the offline matcher tries them, so
92// put the narrow ones first.
93type Registry struct {
94 skills []Skill
95 byName map[string]Skill
96}
97
98func NewRegistry(list ...Skill) *Registry {
99 r := &Registry{byName: make(map[string]Skill, len(list))}
100 for _, s := range list {
101 r.skills = append(r.skills, s)
102 r.byName[s.Card().Name] = s
103 }
104 return r
105}
106
107// All returns the registered skills in registration order.
108func (r *Registry) All() []Skill { return r.skills }
109
110// Get returns a skill by name, or nil when the name is not one of ours.
111func (r *Registry) Get(name string) Skill { return r.byName[name] }
112
113// Default is the set this site runs. Adding a skill is adding it here and
114// nowhere else, since the router builds its prompt and its enum from whatever
115// this returns.
116func Default() *Registry {
117 return NewRegistry(
118 Page{},
119 Calculator{},
120 Convert{},
121 Time{},
122 Weather{},
123 Markets{},
124 Odds{},
125 Sports{},
126 )
127}