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

3.9 KB · 128 lines · Go Raw History
  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}