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 neturl "net/url"
6 "strings"
7)
8
9// A pasted wall of links is not a question, and five pages is already more of
10// the context window than a summary can use well.
11const maxGivenURLs = 5
12
13// Page claims any question carrying a web address. The address is the
14// instruction: the reader has already found the page and wants that page read,
15// so planning a search off the words around it finds five other pages and
16// answers from those instead.
17//
18// It gathers rather than answers, which is what makes it different from every
19// other skill here. The addresses go back to the caller, which fetches them
20// through the same cache and the same browser headers a search result goes
21// through, and the answer is written and checked the usual way.
22type Page struct{}
23
24func (Page) Card() Card {
25 return Card{
26 Name: "page",
27 Does: "reads the web page whose address the question carries, and answers from that page and nothing else.",
28 Fires: []string{
29 "summary of this https://example.com/post",
30 "what does https://example.com/post say",
31 "tl;dr https://example.com/post",
32 },
33 }
34}
35
36// Claims is the whole routing decision, and it does not need the model. A
37// question either carries an address or it does not.
38func (Page) Claims(question string) bool { return len(URLsIn(question)) > 0 }
39
40func (Page) Run(ctx context.Context, question string, d Deps) (*Result, error) {
41 urls := URLsIn(question)
42 if len(urls) == 0 {
43 return nil, nil
44 }
45 return &Result{Skill: "page", Shape: "summary", URLs: urls}, nil
46}
47
48// URLsIn pulls the web addresses out of a question, in the order they were
49// written.
50func URLsIn(question string) []string {
51 var out []string
52 seen := map[string]bool{}
53 for _, f := range strings.FieldsFunc(question, func(r rune) bool {
54 return r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '<' || r == '>' || r == '"' || r == '\''
55 }) {
56 // A pasted address is often inside brackets, and one at the end of a
57 // sentence takes the punctuation with it.
58 f = strings.TrimLeft(f, "([{`*")
59 if !strings.HasPrefix(f, "http://") && !strings.HasPrefix(f, "https://") {
60 continue
61 }
62 f = strings.TrimRight(f, ".,;:!?`*")
63 for unbalanced(f) {
64 f = f[:len(f)-1]
65 }
66 u, err := neturl.Parse(f)
67 if err != nil || !strings.Contains(u.Host, ".") || seen[f] {
68 continue
69 }
70 seen[f] = true
71 out = append(out, f)
72 if len(out) == maxGivenURLs {
73 break
74 }
75 }
76 return out
77}
78
79// unbalanced says whether the address ends on a bracket it never opened, which
80// means the bracket belongs to the sentence around it. A Wikipedia title opens
81// one of its own, so the count is what decides rather than the last character.
82func unbalanced(u string) bool {
83 for _, p := range [][2]string{{"(", ")"}, {"[", "]"}, {"{", "}"}} {
84 if strings.HasSuffix(u, p[1]) && strings.Count(u, p[0]) < strings.Count(u, p[1]) {
85 return true
86 }
87 }
88 return false
89}