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// Citations. The model points at a source by number and never writes the
2// address, so a link on this page is one a tool really fetched rather than one
3// a small model half remembered. It wrote three dead hostnames under an answer
4// before this existed.
5//
6// The number it wrote is then repaired here rather than trusted, and a sentence
7// it left uncited gets one if the wording clearly came from a source. That is
8// search's shape without search's cost: no entailment call per sentence, which
9// is the part that makes an answer there take a minute.
10package main
11
12import (
13 "fmt"
14 "net/url"
15 "regexp"
16 "strconv"
17 "strings"
18
19 "chat.bythewood.me/tools"
20)
21
22// How many sources the model is offered. Pages it fetched go in first, since
23// those are what it actually read, and search hits fill the rest.
24const (
25 maxSources = 14
26 maxNewsSources = 32
27)
28
29// Source is one page this turn retrieved. Text never leaves the process, it is
30// only what a sentence is matched against.
31type Source struct {
32 N int `json:"n"`
33 URL string `json:"url"`
34 Title string `json:"title"`
35 Site string `json:"site"`
36
37 Text string `json:"-"`
38 tokens map[string]bool `json:"-"`
39}
40
41// collectSources numbers everything the turn read. A url seen as a search hit
42// and then fetched is one source, keeping the hit's title and the page's text.
43func collectSources(used []tools.Result) []Source {
44 type entry struct {
45 url, title, text string
46 fetched bool
47 }
48 var order []*entry
49 seen := map[string]*entry{}
50 add := func(raw, title, text string, fetched bool) {
51 u := tidyURL(raw)
52 if u == "" {
53 return
54 }
55 e, ok := seen[u]
56 if !ok {
57 e = &entry{url: u}
58 seen[u] = e
59 order = append(order, e)
60 }
61 if e.title == "" {
62 e.title = strings.TrimSpace(title)
63 }
64 if len(text) > len(e.text) {
65 e.text = text
66 }
67 e.fetched = e.fetched || fetched
68 }
69
70 for _, r := range used {
71 if r.Err != "" {
72 continue
73 }
74 m, _ := r.Content.(map[string]any)
75 if m == nil {
76 continue
77 }
78 switch r.Name {
79 case tools.WebFetch.Name:
80 add(asString(m["url"]), "", asString(m["text"]), true)
81 case tools.WebSearch.Name:
82 hits, _ := m["results"].([]tools.SearchHit)
83 for _, h := range hits {
84 add(h.URL, h.Title, h.Title+" "+h.Snippet, false)
85 }
86 case tools.News.Name:
87 // A rundown named its publishers in the prose and linked none of
88 // them, so the one answer most worth clicking through was the one
89 // with nothing to click. Each headline is a page that was read.
90 sections, _ := m["sections"].([]tools.NewsSection)
91 for _, sec := range sections {
92 for _, it := range sec.Items {
93 add(it.URL, it.Headline, it.Headline+" "+it.Summary, false)
94 }
95 }
96 }
97 }
98
99 // A rundown is twenty to thirty items and every one of them is a page worth
100 // a link, where an ordinary turn reads a handful. Capping both the same way
101 // left most of the news with nothing to click.
102 cap := maxSources
103 for _, r := range used {
104 if r.Name == tools.News.Name && r.Err == "" {
105 cap = maxNewsSources
106 break
107 }
108 }
109
110 var out []Source
111 for _, want := range []bool{true, false} {
112 for _, e := range order {
113 if e.fetched != want || len(out) >= cap {
114 continue
115 }
116 s := Source{
117 N: len(out) + 1, URL: e.url, Title: e.title,
118 Site: siteOf(e.url), Text: e.text,
119 }
120 if s.Title == "" {
121 s.Title = s.Site
122 }
123 s.tokens = tokenSet(s.Text + " " + s.Title)
124 out = append(out, s)
125 }
126 }
127 return out
128}
129
130// prompt is the numbered list the final turn is handed. Titles are trimmed hard
131// because a page title runs to a headline and a sentence of standfirst, and the
132// model only needs enough to tell one source from another.
133func sourcePrompt(srcs []Source) string {
134 if len(srcs) == 0 {
135 return ""
136 }
137 var b strings.Builder
138 b.WriteString("\n\nSources, by number:\n")
139 for _, s := range srcs {
140 fmt.Fprintf(&b, "[%d] %s, %s\n", s.N, s.Site, trimLine(s.Title, 90))
141 }
142 b.WriteString("\nEnd a sentence that came from one of these with its number, like [2]. " +
143 "Write the number and never the address, and do not list the sources at the end, since they are shown under your answer.")
144 return b.String()
145}
146
147var citeMark = regexp.MustCompile(`\[(\d{1,3})\]`)
148
149// A marker the model put after the full stop belongs to the sentence in front
150// of it, so it moves inside before anything is split.
151var markAfterStop = regexp.MustCompile(`([.!?:])((?:\s*\[\d{1,3}\])+)`)
152
153// attach repairs the citations in a block of markdown. A number with no source
154// behind it is dropped, a sentence that cites nothing is matched against what
155// the turn read, and every marker ends up in the same place.
156func attach(text string, srcs []Source) string {
157 known := make(map[int]bool, len(srcs))
158 for _, s := range srcs {
159 known[s.N] = true
160 }
161 lines := strings.Split(text, "\n")
162 fenced := false
163 for i, line := range lines {
164 t := strings.TrimSpace(line)
165 if strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") {
166 fenced = !fenced
167 continue
168 }
169 // A pill in a heading, a quote or a table cell is in the way rather
170 // than in the margin, and a fence is code somebody is about to copy.
171 if fenced || t == "" || strings.HasPrefix(t, "#") ||
172 strings.HasPrefix(t, ">") || strings.HasPrefix(t, "|") {
173 continue
174 }
175 lines[i] = citeLine(line, srcs, known)
176 }
177 return strings.Join(lines, "\n")
178}
179
180func citeLine(line string, srcs []Source, known map[int]bool) string {
181 indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
182 body := line[len(indent):]
183 marker := ""
184 if item, ok := listItem(body); ok {
185 marker, body = body[:len(body)-len(item)], item
186 }
187 body = markAfterStop.ReplaceAllString(body, "$2$1")
188
189 var out []string
190 for _, s := range sentences(body) {
191 if c := citeSentence(s, srcs, known); c != "" {
192 out = append(out, c)
193 }
194 }
195 if len(out) == 0 {
196 return line
197 }
198 return indent + marker + strings.Join(out, " ")
199}
200
201// citeSentence keeps the numbers that name a real source, drops the rest, and
202// looks one up when the model gave none.
203func citeSentence(s string, srcs []Source, known map[int]bool) string {
204 var ids []int
205 seen := map[int]bool{}
206 // Outside code only. In a code span [1] is an index, and both reading it
207 // as a citation and deleting it break something a reader is about to copy.
208 body := eachOutsideCode(s, func(part string) string {
209 for _, m := range citeMark.FindAllStringSubmatch(part, -1) {
210 n, _ := strconv.Atoi(m[1])
211 if known[n] && !seen[n] {
212 seen[n] = true
213 ids = append(ids, n)
214 }
215 }
216 return citeMark.ReplaceAllString(part, "")
217 })
218 body = spaceBeforePunct.ReplaceAllString(spaceRun.ReplaceAllString(body, " "), "$1")
219 body = strings.TrimSpace(body)
220 if body == "" {
221 return ""
222 }
223 if len(ids) == 0 {
224 if n := bestSource(body, srcs); n > 0 {
225 ids = append(ids, n)
226 }
227 }
228 var b strings.Builder
229 b.WriteString(body)
230 for _, n := range ids {
231 if !endsSentence(body) {
232 b.WriteByte(' ')
233 }
234 fmt.Fprintf(&b, "[%d]", n)
235 }
236 return b.String()
237}
238
239var (
240 spaceRun = regexp.MustCompile(`[ \t]{2,}`)
241 // Lifting a marker out from in front of the full stop leaves the space it
242 // was sitting on.
243 spaceBeforePunct = regexp.MustCompile(` +([,.;:!?])`)
244)
245
246func endsSentence(s string) bool {
247 if s == "" {
248 return false
249 }
250 switch s[len(s)-1] {
251 case '.', '!', '?', ':', ')', '"', '\'', '*', ']':
252 return true
253 }
254 return false
255}
256
257// How much of a sentence has to be in a source before it is cited, and how many
258// of its distinctive words have to be. The share alone is not enough, since two
259// pages about one story share most of their ordinary vocabulary and the thing
260// that tells them apart is the figure or the name only one of them carries.
261const (
262 citeFloor = 0.5
263 minDistinctive = 2
264)
265
266// bestSource picks what a sentence came from, or 0 when nothing is close
267// enough. Silence is the right answer here: a pill on a sentence the page does
268// not support is worse than no pill, since it is read as a check that passed.
269func bestSource(sentence string, srcs []Source) int {
270 words := tokenSet(sentence)
271 if len(words) < 4 {
272 return 0
273 }
274 rare := distinctive(sentence)
275 best, bestScore, bestRare := 0, 0.0, 0
276 for _, s := range srcs {
277 var hit, total float64
278 matched := 0
279 for w := range words {
280 weight := 1.0
281 if rare[w] {
282 weight = 3
283 }
284 total += weight
285 if s.tokens[w] {
286 hit += weight
287 if rare[w] {
288 matched++
289 }
290 }
291 }
292 if total == 0 {
293 continue
294 }
295 score := hit / total
296 if matched < minDistinctive || score < citeFloor {
297 continue
298 }
299 if matched > bestRare || (matched == bestRare && score > bestScore) {
300 best, bestScore, bestRare = s.N, score, matched
301 }
302 }
303 return best
304}
305
306var wordRun = regexp.MustCompile(`[a-z0-9]+`)
307
308// tokenSet is the words worth matching on. Commas go first so 48,000 in a
309// sentence and 48,000 on the page are the same token.
310func tokenSet(s string) map[string]bool {
311 s = strings.ToLower(strings.ReplaceAll(s, ",", ""))
312 out := map[string]bool{}
313 for _, w := range wordRun.FindAllString(s, -1) {
314 if stopword[w] || (len(w) < 3 && !hasDigit(w)) {
315 continue
316 }
317 out[w] = true
318 }
319 return out
320}
321
322// distinctive is the half of a sentence that identifies which page it came
323// from: the figures, the dates and the names. A word is a name here if it is
324// capitalised anywhere but the opening of the sentence.
325func distinctive(sentence string) map[string]bool {
326 out := map[string]bool{}
327 for _, f := range strings.Fields(strings.ReplaceAll(sentence, ",", "")) {
328 clean := strings.Trim(f, `.,;:!?"'()[]*_`)
329 low := strings.ToLower(strings.Join(wordRun.FindAllString(strings.ToLower(clean), -1), ""))
330 if low == "" || stopword[low] {
331 continue
332 }
333 if hasDigit(low) {
334 out[low] = true
335 continue
336 }
337 // A capital anywhere counts, the opening word included. The stopword
338 // list already covers the ordinary openers, and refusing the first word
339 // loses the name in "Hampshire Police opened an investigation", which
340 // is the whole of what identifies the page it came from.
341 if len(clean) > 2 && clean[0] >= 'A' && clean[0] <= 'Z' {
342 out[low] = true
343 }
344 }
345 return out
346}
347
348func hasDigit(s string) bool {
349 return strings.ContainsAny(s, "0123456789")
350}
351
352// sentences splits a line into the units a citation can hang off. It is crude
353// on purpose, since a split inside an abbreviation costs nothing here. A code
354// span is stepped over, because a full stop in `fmt.Println` is not one.
355func sentences(s string) []string {
356 rs := []rune(s)
357 var out []string
358 start, tick := 0, false
359 for i := 0; i < len(rs); i++ {
360 switch {
361 case rs[i] == '`':
362 tick = !tick
363 continue
364 case tick, rs[i] != '.' && rs[i] != '!' && rs[i] != '?':
365 continue
366 }
367 j := i + 1
368 for j < len(rs) && strings.ContainsRune(`.!?")']*`, rs[j]) {
369 j++
370 }
371 k := j
372 for k < len(rs) && rs[k] == ' ' {
373 k++
374 }
375 if k == j || k >= len(rs) || !opensSentence(rs[k]) {
376 continue
377 }
378 if piece := strings.TrimSpace(string(rs[start:j])); piece != "" {
379 out = append(out, piece)
380 }
381 start, i = k, k-1
382 }
383 if rest := strings.TrimSpace(string(rs[start:])); rest != "" {
384 out = append(out, rest)
385 }
386 return out
387}
388
389func opensSentence(r rune) bool {
390 return r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' ||
391 strings.ContainsRune("*_`\"([", r)
392}
393
394func listItem(t string) (string, bool) {
395 if len(t) > 2 && (t[0] == '-' || t[0] == '*' || t[0] == '+') && t[1] == ' ' {
396 return strings.TrimSpace(t[2:]), true
397 }
398 for i := 0; i < len(t) && i < 3; i++ {
399 if t[i] >= '0' && t[i] <= '9' {
400 continue
401 }
402 if i > 0 && (t[i] == '.' || t[i] == ')') && i+1 < len(t) && t[i+1] == ' ' {
403 return strings.TrimSpace(t[i+2:]), true
404 }
405 break
406 }
407 return "", false
408}
409
410// cited reports which sources an answer actually points at, in number order, so
411// the row under a message lists what was used rather than everything read.
412func cited(text string, srcs []Source) []Source {
413 used := map[int]bool{}
414 for _, m := range citeMark.FindAllStringSubmatch(text, -1) {
415 n, _ := strconv.Atoi(m[1])
416 used[n] = true
417 }
418 var out []Source
419 for _, s := range srcs {
420 if used[s.N] {
421 out = append(out, s)
422 }
423 }
424 return out
425}
426
427// linkCitations turns [3] into an anchor after rendering rather than before,
428// since goldmark drops raw HTML written into the markdown. It substitutes only
429// in text, never inside a tag, and never inside code, where [0] is an index
430// somebody is about to copy.
431func linkCitations(h string, srcs []Source) string {
432 byN := make(map[int]Source, len(srcs))
433 for _, s := range srcs {
434 byN[s.N] = s
435 }
436 anchor := func(text string) string {
437 return citeMark.ReplaceAllStringFunc(text, func(m string) string {
438 n, _ := strconv.Atoi(strings.Trim(m, "[]"))
439 s, ok := byN[n]
440 if !ok {
441 return m
442 }
443 return fmt.Sprintf(
444 `<a class="cite" href="%s" target="_blank" rel="noopener noreferrer" title="%s">%d</a>`,
445 escapeHTML(s.URL), escapeHTML(s.Site+", "+s.Title), n)
446 })
447 }
448
449 var out strings.Builder
450 out.Grow(len(h) + 64)
451 for {
452 open := strings.IndexByte(h, '<')
453 if open < 0 {
454 out.WriteString(anchor(h))
455 break
456 }
457 out.WriteString(anchor(h[:open]))
458 shut := strings.IndexByte(h[open:], '>')
459 if shut < 0 {
460 out.WriteString(h[open:])
461 break
462 }
463 tag := h[open : open+shut+1]
464 out.WriteString(tag)
465 h = h[open+shut+1:]
466 if name, ok := verbatimTag(tag); ok {
467 end := strings.Index(h, "</"+name)
468 if end < 0 {
469 out.WriteString(h)
470 break
471 }
472 out.WriteString(h[:end])
473 h = h[end:]
474 }
475 }
476 return out.String()
477}
478
479func verbatimTag(tag string) (string, bool) {
480 for _, name := range []string{"pre", "code"} {
481 if tag == "<"+name+">" || strings.HasPrefix(tag, "<"+name+" ") {
482 return name, true
483 }
484 }
485 return "", false
486}
487
488func escapeHTML(s string) string {
489 return strings.NewReplacer(
490 "&", "&", "<", "<", ">", ">", `"`, """, "'", "'",
491 ).Replace(s)
492}
493
494// A trailing list of addresses, which is what the model wrote before it had
495// numbers to write instead. The row under the message replaces it, and half of
496// them arrived with no scheme and so were never links at all.
497var (
498 sourceHeading = regexp.MustCompile(`(?i)^\**(sources?|references?|links?)\**\s*:?\s*`)
499 bareAddress = regexp.MustCompile(`^(?:https?://)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,24}(?:/\S*)?$`)
500)
501
502// dropSourceList removes an address dump from the end of an answer. It works
503// backwards and stops at the first line that is prose, so an answer ending on a
504// real sentence is untouched.
505//
506// A bulleted address only goes when a Sources heading sits above it, because a
507// list of links in the middle of an answer is a list of links the reader asked
508// for and dropping it loses the answer.
509func dropSourceList(text string) string {
510 lines := strings.Split(text, "\n")
511 plain, any := len(lines), len(lines)
512 for i := len(lines) - 1; i >= 0; i-- {
513 t := strings.TrimSpace(lines[i])
514 if t == "" {
515 continue
516 }
517 bullet := false
518 rest := sourceHeading.ReplaceAllString(t, "")
519 if trimmed, ok := listItem(rest); ok {
520 rest, bullet = trimmed, true
521 }
522 if rest == "" && t != rest {
523 // A heading on its own line, with the addresses below it.
524 any, plain = i, i
525 continue
526 }
527 if !onlyAddresses(rest) {
528 break
529 }
530 any = i
531 if !bullet {
532 plain = i
533 }
534 }
535 cut := plain
536 // The heading is what makes a bulleted run a source list rather than part
537 // of the answer.
538 if any < plain {
539 for i := any - 1; i >= 0; i-- {
540 t := strings.TrimSpace(lines[i])
541 if t == "" {
542 continue
543 }
544 if sourceHeading.MatchString(t) && strings.TrimSpace(sourceHeading.ReplaceAllString(t, "")) == "" {
545 cut = i
546 }
547 break
548 }
549 }
550 return strings.TrimRight(strings.Join(lines[:cut], "\n"), "\n")
551}
552
553// onlyAddresses reports whether a line is nothing but addresses.
554func onlyAddresses(s string) bool {
555 fields := strings.FieldsFunc(s, func(r rune) bool { return r == ' ' || r == ',' })
556 if len(fields) == 0 {
557 return false
558 }
559 for _, f := range fields {
560 if f != "and" && !bareAddress.MatchString(f) {
561 return false
562 }
563 }
564 return true
565}
566
567// An address written without a scheme is not a link to anything, and this model
568// writes them that way about half the time. Adding the scheme is what makes
569// goldmark's autolinker see it.
570var schemeless = regexp.MustCompile(`(^|[\s(])((?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,24}/[^\s)<>]*)`)
571
572func linkBareAddresses(text string) string {
573 lines := strings.Split(text, "\n")
574 fenced := false
575 for i, line := range lines {
576 t := strings.TrimSpace(line)
577 if strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") {
578 fenced = !fenced
579 continue
580 }
581 if fenced || !strings.Contains(line, "/") {
582 continue
583 }
584 lines[i] = eachOutsideCode(line, func(part string) string {
585 return schemeless.ReplaceAllString(part, "${1}https://${2}")
586 })
587 }
588 return strings.Join(lines, "\n")
589}
590
591// eachOutsideCode applies f to the parts of a line that are not in backticks.
592func eachOutsideCode(line string, f func(string) string) string {
593 parts := strings.Split(line, "`")
594 for i := 0; i < len(parts); i += 2 {
595 parts[i] = f(parts[i])
596 }
597 return strings.Join(parts, "`")
598}
599
600// tidyURL drops the fragment and the tracking parameters, so the same page
601// reached from two searches is one source.
602func tidyURL(raw string) string {
603 raw = strings.TrimSpace(raw)
604 if raw == "" {
605 return ""
606 }
607 u, err := url.Parse(raw)
608 if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
609 return ""
610 }
611 u.Fragment = ""
612 if q := u.Query(); len(q) > 0 {
613 for k := range q {
614 if strings.HasPrefix(k, "utm_") || k == "fbclid" || k == "gclid" {
615 q.Del(k)
616 }
617 }
618 u.RawQuery = q.Encode()
619 }
620 return u.String()
621}
622
623func siteOf(raw string) string {
624 u, err := url.Parse(raw)
625 if err != nil {
626 return raw
627 }
628 return strings.TrimPrefix(u.Host, "www.")
629}
630
631func asString(v any) string {
632 s, _ := v.(string)
633 return s
634}
635
636// The words that say nothing about which page a sentence came from.
637var stopword = map[string]bool{
638 "the": true, "and": true, "but": true, "for": true, "not": true, "you": true,
639 "are": true, "was": true, "were": true, "has": true, "had": true, "have": true,
640 "his": true, "her": true, "its": true, "our": true, "their": true, "them": true,
641 "they": true, "this": true, "that": true, "these": true, "those": true,
642 "with": true, "from": true, "into": true, "over": true, "under": true,
643 "than": true, "then": true, "when": true, "what": true, "which": true,
644 "who": true, "whom": true, "will": true, "would": true, "could": true,
645 "should": true, "been": true, "being": true, "there": true, "here": true,
646 "also": true, "more": true, "most": true, "some": true, "such": true,
647 "only": true, "just": true, "very": true, "much": true, "many": true,
648 "each": true, "every": true, "any": true, "all": true, "one": true,
649 "two": true, "about": true, "after": true, "before": true, "because": true,
650 "while": true, "where": true, "how": true, "why": true, "can": true,
651 "said": true, "says": true, "say": true, "make": true, "made": true,
652 "does": true, "did": true, "done": true, "get": true, "got": true,
653 "out": true, "off": true, "own": true, "same": true, "still": true,
654 "other": true, "another": true, "between": true, "through": true,
655 "during": true, "against": true, "both": true, "well": true, "back": true,
656 "even": true, "way": true, "new": true, "now": true, "day": true,
657 "like": true, "time": true, "year": true, "years": true, "people": true,
658}