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

8.2 KB · 234 lines · Go Raw History
  1package main
  2
  3import (
  4	"fmt"
  5	"regexp"
  6	"strconv"
  7	"strings"
  8	"time"
  9)
 10
 11// A question about what is next is answered by a date, so whether an answer
 12// answered it is arithmetic and belongs here rather than in a prompt. The
 13// contract tells the model which dates can answer and a 4B still writes the
 14// ones its passages gave it, which for a team that played on Friday is Friday.
 15//
 16// The test is deliberately weak. One date anywhere in the answer that is today
 17// or later leaves it alone, because a fixture list names the last result as
 18// background and that is fine. Nothing at all dated today or later means the
 19// answer cannot contain the thing that was asked for, whatever else it holds.
 20
 21// Abbreviations included, since a fixture list writes "9 Sept 2026" far more
 22// often than it writes the month out.
 23const monthNames = `(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sept|sep|oct|nov|dec)\.?`
 24
 25var (
 26	isoDate = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}\b`)
 27	// "12 September 2026" and "12 September".
 28	dayFirst = regexp.MustCompile(`(?i)\b(\d{1,2})(?:st|nd|rd|th)?\s+` + monthNames + `\b(?:,?\s+(\d{4})\b)?`)
 29	// "September 12, 2026" and "September 12".
 30	monthFirst = regexp.MustCompile(`(?i)\b` + monthNames + `\s+(\d{1,2})(?:st|nd|rd|th)?\b(?:,?\s+(\d{4})\b)?`)
 31
 32	months = map[string]time.Month{
 33		"jan": time.January, "feb": time.February, "mar": time.March,
 34		"apr": time.April, "may": time.May, "jun": time.June,
 35		"jul": time.July, "aug": time.August, "sep": time.September,
 36		"oct": time.October, "nov": time.November, "dec": time.December,
 37	}
 38)
 39
 40// datesIn is every date named in the text, each at midnight where the reader
 41// is, so a page's UTC timestamp and a fixture written as a bare day compare as
 42// the same kind of thing.
 43func datesIn(text string, now time.Time) []time.Time {
 44	var out []time.Time
 45	add := func(t time.Time, ok bool) {
 46		if ok {
 47			out = append(out, time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, now.Location()))
 48		}
 49	}
 50	for _, m := range isoDate.FindAllString(text, -1) {
 51		add(parseDate(m))
 52	}
 53	for _, m := range dayFirst.FindAllStringSubmatch(text, -1) {
 54		add(dateAt(m[1], m[2], m[3], now))
 55	}
 56	for _, m := range monthFirst.FindAllStringSubmatch(text, -1) {
 57		add(dateAt(m[2], m[1], m[3], now))
 58	}
 59	// A bare month and year is coarse but datable, and it only counts when
 60	// nothing more precise was found, since "September 2026" sitting beside
 61	// "12 September 2026" is the same date written twice.
 62	if len(out) == 0 {
 63		for _, m := range monthYear.FindAllStringSubmatch(text, -1) {
 64			add(monthStart(m[1], m[2]))
 65		}
 66	}
 67	return out
 68}
 69
 70// dateAt builds a date from the pieces as they were written.
 71func dateAt(day, month, year string, now time.Time) (time.Time, bool) {
 72	m, ok := months[strings.ToLower(month)[:3]]
 73	if !ok {
 74		return time.Time{}, false
 75	}
 76	d, err := strconv.Atoi(day)
 77	if err != nil || d < 1 || d > 31 {
 78		return time.Time{}, false
 79	}
 80	y := now.Year()
 81	assumed := year == ""
 82	if !assumed {
 83		if y, err = strconv.Atoi(year); err != nil {
 84			return time.Time{}, false
 85		}
 86	}
 87	t := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
 88	// 31 February rolls over into March rather than failing, and a date that
 89	// does not exist is not a date.
 90	if t.Day() != d {
 91		return time.Time{}, false
 92	}
 93	// A date written without its year in late December means January, and
 94	// nothing names a fixture two months behind us as the next one.
 95	if assumed && t.Before(now.AddDate(0, -2, 0)) {
 96		t = t.AddDate(1, 0, 0)
 97	}
 98	return t, true
 99}
100
101// latestDate is the furthest ahead date named anywhere in the text.
102func latestDate(text string, now time.Time) (time.Time, bool) {
103	var best time.Time
104	for _, d := range datesIn(text, now) {
105		if d.After(best) {
106			best = d
107		}
108	}
109	return best, !best.IsZero()
110}
111
112// earliestFuture is the soonest date today or later, which for this shape is
113// the one being asked for.
114func earliestFuture(text string, now time.Time) (time.Time, bool) {
115	var best time.Time
116	for _, d := range datesIn(text, now) {
117		if d.Before(truncDay(now)) {
118			continue
119		}
120		if best.IsZero() || d.Before(best) {
121			best = d
122		}
123	}
124	return best, !best.IsZero()
125}
126
127// A page stamps itself with the day it was last touched, and that date is not a
128// fixture. It is the one false positive worth spending a regex on, since a
129// schedule page updated this morning would otherwise look like a match today.
130var metaLine = regexp.MustCompile(`(?i)(updated|published|posted|written|reviewed|retrieved|copyright|all rights reserved|subscribe)`)
131
132// fixtureDates is every date on a line that is not a page stamping itself.
133func fixtureDates(text string, now time.Time) []time.Time {
134	var out []time.Time
135	for _, line := range strings.Split(text, "\n") {
136		if metaLine.MatchString(line) {
137			continue
138		}
139		out = append(out, datesIn(line, now)...)
140	}
141	return out
142}
143
144// sooner catches the answer that took a page at its word. A fixture list
145// covering one competition calls its own first match the next match, so a page
146// of league fixtures says 12 September while a page carrying the cup says the
147// 9th, and picking between them is a date comparison across five documents,
148// which is the thing a 4B does worst.
149//
150// It warns rather than rewrites, because the earlier date can legitimately be
151// something else the passage happened to mention.
152func sooner(text string, passages []Passage, now time.Time) []string {
153	lead, ok := earliestFuture(text, now)
154	if !ok {
155		return nil
156	}
157	var best time.Time
158	for _, p := range passages {
159		for _, d := range fixtureDates(p.Text, now) {
160			if d.Before(truncDay(now)) {
161				continue
162			}
163			if best.IsZero() || d.Before(best) {
164				best = d
165			}
166		}
167	}
168	if best.IsZero() || !best.Before(lead) {
169		return nil
170	}
171	return []string{fmt.Sprintf(
172		"a source names %s, which is sooner than the %s this leads with, so check that the earlier one is not a fixture in a competition the other pages leave out",
173		best.Format("2 January"), lead.Format("2 January"))}
174}
175
176// noFutureDate reports whether nothing in the answer is dated today or later,
177// which for this shape means the answer does not contain what was asked for.
178func noFutureDate(text string, now time.Time) bool {
179	d, ok := latestDate(text, now)
180	if !ok {
181		return true
182	}
183	return d.Before(truncDay(now))
184}
185
186// pastOnly is the reader-facing half of the same check.
187func pastOnly(text string, now time.Time) []string {
188	if strings.TrimSpace(text) == "" {
189		return nil
190	}
191	d, ok := latestDate(text, now)
192	if !ok {
193		return []string{"this names no date, and the question asked when something happens next"}
194	}
195	if d.Before(truncDay(now)) {
196		return []string{fmt.Sprintf(
197			"every date here has already passed, the latest is %s, so the pages found cover what has already happened rather than what is next",
198			d.Format("2 January 2006"))}
199	}
200	return nil
201}
202
203// scheduleAge warns when the freshest page behind a schedule is old enough
204// that the fixture has probably moved. The model was asked to say this itself
205// for one round and wrote that its sources were published in late September on
206// the fifth of September, which is the same lesson stale.go opens with: a 4B
207// does not compare dates in prose, so the comparison happens in Go and the
208// answer says nothing about it.
209func scheduleAge(sources []Source, now time.Time) []string {
210	var newest time.Time
211	for _, s := range sources {
212		if t, ok := parseDate(s.Published); ok && t.After(newest) {
213			newest = t
214		}
215	}
216	if newest.IsZero() {
217		return []string{"none of these pages says when it was written, so there is no telling whether this schedule is current"}
218	}
219	if days := int(truncDay(now).Sub(truncDay(newest)).Hours() / 24); days > 14 {
220		return []string{fmt.Sprintf(
221			"the newest page behind this was written %d days ago, and a schedule moves, so check it against the official one", days)}
222	}
223	return nil
224}
225
226// upcomingHint is the retry's instruction. The first search found the right
227// subject and the wrong half of it, so re-planning has to change the words
228// rather than the topic.
229func upcomingHint() string {
230	return "A previous search found only events that have already happened, and the question asks what is next. " +
231		"Write queries for the schedule itself, using words like schedule, fixtures, upcoming or next, " +
232		"and drop any word about a result, a score or what happened."
233}