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

7.5 KB · 197 lines · Go Raw History
  1package tools
  2
  3import (
  4	"context"
  5	"fmt"
  6	"net/url"
  7	"regexp"
  8	"strings"
  9	"time"
 10)
 11
 12// ---------------------------------------------------------------- music
 13
 14var MusicLookup = Tool{
 15	Name: "music_lookup",
 16	Description: "Check that a song or artist actually exists and get its album and year, from the " +
 17		"iTunes catalogue. Use it before listing songs you are not certain about, since an invented " +
 18		"track is the one mistake in a playlist a reader cannot see. " +
 19		"Only for a question that is already about music. It searches a music catalogue and cannot " +
 20		"say what an unfamiliar name refers to, so never reach for it to identify a person, a " +
 21		"channel or a company because the name sounds like a band.",
 22	Schema: obj(map[string]any{
 23		"artist": str("the artist"),
 24		"track":  str("the song title, optional"),
 25	}, "artist"),
 26	Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
 27		term := strings.TrimSpace(argStr(a, "artist") + " " + argStr(a, "track"))
 28		if term == "" {
 29			return nil, fmt.Errorf("artist is required")
 30		}
 31		var res struct {
 32			Results []struct {
 33				ArtistName     string `json:"artistName"`
 34				TrackName      string `json:"trackName"`
 35				CollectionName string `json:"collectionName"`
 36				ReleaseDate    string `json:"releaseDate"`
 37			} `json:"results"`
 38		}
 39		u := "https://itunes.apple.com/search?media=music&limit=5&term=" + url.QueryEscape(term)
 40		if err := getJSON(ctx, d, u, &res); err != nil {
 41			return nil, err
 42		}
 43		type match struct {
 44			Artist string `json:"artist"`
 45			Track  string `json:"track"`
 46			Album  string `json:"album"`
 47			Year   string `json:"year"`
 48		}
 49		out := make([]match, 0, len(res.Results))
 50		for _, r := range res.Results {
 51			y := r.ReleaseDate
 52			if len(y) >= 4 {
 53				y = y[:4]
 54			}
 55			out = append(out, match{r.ArtistName, r.TrackName, r.CollectionName, y})
 56		}
 57		if len(out) == 0 {
 58			return map[string]any{"query": term, "matches": out,
 59				"note": "nothing in the catalogue matches, so treat this as not existing"}, nil
 60		}
 61		return map[string]any{"query": term, "matches": out}, nil
 62	},
 63}
 64
 65// ---------------------------------------------------------------- convert
 66
 67// units are grouped so a length never converts into a weight. Volume to weight
 68// is the one crossing allowed and it is only true for water, which is why it
 69// says so in the answer rather than quietly being wrong about flour.
 70var (
 71	weight = map[string]float64{"mg": 0.001, "g": 1, "kg": 1000, "oz": 28.3495, "lb": 453.592, "st": 6350.29}
 72	volume = map[string]float64{"ml": 1, "l": 1000, "tsp": 4.92892, "tbsp": 14.7868,
 73		"cup": 236.588, "floz": 29.5735, "pint": 473.176, "quart": 946.353, "gallon": 3785.41}
 74	length = map[string]float64{"mm": 0.001, "cm": 0.01, "m": 1, "km": 1000,
 75		"in": 0.0254, "ft": 0.3048, "yd": 0.9144, "mi": 1609.34}
 76)
 77
 78var unitAlias = map[string]string{
 79	"gram": "g", "grams": "g", "gramme": "g", "kilogram": "kg", "kilograms": "kg", "kilo": "kg",
 80	"ounce": "oz", "ounces": "oz", "pound": "lb", "pounds": "lb", "lbs": "lb",
 81	"millilitre": "ml", "milliliter": "ml", "litre": "l", "liter": "l", "liters": "l", "litres": "l",
 82	"teaspoon": "tsp", "teaspoons": "tsp", "tablespoon": "tbsp", "tablespoons": "tbsp",
 83	"cups": "cup", "fluid ounce": "floz", "fl oz": "floz", "fahrenheit": "f", "celsius": "c",
 84	"centigrade": "c", "inch": "in", "inches": "in", "foot": "ft", "feet": "ft",
 85	"mile": "mi", "miles": "mi", "metre": "m", "meter": "m", "kilometre": "km", "kilometer": "km",
 86}
 87
 88func normUnit(u string) string {
 89	u = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(u, ".")))
 90	u = strings.TrimPrefix(u, "°")
 91	if v, ok := unitAlias[u]; ok {
 92		return v
 93	}
 94	return u
 95}
 96
 97var Convert = Tool{
 98	Name:        "convert",
 99	Description: "Convert between units of weight, volume, length or temperature.",
100	Schema: obj(map[string]any{
101		"value":     num("the number to convert"),
102		"from_unit": str("the unit it is in, like cup, lb, f, mi"),
103		"to_unit":   str("the unit to convert to, like g, kg, c, km"),
104	}, "value", "from_unit", "to_unit"),
105	Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
106		v := argNum(a, "value", 0)
107		from, to := normUnit(argStr(a, "from_unit")), normUnit(argStr(a, "to_unit"))
108		if from == "" || to == "" {
109			return nil, fmt.Errorf("from_unit and to_unit are required")
110		}
111		if from == "c" || from == "f" || to == "c" || to == "f" {
112			switch {
113			case from == "c" && to == "f":
114				return map[string]any{"value": round1(v*9/5 + 32), "unit": "F"}, nil
115			case from == "f" && to == "c":
116				return map[string]any{"value": round1((v - 32) * 5 / 9), "unit": "C"}, nil
117			case from == to:
118				return map[string]any{"value": v, "unit": strings.ToUpper(from)}, nil
119			}
120			return nil, fmt.Errorf("temperature only converts to temperature")
121		}
122		for _, set := range []map[string]float64{weight, volume, length} {
123			f, okF := set[from]
124			t, okT := set[to]
125			if okF && okT {
126				return map[string]any{"value": round4(v * f / t), "unit": to}, nil
127			}
128		}
129		// The one crossing worth allowing, said out loud.
130		if f, ok := volume[from]; ok {
131			if t, ok2 := weight[to]; ok2 {
132				return map[string]any{"value": round4(v * f / t), "unit": to,
133					"warning": "volume to weight is only right for water. Flour, sugar and butter all differ, so say so or look up the real density."}, nil
134			}
135		}
136		return nil, fmt.Errorf("cannot convert %s to %s", from, to)
137	},
138}
139
140func round4(f float64) float64 { return float64(int(f*10000+0.5)) / 10000 }
141
142// ---------------------------------------------------------------- calc
143
144var safeExpr = regexp.MustCompile(`^[0-9\.\+\-\*/\(\)\s%]+$`)
145
146// Long enough for any real sum and short enough that writing one cannot use up
147// a whole round's token budget.
148const maxExprChars = 600
149
150var Calc = Tool{
151	Name: "calc",
152	Description: "Evaluate an arithmetic expression. Use it rather than doing sums in your head, especially " +
153		"for totals and budgets. Keep the expression short: to total a long list of numbers, add them in " +
154		"groups of about twenty and then total the groups, rather than writing every number into one call.",
155	Schema: obj(map[string]any{"expression": str("arithmetic only, like (1299 + 210) * 0.93")}, "expression"),
156	Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
157		e := argStr(a, "expression")
158		// A model totalling a bank statement will write every line into one
159		// expression and run out of tokens partway, so the call arrives cut in
160		// half. Saying so is more use than evaluating whatever survived.
161		if len(e) > maxExprChars {
162			return nil, fmt.Errorf("that expression is too long at %d characters, add the numbers in groups of about twenty and total the groups", len(e))
163		}
164		if !safeExpr.MatchString(e) {
165			return nil, fmt.Errorf("only arithmetic is supported, no names or functions")
166		}
167		v, err := evalExpr(e)
168		if err != nil {
169			return nil, err
170		}
171		return map[string]any{"expression": e, "value": v}, nil
172	},
173}
174
175// ---------------------------------------------------------------- now
176
177var Now = Tool{
178	Name:        "now",
179	Description: "The current date and time. Use it whenever the answer depends on what day it is.",
180	Schema:      obj(map[string]any{"timezone": str("IANA name, default America/New_York")}),
181	Run: func(ctx context.Context, d *Deps, a map[string]any) (any, error) {
182		name := argStr(a, "timezone")
183		if name == "" {
184			name = "America/New_York"
185		}
186		loc, err := time.LoadLocation(name)
187		if err != nil {
188			return nil, fmt.Errorf("no timezone called %q", name)
189		}
190		t := d.Now().In(loc)
191		return map[string]any{
192			"iso": t.Format(time.RFC3339), "readable": t.Format("Monday, 2 January 2006 at 3:04 PM MST"),
193			"weekday": t.Format("Monday"), "timezone": name,
194		}, nil
195	},
196}