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

23.5 KB · 536 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"regexp"
  7	"strings"
  8
  9	"chat.bythewood.me/tools"
 10)
 11
 12// What stops a turn ending on "I could look that up for you".
 13//
 14// The turn loop breaks the moment the model replies without a tool call, which
 15// treats every reply as an answer. Two of them are not: a reply that offers to
 16// go and check, and a reply that states facts about the world nothing in the
 17// turn checked. Both read as an answer to the loop and neither is one, so the
 18// model gets one more pass with the tools still on the table rather than the
 19// turn ending there.
 20
 21// A deferral in the shapes a small model actually writes them. It is checked
 22// first because it is free and catches the common case without a model call.
 23//
 24// Every pattern needs a first person subject, since "you can search for it on
 25// their site" is advice and not a deferral, and an answer that mentions
 26// searching in passing must not be thrown away.
 27var deferrals = []*regexp.Regexp{
 28	regexp.MustCompile(`(?i)\b(i|we)\s+(can|could|will|shall|am able to)\s+(now\s+)?(go\s+)?(and\s+)?(search|look|check|find|fetch|pull|dig|research|browse|see)\b`),
 29	regexp.MustCompile(`(?i)\b(let me|i'?ll|i will|i'?m going to|i am going to)\s+(go\s+)?(and\s+)?(search|look|check|find|fetch|pull|dig|research|browse|see|grab|get)\b`),
 30	regexp.MustCompile(`(?i)\bwould you like me to\b`),
 31	regexp.MustCompile(`(?i)\bdo you want me to\b`),
 32	regexp.MustCompile(`(?i)\bshall i\b`),
 33	regexp.MustCompile(`(?i)\bif you'?d? (like|want)\b.{0,40}\b(search|look|check|find)\b`),
 34	regexp.MustCompile(`(?i)\bjust (say|let me know|tell me)\b.{0,30}\b(and|so)? ?i'?ll\b`),
 35	regexp.MustCompile(`(?i)\bi (do not|don'?t) have (access to|real ?time|current|up ?to ?date|live)\b`),
 36	regexp.MustCompile(`(?i)\bmy (training data|knowledge) (only )?(goes|extends|cuts off|ends)\b`),
 37	regexp.MustCompile(`(?i)\b(i|we) (do not|don'?t) have (anything|any information|any details|much) (to report|on that|about that)\b`),
 38}
 39
 40// Where a deferral has to start to count. A reply that puts the work off says
 41// so at the top, and one that answers and then offers to dig further has
 42// answered. Without the position an answer ending on "if you want I can check
 43// the other two" is thrown away and the whole turn is spent again.
 44const deferralHead = 240
 45
 46// The length past which even an early hedge is not a deferral, since a model
 47// that opened with one and then wrote two thousand characters did the work.
 48const deferralMax = 1200
 49
 50// isDeferral reports whether a reply puts the work off rather than doing it.
 51func isDeferral(reply string) bool {
 52	s := strings.TrimSpace(reply)
 53	if s == "" || len(s) > deferralMax {
 54		return false
 55	}
 56	for _, re := range deferrals {
 57		if m := re.FindStringIndex(s); m != nil && m[0] < deferralHead {
 58			return true
 59		}
 60	}
 61	return false
 62}
 63
 64// A refusal is not a deferral and the head and length rules above do not reach
 65// it. This one is matched anywhere in a reply of any length, which is only safe
 66// because it says outright that the reply is not answering the question.
 67var refusal = regexp.MustCompile(`(?i)\b(i|we) (cannot|can'?t|could not|couldn'?t) answer (this|that|it|your question)\b.{0,60}\bfrom (the )?tool results\b`)
 68
 69// The tool results die with the turn and only the answers survive, so a model
 70// reading its own earlier answer treats it as evidence it still holds and
 71// writes it out again. That is not a deferral and the patterns above miss it.
 72//
 73// Six word shingles against every earlier answer put the rehashes already in
 74// this site's history at 0.34, 0.40 and 0.59, and every real follow-up at 0.08.
 75const (
 76	repeatShingle = 6
 77	repeatOverlap = 0.30
 78	// Below this a draft is too short for the overlap to mean anything. A one
 79	// line answer to "why?" shares its whole vocabulary with what came before.
 80	repeatMinShingles = 20
 81)
 82
 83var wordish = regexp.MustCompile(`[a-z0-9]+`)
 84
 85// repeatsAnswered reports whether most of the draft is already in what this
 86// conversation has answered.
 87func repeatsAnswered(draft string, previous []string) bool {
 88	d := shingles(draft)
 89	if len(d) < repeatMinShingles {
 90		return false
 91	}
 92	seen := map[string]bool{}
 93	for _, p := range previous {
 94		for s := range shingles(p) {
 95			seen[s] = true
 96		}
 97	}
 98	if len(seen) == 0 {
 99		return false
100	}
101	hit := 0
102	for s := range d {
103		if seen[s] {
104			hit++
105		}
106	}
107	return float64(hit)/float64(len(d)) >= repeatOverlap
108}
109
110func shingles(s string) map[string]bool {
111	w := wordish.FindAllString(strings.ToLower(s), -1)
112	out := make(map[string]bool, len(w))
113	for i := 0; i+repeatShingle <= len(w); i++ {
114		out[strings.Join(w[i:i+repeatShingle], " ")] = true
115	}
116	return out
117}
118
119// The gate. It runs when the model stops calling tools and wants to answer,
120// which is the only moment where both "is this actually an answer" and "is
121// there enough behind it" can be asked at once.
122//
123// Two fields and one of them an enum, because a 4B handed a free reasoning
124// field beside a constrained one writes the reasoning and then contradicts it.
125// The query is what makes a verdict actionable: a step that says more research
126// is needed without saying what to look for sends the model back to the search
127// it already ran.
128type verdict struct {
129	Verdict string `json:"verdict"`
130	Query   string `json:"query"`
131}
132
133var verdictSchema = map[string]any{
134	"type": "object",
135	"properties": map[string]any{
136		"verdict": map[string]any{
137			"type": "string",
138			"enum": []string{"answered", "research"},
139		},
140		"query": map[string]any{"type": "string"},
141	},
142	"required":             []string{"verdict", "query"},
143	"additionalProperties": false,
144}
145
146const gateSystem = `You are checking a draft answer before it is sent. Answer only with the JSON object you were given a schema for.
147
148"answered" means the draft answers the question with real specifics and every fact in it either came from the tool results, or was established earlier in this conversation, or is something no tool could change, like arithmetic, code, or an opinion asked for. A draft that disagrees with the background section is never "answered", whatever else is true of it.
149
150"research" means the draft is not ready to send. Choose it when any of these are true:
151- A background section is present and the draft disagrees with it. Compare them claim by claim before anything else. The background is the opening section of a Wikipedia article, so on a definition, a name, a date, an origin or who did something it is right and a draft that says otherwise is wrong, however confident the draft sounds. Two limits: the background does not cover everything, so a fact it is simply silent on is not a disagreement, and it was taken on the date it states, so a difference about something that could have changed since then means the background is old rather than the draft wrong, and that is "answered".
152- The draft offers to look something up, says it will check, or asks whether it should.
153- The draft says it does not know, has no access, has nothing to report, or cannot answer from what it has.
154- The draft states a name, title, date, number, price or event that no tool result above supports.
155- The question asks about something current, local, priced, scheduled or newsworthy and no tool was called.
156- The draft answers part of the question and leaves the rest.
157- The draft repeats an earlier answer in this conversation instead of addressing what the new question adds to it.
158- The draft quotes a page or credits a figure to a source that is not in the tool results above. Pages read in earlier turns are gone and cannot be quoted from memory.
159
160When the verdict is "research", query is the single web search that would close the biggest gap, written as a person would type it. When the verdict is "answered", query is an empty string.`
161
162// Whether the question needs something the weights cannot hold. This is asked
163// of the question and never of the draft, because a draft that invents an
164// answer reads exactly like one that knows it, and the eight rule check below
165// let "no big news today" through on a Monday afternoon for that reason.
166type freshness struct {
167	NeedsFresh bool   `json:"needs_fresh"`
168	Query      string `json:"query"`
169}
170
171var freshnessSchema = map[string]any{
172	"type": "object",
173	"properties": map[string]any{
174		"needs_fresh": map[string]any{"type": "boolean"},
175		"query":       map[string]any{"type": "string"},
176	},
177	"required":             []string{"needs_fresh", "query"},
178	"additionalProperties": false,
179}
180
181// One question rather than the eight the draft check weighs at once, since a 9B
182// asked for a single judgement gets it right far more often. Measured over
183// thirty two of Isaac's own questions this reached sixteen of eighteen held
184// out, against a draft check that missed "any big news today?" outright.
185const freshnessSystem = `Decide whether answering the user's question correctly needs information you could not have from training alone, because it changes over time or has happened since.
186
187true when the question touches news, current events, prices, markets, scores, odds, fixtures, schedules, opening or closing, weather, or what is happening now.
188true whenever the question carries a time word like today, tonight, this weekend, yesterday, this week, right now, currently, or latest, even if the subject sounds ordinary.
189true when the question asks what has been going on with something.
190
191false when the answer is a definition, an explanation, how something works, history, code, arithmetic, or a recipe, none of which change.
192
193When needs_fresh is true, query is the single web search that would answer it, written as a person would type it. When it is false, query is an empty string.`
194
195// The second narrow question, asked of a draft that fetched nothing and passed
196// the freshness check.
197//
198// Nineteen of sixty four turns on 2026-09-08 called no tool, and the wrong ones
199// were not about anything current: they were specifics written from memory. The
200// dirty rice with 23g of protein, the Marlin 195 and the Howa 158 that are not
201// real rifles, CDX described as what Common Crawl uses under the hood. The
202// contract has said to look a subject up since it was written.
203//
204// Asked of the draft rather than the question, because the question is often
205// vague and the draft is where the invented specifics actually are.
206type grounding struct {
207	NeedsCheck bool   `json:"needs_check"`
208	Query      string `json:"query"`
209}
210
211var groundingSchema = map[string]any{
212	"type": "object",
213	"properties": map[string]any{
214		"needs_check": map[string]any{"type": "boolean"},
215		"query":       map[string]any{"type": "string"},
216	},
217	"required":             []string{"needs_check", "query"},
218	"additionalProperties": false,
219}
220
221const groundingSystem = `A draft answer was written without looking anything up. Decide whether it states specifics that ought to have been checked.
222
223true when the draft names a product, model number, part number, version, company, person, book, film or song.
224true when it gives a figure presented as fact: a price, a measurement, a nutrition number, a count, a capacity, a date.
225true when it describes what a named tool, service, format or standard does or is used for.
226
227false when the draft is explanation, reasoning, opinion, code the user asked to be written, arithmetic on figures the user supplied, or ordinary conversation.
228false when every specific in it came from what the user said in the question.
229
230When needs_check is true, query is the single web search that would check the most load bearing specific, written as a person would type it. When it is false, query is an empty string.`
231
232// needsChecking reports whether a draft written from memory states specifics.
233// A failure is a no, for the same reason every other gate fails open.
234func (e *Engine) needsChecking(ctx context.Context, draft string) (grounding, Stats) {
235	msgs := []Message{
236		{Role: RoleSystem, Content: groundingSystem},
237		{Role: RoleUser, Content: "Draft:\n" + trim(strings.TrimSpace(draft), 1800)},
238	}
239	var g grounding
240	st, err := e.llm.Structured(ctx, msgs, gateTokens, groundingSchema, &g)
241	if err != nil {
242		return grounding{}, st
243	}
244	return g, st
245}
246
247// needsFresh reports whether the question wants current information. A failure
248// is a no, for the same reason the draft check fails open: a turn that cannot
249// reach the model to ask is not a turn to send round again.
250func (e *Engine) needsFresh(ctx context.Context, question string) (freshness, Stats) {
251	msgs := []Message{
252		{Role: RoleSystem, Content: freshnessSystem},
253		{Role: RoleUser, Content: "Question: " + strings.TrimSpace(question)},
254	}
255	var f freshness
256	st, err := e.llm.Structured(ctx, msgs, gateTokens, freshnessSchema, &f)
257	if err != nil {
258		return freshness{}, st
259	}
260	return f, st
261}
262
263// enough asks whether the draft can be sent. Anything that goes wrong is a yes,
264// because a gate that fails closed would turn a working turn into a loop over a
265// model that is not answering the gate either.
266func (e *Engine) enough(ctx context.Context, question, draft, background string, results, previous []string) (verdict, Stats) {
267	var b strings.Builder
268	b.WriteString("Question:\n")
269	b.WriteString(strings.TrimSpace(question))
270	if len(previous) > 0 {
271		// Without this the gate cannot tell a fresh answer from the last one
272		// written out again, which is the whole failure on a follow-up.
273		b.WriteString("\n\nAnswers already given earlier in this conversation:\n")
274		for _, p := range previous {
275			b.WriteString("- ")
276			b.WriteString(trimLine(p, 600))
277			b.WriteByte('\n')
278		}
279	}
280	b.WriteString("\n\nTool results in this turn:\n")
281	if len(results) == 0 {
282		b.WriteString("(none, no tool was called)")
283	} else {
284		for _, r := range results {
285			b.WriteString("- ")
286			b.WriteString(trimLine(r, 600))
287			b.WriteByte('\n')
288		}
289	}
290	if background != "" {
291		b.WriteString("\n\nBackground, looked up locally rather than by the model:\n")
292		b.WriteString(background)
293		b.WriteByte('\n')
294	}
295	b.WriteString("\n\nDraft answer:\n")
296	b.WriteString(trimLine(strings.TrimSpace(draft), 2000))
297
298	msgs := []Message{
299		{Role: RoleSystem, Content: gateSystem},
300		{Role: RoleUser, Content: b.String()},
301	}
302	var v verdict
303	st, err := e.llm.Structured(ctx, msgs, gateTokens, verdictSchema, &v)
304	if err != nil || v.Verdict != "research" {
305		return verdict{Verdict: "answered"}, st
306	}
307	return v, st
308}
309
310// The nudge that goes back into the conversation. The draft itself is never
311// appended, because a model handed its own deferral writes it again.
312// Arithmetic.
313//
314// The contract has asked for calc since the tool existed and it was called zero
315// times on 2026-09-08, across a day of adding up calories. Three answers had
316// wrong sums in them, one of them contradicting a total the same conversation
317// had already given. Asking was never going to work, for the same reason it did
318// not work for tool calls generally, so a draft that adds up in prose is sent
319// back to do it with the tool.
320//
321// Deterministic and free, and deliberately not an attempt to check the sum
322// here. Working out which numbers in a sentence are the addends is the part
323// that goes wrong, and calc gets it right by construction.
324var (
325	totalWord = regexp.MustCompile(`(?i)\b(total|totals|totalling|altogether|all together|adds up to|comes to|sums? to|in total|grand total)\b`)
326	// A citation marker is a number to a regex and is not one to a reader.
327	citeNum = regexp.MustCompile(`\[\d{1,3}\]`)
328	numeral = regexp.MustCompile(`\d[\d,]*(?:\.\d+)?`)
329)
330
331// countsUpInProse is true for a draft that states a total over several numbers
332// it worked out itself. Three is the floor: two numbers and a total is usually
333// a comparison, and one is a quantity rather than a sum.
334func countsUpInProse(draft string) bool {
335	clean := citeNum.ReplaceAllString(draft, " ")
336	if !totalWord.MatchString(clean) {
337		return false
338	}
339	// Inside a fence the numbers belong to code somebody is about to run.
340	clean = strings.Join(outsideFences(clean), "\n")
341	return len(numeral.FindAllString(clean, -1)) >= 3 && totalWord.MatchString(clean)
342}
343
344// outsideFences drops fenced code, since arithmetic in an example is not a
345// claim about a total.
346func outsideFences(s string) []string {
347	var out []string
348	fenced := false
349	for _, line := range strings.Split(s, "\n") {
350		t := strings.TrimSpace(line)
351		if strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") {
352			fenced = !fenced
353			continue
354		}
355		if !fenced {
356			out = append(out, line)
357		}
358	}
359	return out
360}
361
362func calcNudge() string {
363	return "You added those up yourself. Call calc with the figures and write the total it gives you, " +
364		"rather than the one you worked out. If some of the figures are missing, say which."
365}
366
367func researchNudge(query string) string {
368	q := strings.TrimSpace(query)
369	if q == "" {
370		return "That does not answer the question. Call a tool now and find out. " +
371			"Do not offer to look something up and do not say what you would do next, there is nobody to answer you."
372	}
373	return "That does not answer the question yet. Search for " + quoted(q) + " now, and keep going until you have the specifics. " +
374		"Do not offer to look something up and do not say what you would do next, there is nobody to answer you."
375}
376
377// What a draft contradicted by the snapshot is told. It names the tool and the
378// subject, since a model sent back without both goes and searches the web for
379// what is already on this machine.
380func wikiNudge(subject string) string {
381	s := strings.TrimSpace(subject)
382	if s == "" {
383		return "Part of that does not match what the offline Wikipedia says. Call wikipedia now, read it, and correct the answer."
384	}
385	return "Part of that does not match what the offline Wikipedia says about " + quoted(s) + ". " +
386		"Call wikipedia with " + quoted(s) + " now, read the article, and correct the answer rather than repeating it."
387}
388
389// What a rehash is told, which has to say why rather than just no. A model sent
390// back with the generic nudge writes the same answer a third time, since as far
391// as it can see it already has the material.
392func repeatNudge() string {
393	return "That is the answer you already gave, and it does not address what this question adds. " +
394		"The tool results from the earlier turns are gone and the pages behind them are not in front of you, so nothing there can be quoted or checked. " +
395		"Call a tool now and get what this question needs."
396}
397
398// Quoted so a multi word query is not read as part of the sentence around it.
399func quoted(s string) string { return "\"" + strings.ReplaceAll(s, "\"", "") + "\"" }
400
401// gate is the whole check, and it returns the nudge to send the turn back with
402// or an empty string to let the draft stand. The two free checks run first and
403// the model is only asked when neither fired, which is a second saved on every
404// one of the failures this exists for. previous is what this conversation has
405// already answered, most recent last.
406func (e *Engine) gate(ctx context.Context, question, draft string, previous []string, used []tools.Result, emit func(Event)) (string, Stats) {
407	// Sending a turn back to research when the search endpoint is in the
408	// penalty box is a guaranteed loop: it cannot succeed, and every pass costs
409	// a model call and another failed request against a host that is already
410	// refusing. Every nudge below that names a search is held back for that
411	// reason, and the local snapshot is not, since correcting a draft against a
412	// container on the bridge needs nothing that is refusing us.
413	_, searchDown := e.SearchDown()
414	if searchDown {
415		return e.gateOffline(ctx, question, draft, used, emit)
416	}
417	// A news rundown is finished when it arrives. The gate's own rules read a
418	// list of twenty stories as an answer that covers part of the question and
419	// leaves the rest, so left to itself it sends the turn back to research one
420	// of them and the rundown becomes a single story write up.
421	if calledNews(used) {
422		return "", Stats{}
423	}
424	// Same for a turn that was told to remember something and did. There is no
425	// question under it to research, and the gate reading it as one sent the
426	// turn off to look up the film again and answer with where to stream it,
427	// having stored nothing.
428	if calledTool(used, tools.Remember.Name) {
429		return "", Stats{}
430	}
431	// Asked of the question and before anything reads the draft, since the
432	// failure this catches is a draft that sounds like an answer. A turn that
433	// already fetched something is left alone, because the question needing
434	// current information is only a problem when nothing went and got it.
435	var st Stats
436	if len(used) == 0 {
437		f, fst := e.needsFresh(ctx, question)
438		st.merge(fst)
439		if f.NeedsFresh {
440			emit(Event{Kind: "status", Text: "looking it up"})
441			return researchNudge(f.Query), st
442		}
443	}
444	if isDeferral(draft) || refusal.MatchString(draft) {
445		emit(Event{Kind: "status", Text: "looking it up"})
446		return researchNudge(""), st
447	}
448	// Only when nothing was fetched. A turn that did the work and then restated
449	// some of what it said before has answered, and taking it away would cost
450	// the tool calls it already spent.
451	if len(used) == 0 && repeatsAnswered(draft, previous) {
452		emit(Event{Kind: "status", Text: "looking it up"})
453		return repeatNudge(), st
454	}
455	// Before the model check, since it costs nothing and the model check has
456	// never once objected to a wrong sum.
457	if !calledTool(used, tools.Calc.Name) && countsUpInProse(draft) {
458		emit(Event{Kind: "status", Text: "adding it up"})
459		return calcNudge(), st
460	}
461	// A draft written from memory that states specifics. The freshness check
462	// above only catches what changes over time, and the specifics that were
463	// wrong were mostly things that do not: a rifle that does not exist, a
464	// protein figure off by eighteen grams.
465	if len(used) == 0 {
466		g, gst := e.needsChecking(ctx, draft)
467		st.merge(gst)
468		if g.NeedsCheck {
469			emit(Event{Kind: "status", Text: "checking it"})
470			return researchNudge(g.Query), st
471		}
472	}
473	emit(Event{Kind: "status", Text: "checking the answer"})
474	// Only when the turn fetched nothing, since that is the case the gate has
475	// no evidence for. A turn that called tools already gave it something to
476	// work with, and a second opinion there would argue with what was fetched.
477	var background string
478	if len(used) == 0 {
479		background = e.background(ctx, question)
480	}
481	results := make([]string, 0, len(used))
482	for _, r := range used {
483		if r.Err != "" {
484			results = append(results, r.Name+" failed: "+r.Err)
485			continue
486		}
487		body, _ := json.Marshal(r.Content)
488		results = append(results, r.Name+": "+string(body))
489	}
490	v, est := e.enough(ctx, question, draft, background, results, previous)
491	st.merge(est)
492	if v.Verdict != "research" {
493		return "", st
494	}
495	emit(Event{Kind: "status", Text: "looking it up"})
496	// The snapshot already has the article, so sending it to a web search for
497	// something a local call answers in milliseconds is the slower way to be
498	// right.
499	if background != "" {
500		return wikiNudge(subjectOf(question)), st
501	}
502	return researchNudge(v.Query), st
503}
504
505// gateOffline is the gate with the search host refusing us. The only thing that
506// can be acted on is a draft the local snapshot disagrees with, so that is the
507// only thing checked, and anything else is let through as the honest answer the
508// turn managed.
509func (e *Engine) gateOffline(ctx context.Context, question, draft string, used []tools.Result, emit func(Event)) (string, Stats) {
510	if len(used) > 0 {
511		return "", Stats{}
512	}
513	background := e.background(ctx, question)
514	if background == "" {
515		return "", Stats{}
516	}
517	emit(Event{Kind: "status", Text: "checking the answer"})
518	v, st := e.enough(ctx, question, draft, background, nil, nil)
519	if v.Verdict != "research" {
520		return "", st
521	}
522	emit(Event{Kind: "status", Text: "looking it up"})
523	return wikiNudge(subjectOf(question)), st
524}
525
526func calledNews(used []tools.Result) bool { return calledTool(used, tools.News.Name) }
527
528func calledTool(used []tools.Result, name string) bool {
529	for _, r := range used {
530		if r.Name == name && r.Err == "" {
531			return true
532		}
533	}
534	return false
535}