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

37.5 KB · 1098 lines · Go Raw History
   1package main
   2
   3import (
   4	"context"
   5	"fmt"
   6	"log/slog"
   7	"net/http"
   8	"sort"
   9	"strings"
  10	"sync"
  11	"time"
  12
  13	"search.bythewood.me/skills"
  14)
  15
  16const (
  17	serpTTL    = 6 * time.Hour
  18	pageTTL    = 14 * 24 * time.Hour
  19	maxSources = 5
  20
  21	// Below this share of cited sentences holding up, the answer is treated as
  22	// wrong rather than imperfect, and the pipeline searches again with what it
  23	// learned. Most of the answer failing means the search found the wrong
  24	// pages, which no amount of rewriting fixes.
  25	retryBelowSupport = 0.5
  26)
  27
  28// Source is one document that made it into the answer's evidence.
  29type Source struct {
  30	N         int
  31	URL       string
  32	Title     string
  33	Site      string
  34	Published string
  35	FromCache bool
  36}
  37
  38// Passage is a numbered piece of evidence. The model is shown these IDs and
  39// never a URL, so a citation it emits always resolves back to real text.
  40type Passage struct {
  41	ID     int
  42	Source int
  43	Text   string
  44}
  45
  46// Citation records the validation verdict for one sentence of the answer.
  47type Citation struct {
  48	Sentence  string
  49	PassageID int
  50	Source    int
  51	Supported bool
  52	Checked   bool
  53	// Repaired names the passage a failing claim was re-pointed at, when a
  54	// different passage turned out to support it.
  55	Repaired int
  56	Note     string
  57}
  58
  59// Answer is the finished product.
  60type Answer struct {
  61	Query      string
  62	Standalone string
  63	Shape      Shape
  64	Text       string
  65	HTML       string
  66	Sources    []Source
  67	Passages   []Passage
  68	Citations  []Citation
  69	Queries    []string
  70	Elapsed    string
  71	Warnings   []string
  72	Retried    bool
  73	Support    float64
  74
  75	// Skill names the handler that answered, when something other than a web
  76	// search did. Empty means the normal pipeline.
  77	Skill string
  78
  79	// Links are checked URLs for the things the answer named, which the source
  80	// list does not cover: sources say where the research came from, not where
  81	// the subject lives.
  82	Links []EntityLink
  83
  84	// Checks and Deps are the code shape's own verification, since a file that
  85	// does not parse and a package that does not exist are what go wrong in an
  86	// answer somebody is going to paste.
  87	Checks []CodeCheck
  88	Deps   []Dependency
  89}
  90
  91// Progress reports pipeline steps to whoever is watching. The web handler wires
  92// it to an SSE stream; tests leave it nil.
  93type Progress func(step, detail string)
  94
  95func (p Progress) send(step, detail string) {
  96	if p != nil {
  97		p(step, detail)
  98	}
  99}
 100
 101// Engine runs the pipeline.
 102type Engine struct {
 103	store  *Store
 104	llm    *LLM
 105	client *http.Client
 106	budget *Budget
 107	skills *skills.Registry
 108}
 109
 110func NewEngine(store *Store, llm *LLM, budget *Budget) *Engine {
 111	return &Engine{
 112		store: store, llm: llm, client: newHTTPClient(),
 113		budget: budget, skills: skills.Default(),
 114	}
 115}
 116
 117func (e *Engine) skillDeps() skills.Deps {
 118	return skills.Deps{HTTP: e.client, UA: browserUA, Now: localNow}
 119}
 120
 121// skillAnswer converts a skill result into the answer the rest of the site
 122// expects. Rendering happens here rather than in the skill, since the template
 123// and its renderer belong to this package.
 124func skillAnswer(question, standalone string, r *skills.Result, start time.Time) *Answer {
 125	a := &Answer{
 126		Query: question, Standalone: standalone, Skill: r.Skill,
 127		Shape: Shape(r.Shape), Text: r.Text, HTML: renderMarkdown(r.Text),
 128		Support: 1,
 129		Elapsed: time.Since(start).Round(10 * time.Millisecond).String(),
 130	}
 131	if a.Shape == "" {
 132		a.Shape = ShapeFactual
 133	}
 134	for i, src := range r.Sources {
 135		a.Sources = append(a.Sources, Source{
 136			N: i + 1, URL: src.URL, Title: src.Title, Site: src.Site,
 137		})
 138	}
 139	return a
 140}
 141
 142// Run executes the pipeline: plan, gather, select, synthesize, validate, and
 143// retry once if the answer did not hold up.
 144func (e *Engine) Run(ctx context.Context, question string, history []Turn, pr Progress) (*Answer, error) {
 145	start := time.Now()
 146
 147	// The model loads while the search and the fetches are in flight.
 148	go e.llm.Warm(ctx)
 149
 150	// A follow-up is resolved before anything else reads it, the router
 151	// included. "odds on the match" names no event, no team and no date, so a
 152	// router shown those words can only answer none, and a follow-up could
 153	// never reach a skill however plainly it asked for one. The rewrite is what
 154	// turns it into a question a card can match.
 155	standalone := question
 156	if len(history) > 0 {
 157		pr.send("followup", "reading the previous answer")
 158		standalone = e.rewriteFollowup(ctx, question, history)
 159		standalone = keepURLs(question, standalone)
 160	}
 161
 162	// Routing is the classification a 4B is reliably good at. The skill names
 163	// are an enum in the grammar rather than an instruction in the prompt, so
 164	// the model cannot name a handler that does not exist, and every card
 165	// carries the near misses that should not fire it as well as the phrasings
 166	// that should.
 167	//
 168	// A keyword matcher used to do this and it was wrong in both directions,
 169	// claiming "what is the S&P 500" for the quote skill and missing "do i need
 170	// a jacket today" entirely. The matcher survives inside the registry as the
 171	// fallback for when the model is unreachable.
 172	pr.send("route", "working out what kind of question this is")
 173	if res, name := e.skills.Run(ctx, e.llm, standalone, e.skillDeps()); res != nil {
 174		// A skill that returns addresses found where the answer is rather than
 175		// what it says, so the rest of the pipeline runs over those pages
 176		// instead of over a search.
 177		if len(res.URLs) > 0 {
 178			pr.send("skill", "reading "+listHosts(res.URLs))
 179			a, err := e.readGiven(ctx, question, standalone, res, start, pr)
 180			if err == nil {
 181				return a, nil
 182			}
 183			// A page behind a login or a wall is common enough that falling
 184			// back to a search beats an error, and the warning says which
 185			// happened.
 186			slog.Warn("given page unreadable, searching instead", slog.Any("err", err))
 187			pr.send("skill", "could not read that page, searching instead")
 188			ans := &Answer{Query: question, Standalone: standalone}
 189			ans.Warnings = append(ans.Warnings, fmt.Sprintf(
 190				"%s could not be read, so this answers from a search rather than from the page you gave", listHosts(res.URLs)))
 191			return e.search(ctx, ans, start, pr)
 192		}
 193		pr.send("skill", "answered from "+name)
 194		return skillAnswer(question, standalone, res, start), nil
 195	}
 196
 197	return e.search(ctx, &Answer{Query: question, Standalone: standalone}, start, pr)
 198}
 199
 200// readGiven answers from the pages the question named. Everything after the
 201// gathering is the normal pipeline, so the answer is written to a contract,
 202// every sentence is checked against the passage it cites, and the page is
 203// fetched through the same cache and the same browser headers a search result
 204// is.
 205func (e *Engine) readGiven(ctx context.Context, question, standalone string, res *skills.Result, start time.Time, pr Progress) (*Answer, error) {
 206	ans := &Answer{Query: question, Standalone: standalone, Shape: ShapeSummary, Skill: res.Skill}
 207	given := make([]Result, 0, len(res.URLs))
 208	for _, u := range res.URLs {
 209		given = append(given, Result{URL: u, Title: u})
 210	}
 211	if err := e.answerFrom(ctx, ans, given, contractFor(ShapeSummary), pr); err != nil {
 212		return nil, err
 213	}
 214	ans.HTML = renderMarkdown(ans.Text)
 215	ans.Elapsed = time.Since(start).Round(100 * time.Millisecond).String()
 216	return ans, nil
 217}
 218
 219// search is the pipeline proper: plan, gather, and one self-correction round.
 220func (e *Engine) search(ctx context.Context, ans *Answer, start time.Time, pr Progress) (*Answer, error) {
 221	question, standalone := ans.Query, ans.Standalone
 222	// A warning raised before the search, such as the page the question named
 223	// being unreadable, has to survive a retry throwing this answer away.
 224	carried := append([]string(nil), ans.Warnings...)
 225
 226	pr.send("plan", "working out what to search for")
 227	plan := e.plan(ctx, standalone, "")
 228	ans.Shape = plan.Shape
 229	ans.Queries = plan.Queries
 230	contract := contractFor(plan.Shape)
 231	pr.send("plan", fmt.Sprintf("%s question, searching: %s", plan.Shape, strings.Join(plan.Queries, " / ")))
 232
 233	if err := e.round(ctx, ans, plan, contract, pr); err != nil {
 234		return nil, err
 235	}
 236
 237	// One self-correction round. A mostly-unsupported answer is evidence the
 238	// search missed, so the retry re-plans with the failure described rather
 239	// than rewording the same evidence.
 240	//
 241	// A code answer is judged on the code. Its prose is three caveats, so one
 242	// mis-cited caveat drops the support rate under the floor and would throw
 243	// away a file that parsed, and a file that did not parse is worth a second
 244	// search however well the caveats held up.
 245	var badCode bool
 246	if ans.Shape == ShapeCode {
 247		badCode = codeFailed(ans.Checks, ans.Deps)
 248	}
 249	// The support rate cannot see this one. Every sentence about a match that
 250	// was already played is true and cited and holds up, and the answer is
 251	// still not the one that was asked for, so the second search is triggered
 252	// by the dates rather than by the validator.
 253	var lookedBack bool
 254	if ans.Shape == ShapeUpcoming {
 255		lookedBack = noFutureDate(ans.Text, localNow())
 256	}
 257	if badCode || lookedBack || (ans.Shape != ShapeCode && ans.Support < retryBelowSupport && len(ans.Citations) > 0) {
 258		hint := e.failureHint(ans)
 259		if badCode {
 260			pr.send("retry", "the code did not hold up, searching again")
 261			hint = codeHint(ans.Checks, ans.Deps)
 262		} else if lookedBack {
 263			pr.send("retry", "that only found things that have already happened, searching again")
 264			hint = upcomingHint()
 265		} else {
 266			pr.send("retry", fmt.Sprintf("only %.0f%% of that held up, searching again", ans.Support*100))
 267		}
 268		retryPlan := e.plan(ctx, standalone, hint)
 269		// The retry keeps the first plan's shape, since the failure was in the
 270		// answer and re-classifying can only move a code question off the
 271		// contract that was right for it.
 272		retryPlan.Shape = ans.Shape
 273		retry := &Answer{Query: question, Standalone: standalone, Shape: retryPlan.Shape,
 274			Queries: retryPlan.Queries, Retried: true, Warnings: carried}
 275		if err := e.round(ctx, retry, retryPlan, contractFor(retryPlan.Shape), pr); err == nil && betterAnswer(retry, ans, badCode, lookedBack) {
 276			if badCode {
 277				retry.Warnings = append(retry.Warnings, "the first attempt was thrown away because its code did not check out")
 278			} else if lookedBack {
 279				retry.Warnings = append(retry.Warnings, "the first attempt only found things that had already happened, so it was searched again")
 280			} else {
 281				retry.Warnings = append(retry.Warnings,
 282					fmt.Sprintf("first attempt was rejected, %.0f%% of its sentences were unsupported", (1-ans.Support)*100))
 283			}
 284			ans = retry
 285		}
 286	}
 287
 288	ans.HTML = renderMarkdown(ans.Text)
 289	ans.Elapsed = time.Since(start).Round(100 * time.Millisecond).String()
 290	return ans, nil
 291}
 292
 293// round is one full attempt: gather, select, synthesize, validate, repair.
 294func (e *Engine) round(ctx context.Context, ans *Answer, plan Plan, contract Contract, pr Progress) error {
 295	pr.send("search", fmt.Sprintf("running %d search%s",
 296		len(plan.Queries), map[bool]string{true: "", false: "es"}[len(plan.Queries) == 1]))
 297	results := e.gather(ctx, plan.Queries, ans, pr)
 298	if len(results) == 0 {
 299		if len(ans.Warnings) > 0 {
 300			return fmt.Errorf("%s", ans.Warnings[0])
 301		}
 302		return fmt.Errorf("no search results for that question")
 303	}
 304	return e.answerFrom(ctx, ans, results, contract, pr)
 305}
 306
 307// answerFrom is everything after the evidence is chosen: fetch, select,
 308// synthesize, validate, repair. It does not care whether the pages came from a
 309// search or from the question.
 310func (e *Engine) answerFrom(ctx context.Context, ans *Answer, results []Result, contract Contract, pr Progress) error {
 311	pr.send("fetch", fmt.Sprintf("reading %d pages", min(len(results), maxSources*2)))
 312	sources, passages, links := e.collect(ctx, results, ans.Standalone, contract, pr)
 313	if len(passages) == 0 {
 314		return fmt.Errorf("nothing readable found for that question")
 315	}
 316	ans.Sources, ans.Passages = sources, passages
 317
 318	pr.send("write", "writing the answer")
 319	text, err := e.synthesize(ctx, ans.Standalone, passages, contract)
 320	if err != nil {
 321		return err
 322	}
 323	ans.Text = strings.TrimSpace(dropMeta(dropMissingFields(tidyCitations(text))))
 324	if contract.Shape == ShapeCode {
 325		pr.send("code", "parsing every file and looking up what it imports")
 326		// Before anything reads the answer, since a marker left in a file is
 327		// pasted into it and the fix has to happen once, at the source.
 328		ans.Text = stripCodeCitations(ans.Text)
 329		blocks := codeBlocks(ans.Text)
 330		// After the blocks are read, since that is where the file name comes
 331		// from when the model wrote it as a comment.
 332		ans.Text = liftFileComments(ans.Text)
 333		ans.Checks = checkCode(blocks)
 334		ans.Warnings = append(ans.Warnings, codeWarnings(ans.Checks)...)
 335		ans.Warnings = append(ans.Warnings, unusedConstants(blocks)...)
 336		if len(blocks) == 0 {
 337			ans.Warnings = append(ans.Warnings, "this came back with no code in it, so it is an explanation rather than something to paste")
 338		}
 339	}
 340	// Only the time sensitive shapes, since "scheduled for April 2026" in a
 341	// recipe is not a claim about now.
 342	if contract.Shape == ShapeStatus || contract.Shape == ShapeNews || contract.Shape == ShapeUpcoming {
 343		now := localNow()
 344		ans.Warnings = append(ans.Warnings, staleFutures(ans.Text, now)...)
 345		ans.Warnings = append(ans.Warnings, staleNow(ans.Text, now)...)
 346		if contract.Shape == ShapeUpcoming {
 347			ans.Warnings = append(ans.Warnings, pastOnly(ans.Text, now)...)
 348			ans.Warnings = append(ans.Warnings, sooner(ans.Text, passages, now)...)
 349			ans.Warnings = append(ans.Warnings, scheduleAge(ans.Sources, now)...)
 350		}
 351	}
 352
 353	// Linking runs alongside validation rather than after it. Neither needs the
 354	// other's result and both are several model or network calls.
 355	var wg sync.WaitGroup
 356	wg.Add(1)
 357	go func() {
 358		defer wg.Done()
 359		ans.Links = e.linkEntities(ctx, ans.Standalone, text, links)
 360	}()
 361	if contract.Shape == ShapeCode {
 362		wg.Add(1)
 363		go func() {
 364			defer wg.Done()
 365			ans.Deps = verifyDeps(ctx, e.client, codeBlocks(ans.Text))
 366		}()
 367	}
 368
 369	pr.send("check", "checking every sentence against its source")
 370	// The prose only, because a line of code is not a claim: entailing it costs
 371	// a model call that can only fail, and `rows[0]` reads as a citation.
 372	ans.Citations = e.validate(ctx, proseOnly(ans.Text), passages)
 373	ans.Support = supportRate(ans.Citations)
 374	// An answer with nothing to check renders without the verification panel,
 375	// which looks the same as an answer that had nothing wrong with it. On a
 376	// site whose claim is that every sentence is checked, silence is the
 377	// misleading option, so it says so instead.
 378	if countChecked(ans.Citations) == 0 && strings.TrimSpace(ans.Text) != "" {
 379		note := "nothing in this answer carries a citation, so none of it was checked against a source"
 380		if contract.Shape == ShapeCode {
 381			// The code itself was parsed and its packages looked up, so saying
 382			// nothing was checked would be wrong in the other direction.
 383			note = "the writing around the code carries no citation, so only the code itself was checked"
 384		}
 385		ans.Warnings = append(ans.Warnings, note)
 386	}
 387
 388	wg.Wait()
 389	ans.Warnings = append(ans.Warnings, depWarnings(ans.Deps)...)
 390	if len(ans.Links) > 0 {
 391		pr.send("check", fmt.Sprintf("found %d verified link%s", len(ans.Links),
 392			map[bool]string{true: "", false: "s"}[len(ans.Links) == 1]))
 393	}
 394	return nil
 395}
 396
 397// betterAnswer decides whether the second attempt replaces the first. For code
 398// that is whether it fixed what was broken, and for everything else whether
 399// more of it held up.
 400func betterAnswer(retry, first *Answer, wasCode, lookedBack bool) bool {
 401	switch {
 402	case wasCode:
 403		return !codeFailed(retry.Checks, retry.Deps) && len(retry.Checks) > 0
 404	case lookedBack:
 405		return !noFutureDate(retry.Text, localNow())
 406	}
 407	return retry.Support > first.Support
 408}
 409
 410func countChecked(cs []Citation) int {
 411	n := 0
 412	for _, c := range cs {
 413		if c.Checked {
 414			n++
 415		}
 416	}
 417	return n
 418}
 419
 420func supportRate(cs []Citation) float64 {
 421	checked, ok := 0, 0
 422	for _, c := range cs {
 423		if !c.Checked {
 424			continue
 425		}
 426		checked++
 427		if c.Supported {
 428			ok++
 429		}
 430	}
 431	// Nothing checked is not the same as nothing wrong, but the caller warns
 432	// about that case rather than reading a rate that has no denominator.
 433	if checked == 0 {
 434		return 1
 435	}
 436	return float64(ok) / float64(checked)
 437}
 438
 439// failureHint describes what went wrong so the second plan does not repeat the
 440// first one's mistake.
 441func (e *Engine) failureHint(ans *Answer) string {
 442	var bad []string
 443	for _, c := range ans.Citations {
 444		if c.Checked && !c.Supported {
 445			bad = append(bad, stripCitations(c.Sentence))
 446		}
 447		if len(bad) >= 3 {
 448			break
 449		}
 450	}
 451	return fmt.Sprintf(
 452		"A previous search used the queries %q and the pages it found did not support the answer. "+
 453			"These claims could not be backed by anything found: %s. "+
 454			"Write different queries that would find pages actually stating the answer.",
 455		strings.Join(ans.Queries, ", "), strings.Join(bad, " | "))
 456}
 457
 458// Plan is the output of the planning step.
 459type Plan struct {
 460	Queries []string `json:"queries"`
 461	Shape   Shape    `json:"shape"`
 462}
 463
 464func (e *Engine) plan(ctx context.Context, question, hint string) Plan {
 465	schema := map[string]any{
 466		"type": "object",
 467		"properties": map[string]any{
 468			"queries": map[string]any{
 469				"type": "array", "items": map[string]any{"type": "string"},
 470				"minItems": 1, "maxItems": 3,
 471			},
 472			"shape": map[string]any{
 473				"type": "string",
 474				"enum": shapeNames(),
 475			},
 476		},
 477		"required":             []string{"queries", "shape"},
 478		"additionalProperties": false,
 479	}
 480	system := strings.Join([]string{
 481		AmbientContext(),
 482		"You plan a web search. Return short keyword queries that would find pages answering the question, and the shape of answer it wants.",
 483		"recipe: the user wants something they can cook from. comparison: two or more options weighed.",
 484		"code: the user wants something they can paste into a file and run, such as a script, a program, a config file, a query or a command. Anything asking to write, build or give code is this. So is a question about how or where to do something on a computer whose answer is a command, a query or a config, even when it is worded as the best way to do it, because what that reader wants is the command.",
 485		"Write code queries naming the language, the library, the platform and the version, and prefer official documentation over roundups.",
 486		"howto: ordered steps a person carries out in an interface or on hardware, where nothing gets typed into a file.",
 487		"news: something that already happened, including sports results and recent events.",
 488		"upcoming: the user wants the date or time of something that has not happened yet, such as the next match, a launch, a release or when a thing starts. Anything asking when the next one is, or what is coming up, is this rather than news.",
 489		"factual: everything else.",
 490		"For news, write queries that would find what happened, using words like result, final score, or the current month and year, not words like schedule, fixtures or upcoming.",
 491		"For upcoming, write the opposite: queries carrying schedule, fixtures, upcoming or next along with the current month and year, since a query about a result finds the match before the one being asked about.",
 492		"A team plays in more than one competition, so one of those queries asks for the next fixture in any competition rather than naming a league, since a league schedule leaves the cup out and calls its own next match the next match.",
 493		"status: the user wants to know where an ongoing thing stands now, such as a court case, an investigation or a rollout. Write queries carrying the current month and year so the newest coverage is found rather than the first report.",
 494		"No sentences, no quotes, no search operators.",
 495	}, " ")
 496	user := question
 497	if hint != "" {
 498		user = question + "\n\n" + hint
 499	}
 500
 501	var out Plan
 502	if err := e.llm.Structured(ctx, system, user, 250, schema, &out); err != nil || len(out.Queries) == 0 {
 503		slog.Warn("plan failed, falling back", slog.Any("err", err))
 504		return Plan{Queries: []string{question}, Shape: guessShape(question)}
 505	}
 506	for i, q := range out.Queries {
 507		out.Queries[i] = strings.TrimSpace(q)
 508	}
 509	if out.Shape == "" {
 510		out.Shape = guessShape(question)
 511	}
 512	return out
 513}
 514
 515// rewriteFollowup turns "what about the vegetarian version" into a question
 516// that stands on its own, because the search engine has no conversation.
 517func (e *Engine) rewriteFollowup(ctx context.Context, question string, history []Turn) string {
 518	schema := map[string]any{
 519		"type":                 "object",
 520		"properties":           map[string]any{"question": map[string]any{"type": "string"}},
 521		"required":             []string{"question"},
 522		"additionalProperties": false,
 523	}
 524	var b strings.Builder
 525	for _, t := range history {
 526		fmt.Fprintf(&b, "Q: %s\nA: %s\n\n", t.Question, truncate(t.Answer, 700))
 527	}
 528	var out struct {
 529		Question string `json:"question"`
 530	}
 531	err := e.llm.Structured(ctx,
 532		AmbientContext()+" Rewrite the user's follow-up into one standalone question that carries over whatever it refers to from the conversation. Keep it short. If it already stands alone, return it unchanged.",
 533		fmt.Sprintf("Conversation so far:\n\n%sFollow-up: %s", b.String(), question),
 534		200, schema, &out)
 535	if err != nil || strings.TrimSpace(out.Question) == "" {
 536		return question
 537	}
 538	return strings.TrimSpace(out.Question)
 539}
 540
 541func (e *Engine) gather(ctx context.Context, queries []string, ans *Answer, pr Progress) []Result {
 542	var all []Result
 543	seen := map[string]bool{}
 544	for _, q := range queries {
 545		results := e.store.CachedSERP(q, serpTTL)
 546		if results == nil {
 547			// A cached query costs nothing, so the budget only guards the ones
 548			// that actually leave the house.
 549			if st := e.budget.State(); st.Left == 0 {
 550				ans.Warnings = append(ans.Warnings, fmt.Sprintf(
 551					"search allowance is spent, skipped %q (room opens in about %ds)", q, st.ResetIn))
 552				continue
 553			}
 554			// Only uncached queries pace, so a repeat question still returns at
 555			// once. The wait is reported because a silent pause looks like a
 556			// hang, and this one is deliberate.
 557			if waited := e.budget.Wait(ctx); waited > 500*time.Millisecond {
 558				pr.send("search", fmt.Sprintf("paused %.1fs between searches", waited.Seconds()))
 559			}
 560			if ctx.Err() != nil {
 561				return all
 562			}
 563			e.budget.Spend()
 564			var err error
 565			results, err = SearchDDG(e.client, q, 8)
 566			if err != nil {
 567				if err == errRateLimited {
 568					e.budget.Limited()
 569				}
 570				ans.Warnings = append(ans.Warnings, fmt.Sprintf("search %q: %v", q, err))
 571				continue
 572			}
 573			if err := e.store.PutSERP(q, results); err != nil {
 574				slog.Warn("serp cache write", slog.Any("err", err))
 575			}
 576		}
 577		for _, r := range results {
 578			if !seen[r.URL] {
 579				seen[r.URL] = true
 580				all = append(all, r)
 581			}
 582		}
 583	}
 584	return all
 585}
 586
 587// collect fetches pages in parallel, then picks the chunks that answer the
 588// question rather than the ones that happen to come first.
 589func (e *Engine) collect(ctx context.Context, results []Result, question string, contract Contract, pr Progress) ([]Source, []Passage, []Link) {
 590	if len(results) > maxSources*2 {
 591		results = results[:maxSources*2]
 592	}
 593
 594	type fetched struct {
 595		idx    int
 596		page   *Page
 597		id     int64
 598		cached bool
 599	}
 600	var (
 601		mu   sync.Mutex
 602		got  []fetched
 603		wg   sync.WaitGroup
 604		sema = make(chan struct{}, 5)
 605		done int
 606	)
 607	for i, r := range results {
 608		wg.Add(1)
 609		go func(i int, r Result) {
 610			defer wg.Done()
 611			sema <- struct{}{}
 612			defer func() { <-sema }()
 613
 614			var (
 615				p      *Page
 616				id     int64
 617				cached bool
 618			)
 619			if hit := e.store.CachedPage(r.URL, pageTTL); hit != nil {
 620				p, id, cached = hit, e.store.PageID(r.URL), true
 621			} else {
 622				var err error
 623				p, err = Fetch(e.client, r.URL)
 624				if err != nil {
 625					slog.Debug("fetch skipped", slog.String("url", r.URL), slog.Any("err", err))
 626					return
 627				}
 628				if id, err = e.store.PutPage(p); err != nil {
 629					slog.Warn("page cache write", slog.Any("err", err))
 630				}
 631			}
 632			mu.Lock()
 633			got = append(got, fetched{i, p, id, cached})
 634			done++
 635			pr.send("fetch", fmt.Sprintf("read %s", hostname(r.URL)))
 636			mu.Unlock()
 637		}(i, r)
 638	}
 639	wg.Wait()
 640
 641	// Search order normally, but two shapes have a better ordering than the
 642	// search engine's.
 643	switch {
 644	// A status question is answered by whichever source is newest, so a dated
 645	// page outranks a well ranked stale one.
 646	// A schedule is the same, since an old page lists a fixture that has since
 647	// moved and a new one lists the one being asked about.
 648	case contract.Shape == ShapeStatus || contract.Shape == ShapeUpcoming:
 649		sort.SliceStable(got, func(a, b int) bool {
 650			return got[a].page.Published > got[b].page.Published
 651		})
 652	// "breakfast burrito recipe" is the exact phrase every roundup is written
 653	// to rank for, so the first page of results is listicles naming twelve
 654	// recipes and giving the quantities for none. A page publishing a
 655	// schema.org Recipe is an actual recipe, and it is the only place the
 656	// quantities exist, so it goes first whatever the search engine thought.
 657	// A page with no code on it cannot show how the code is written, whatever
 658	// it ranks for, and the first page of results for anything with a language
 659	// name in it is content marketing.
 660	case contract.Shape == ShapeCode:
 661		sort.SliceStable(got, func(a, b int) bool {
 662			ca, cb := codeWeight(got[a].page), codeWeight(got[b].page)
 663			if ca != cb {
 664				return ca > cb
 665			}
 666			return got[a].idx < got[b].idx
 667		})
 668	case contract.Shape == ShapeRecipe:
 669		sort.SliceStable(got, func(a, b int) bool {
 670			ra, rb := hasStructuredRecipe(got[a].page), hasStructuredRecipe(got[b].page)
 671			if ra != rb {
 672				return ra
 673			}
 674			return got[a].idx < got[b].idx
 675		})
 676	default:
 677		sort.Slice(got, func(a, b int) bool { return got[a].idx < got[b].idx })
 678	}
 679	if len(got) > maxSources {
 680		got = got[:maxSources]
 681	}
 682
 683	var sources []Source
 684	var passages []Passage
 685	var links []Link
 686	for i, f := range got {
 687		n := i + 1
 688		links = append(links, e.store.PageLinks(f.id)...)
 689		sources = append(sources, Source{
 690			N: n, URL: f.page.URL, Title: f.page.Title,
 691			Site: f.page.Site, Published: f.page.Published, FromCache: f.cached,
 692		})
 693
 694		// Relevance first, document order as the fallback when the question's
 695		// words do not appear (which happens on pages that answer it anyway).
 696		// A summary is the exception: it follows the page from the top, and
 697		// ranking it against "summary of this" plus an address would shuffle
 698		// the page into the order of words that are not about anything.
 699		var chunks []string
 700		if contract.Shape == ShapeSummary {
 701			chunks = e.store.PageChunks(f.id, contract.PerSource)
 702		} else if chunks = e.store.RankPassages(f.id, question, contract.PerSource); len(chunks) == 0 {
 703			chunks = e.store.PageChunks(f.id, contract.PerSource)
 704		}
 705		for _, text := range chunks {
 706			passages = append(passages, Passage{ID: len(passages) + 1, Source: n, Text: text})
 707		}
 708	}
 709	if len(passages) > contract.MaxPassages {
 710		passages = passages[:contract.MaxPassages]
 711	}
 712	return sources, passages, links
 713}
 714
 715func (e *Engine) synthesize(ctx context.Context, question string, passages []Passage, contract Contract) (string, error) {
 716	var b strings.Builder
 717	for _, p := range passages {
 718		fmt.Fprintf(&b, "[%d] %s\n\n", p.ID, truncate(p.Text, 1800))
 719	}
 720	system := strings.Join([]string{
 721		AmbientContext(),
 722		"You answer using only the numbered passages provided.",
 723		"Cite every factual sentence with the passage number it came from, like [3]. A sentence may carry more than one.",
 724		"Never state anything the passages do not support, and never fill a gap from your own knowledge.",
 725		"If the passages do not answer the question that was asked, say so in the first sentence and then say what they do cover. Do not answer a nearby question instead.",
 726		"Never put a citation on a statement that something is missing, unknown or not specified, and prefer leaving that line out entirely.",
 727		"Write markdown. Use **bold** for the facts worth skimming.",
 728		contract.Instruction,
 729	}, " ")
 730	user := fmt.Sprintf("Passages:\n\n%s\nQuestion: %s", b.String(), question)
 731	if contract.Reminder != "" {
 732		user += "\n\n" + contract.Reminder
 733	}
 734	return e.llm.Complete(ctx, system, user, contract.MaxTokens)
 735}
 736
 737// validate checks each cited sentence against the passage it cites, then tries
 738// to repair the ones that fail by looking for a passage that does support them.
 739func (e *Engine) validate(ctx context.Context, text string, passages []Passage) []Citation {
 740	byID := map[int]Passage{}
 741	for _, p := range passages {
 742		byID[p.ID] = p
 743	}
 744
 745	var out []Citation
 746	for _, sentence := range splitClaims(text) {
 747		ids := citedIDs(sentence)
 748		if len(ids) == 0 {
 749			continue
 750		}
 751		claim := stripCitations(sentence)
 752		for _, id := range ids {
 753			p, ok := byID[id]
 754			if !ok {
 755				out = append(out, Citation{
 756					Sentence: plainText(sentence), PassageID: id, Checked: true,
 757					Note: "cites a passage that does not exist",
 758				})
 759				continue
 760			}
 761			c := Citation{Sentence: plainText(sentence), PassageID: id, Source: p.Source}
 762			supported, err := e.entails(ctx, p.Text, claim)
 763			if err == nil {
 764				c.Checked = true
 765				c.Supported = supported
 766			}
 767			if c.Checked && !c.Supported {
 768				// The claim may be true and merely mis-cited, which is a
 769				// different problem from an invented one and worth telling
 770				// apart.
 771				if alt := e.findSupport(ctx, claim, passages, id); alt > 0 {
 772					c.Supported = true
 773					c.Repaired = alt
 774					c.Note = fmt.Sprintf("cited [%d] but [%d] is what supports it", id, alt)
 775				} else {
 776					c.Note = "no fetched passage states this"
 777				}
 778			}
 779			out = append(out, c)
 780		}
 781	}
 782	// An answer that cited nothing at all falls straight through the loop
 783	// above and renders as an answer nobody checked, which on this site reads
 784	// the same as an answer nothing was wrong with. A short answer is where
 785	// this happens, since one sentence carries every citation the model was
 786	// going to write and sometimes it writes none, so each claim gets a pass
 787	// through findSupport instead. The note says the citation was found rather
 788	// than written, because the difference matters.
 789	if len(out) == 0 {
 790		for _, sentence := range splitClaims(text) {
 791			claim := stripCitations(sentence)
 792			if len(contentWords(claim)) < 3 {
 793				continue
 794			}
 795			c := Citation{Sentence: plainText(sentence), Checked: true}
 796			if id := e.findSupport(ctx, claim, passages, 0); id > 0 {
 797				c.PassageID, c.Source, c.Supported, c.Repaired = id, byID[id].Source, true, id
 798				c.Note = fmt.Sprintf("no citation was written, [%d] is what states it", id)
 799			} else {
 800				c.Note = "no citation was written and no passage states this"
 801			}
 802			out = append(out, c)
 803		}
 804	}
 805	return out
 806}
 807
 808func (e *Engine) entails(ctx context.Context, passage, claim string) (bool, error) {
 809	schema := map[string]any{
 810		"type":                 "object",
 811		"properties":           map[string]any{"supported": map[string]any{"type": "boolean"}},
 812		"required":             []string{"supported"},
 813		"additionalProperties": false,
 814	}
 815	var v struct {
 816		Supported bool `json:"supported"`
 817	}
 818	err := e.llm.Structured(ctx,
 819		strings.Join([]string{
 820			"You check whether a passage supports a claim.",
 821			"Answer true if the passage states the claim, directly implies it, or describes the same step or item in different words.",
 822			"A paraphrase is still supported. A claim naming something the passage never mentions is not.",
 823			"Answer only with the JSON field.",
 824		}, " "),
 825		fmt.Sprintf("Passage:\n%s\n\nClaim:\n%s", truncate(passage, 1800), claim),
 826		40, schema, &v)
 827	return v.Supported, err
 828}
 829
 830// findSupport looks for another passage that backs a failing claim. It checks
 831// the most textually similar ones first rather than all of them, since every
 832// check is a model call.
 833func (e *Engine) findSupport(ctx context.Context, claim string, passages []Passage, skip int) int {
 834	type scored struct {
 835		id    int
 836		score int
 837	}
 838	var cands []scored
 839	words := contentWords(claim)
 840	for _, p := range passages {
 841		if p.ID == skip {
 842			continue
 843		}
 844		cands = append(cands, scored{p.ID, overlap(words, contentWords(p.Text))})
 845	}
 846	sort.Slice(cands, func(a, b int) bool { return cands[a].score > cands[b].score })
 847
 848	byID := map[int]Passage{}
 849	for _, p := range passages {
 850		byID[p.ID] = p
 851	}
 852	for i, c := range cands {
 853		if i >= 3 || c.score == 0 {
 854			break
 855		}
 856		if ok, err := e.entails(ctx, byID[c.id].Text, claim); err == nil && ok {
 857			return c.id
 858		}
 859	}
 860	return 0
 861}
 862
 863func contentWords(s string) map[string]bool {
 864	out := map[string]bool{}
 865	for _, f := range strings.Fields(strings.ToLower(s)) {
 866		w := strings.Map(func(r rune) rune {
 867			if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
 868				return r
 869			}
 870			return -1
 871		}, f)
 872		if len(w) > 3 && !stopword[w] {
 873			out[w] = true
 874		}
 875	}
 876	return out
 877}
 878
 879func overlap(a, b map[string]bool) int {
 880	n := 0
 881	for w := range a {
 882		if b[w] {
 883			n++
 884		}
 885	}
 886	return n
 887}
 888
 889func citedIDs(s string) []int {
 890	var ids []int
 891	seen := map[int]bool{}
 892	for i := 0; i < len(s); i++ {
 893		if s[i] != '[' {
 894			continue
 895		}
 896		j := strings.IndexByte(s[i:], ']')
 897		if j < 0 {
 898			break
 899		}
 900		n, ok := 0, j > 1
 901		for _, c := range s[i+1 : i+j] {
 902			if c < '0' || c > '9' {
 903				ok = false
 904				break
 905			}
 906			n = n*10 + int(c-'0')
 907		}
 908		if ok && n > 0 && !seen[n] {
 909			seen[n] = true
 910			ids = append(ids, n)
 911		}
 912		i += j
 913	}
 914	return ids
 915}
 916
 917func stripCitations(s string) string {
 918	var b strings.Builder
 919	depth := 0
 920	for _, r := range s {
 921		switch r {
 922		case '[':
 923			depth++
 924		case ']':
 925			if depth > 0 {
 926				depth--
 927			}
 928		default:
 929			if depth == 0 {
 930				b.WriteRune(r)
 931			}
 932		}
 933	}
 934	return strings.Join(strings.Fields(b.String()), " ")
 935}
 936
 937// splitClaims breaks an answer into the units worth checking.
 938//
 939// A run of list items citing the same passage is one claim, not one per line.
 940// An ingredient list produced fifteen separate model calls that all asked the
 941// same question of the same passage, which was slow and told the reader
 942// nothing. Prose splits on sentences as before.
 943const shortItem = 80
 944
 945func splitClaims(text string) []string {
 946	var out []string
 947	var bullets []string
 948	var bulletCite string
 949
 950	flush := func() {
 951		if len(bullets) == 0 {
 952			return
 953		}
 954		out = append(out, strings.Join(bullets, "; "))
 955		bullets = nil
 956		bulletCite = ""
 957	}
 958
 959	for _, line := range strings.Split(text, "\n") {
 960		t := strings.TrimSpace(line)
 961		if t == "" {
 962			flush()
 963			continue
 964		}
 965		if item, ordered, ok := listItem(t); ok {
 966			// A numbered list is a sequence of steps and each step is its own
 967			// claim, since one wrong step in a recipe is the whole problem. A
 968			// short bulleted run is a list of things and reads as one claim.
 969			if ordered || len(item) > shortItem {
 970				flush()
 971				out = append(out, item)
 972				continue
 973			}
 974			ids := citedIDs(t)
 975			key := ""
 976			if len(ids) > 0 {
 977				key = fmt.Sprint(ids)
 978			}
 979			// A change of citation ends the run, since the group is only one
 980			// claim while every line points at the same evidence.
 981			if bulletCite != "" && key != bulletCite {
 982				flush()
 983			}
 984			bulletCite = key
 985			bullets = append(bullets, item)
 986			continue
 987		}
 988		flush()
 989		out = append(out, splitSentences(t)...)
 990	}
 991	flush()
 992
 993	var kept []string
 994	for _, c := range out {
 995		if len(strings.TrimSpace(c)) > 15 {
 996			kept = append(kept, strings.TrimSpace(c))
 997		}
 998	}
 999	return kept
1000}
1001
1002// listItem recognises a markdown bullet or numbered line, returning its text
1003// and whether the list was ordered.
1004func listItem(t string) (text string, ordered bool, ok bool) {
1005	if strings.HasPrefix(t, "- ") || strings.HasPrefix(t, "* ") || strings.HasPrefix(t, "+ ") {
1006		return strings.TrimSpace(t[2:]), false, true
1007	}
1008	for i := 0; i < len(t) && i < 3; i++ {
1009		if t[i] >= '0' && t[i] <= '9' {
1010			continue
1011		}
1012		if i > 0 && (t[i] == '.' || t[i] == ')') && i+1 < len(t) && t[i+1] == ' ' {
1013			return strings.TrimSpace(t[i+2:]), true, true
1014		}
1015		break
1016	}
1017	return "", false, false
1018}
1019
1020// splitSentences is deliberately crude. It only has to find units small enough
1021// to check individually, so a split inside an abbreviation costs nothing.
1022func splitSentences(text string) []string {
1023	var out []string
1024	var cur strings.Builder
1025	for i, r := range text {
1026		cur.WriteRune(r)
1027		if r == '.' || r == '!' || r == '?' {
1028			rest := text[i+1:]
1029			if rest == "" || rest[0] == ' ' || rest[0] == '\n' {
1030				if s := strings.TrimSpace(cur.String()); len(s) > 15 {
1031					out = append(out, s)
1032				}
1033				cur.Reset()
1034			}
1035		}
1036	}
1037	if s := strings.TrimSpace(cur.String()); len(s) > 15 {
1038		out = append(out, s)
1039	}
1040	return out
1041}
1042
1043// dropMissingFields removes lines whose whole content is that the passages did
1044// not say something. The prompt asks the model to leave them out and it writes
1045// them anyway, and "Total Time: Not specified" is noise in a recipe rather than
1046// an answer.
1047func dropMissingFields(text string) string {
1048	return strings.TrimSpace(eachProseLine(text, func(line string) (string, bool) {
1049		l := strings.ToLower(plainText(line))
1050		if strings.Contains(l, "not specified") || strings.Contains(l, "not mentioned") ||
1051			strings.Contains(l, "not provided") || strings.Contains(l, "not given") {
1052			// Only when the line is a field, not when it is a real sentence
1053			// saying the sources fall short of the question.
1054			if len(l) < 90 && strings.Count(l, " ") < 12 {
1055				return "", false
1056			}
1057		}
1058		return line, true
1059	}))
1060}
1061
1062// plainText strips the markdown emphasis so a claim reads as a sentence in the
1063// validation list rather than as source.
1064func plainText(s string) string {
1065	s = strings.ReplaceAll(s, "**", "")
1066	s = strings.ReplaceAll(s, "`", "")
1067	s = strings.ReplaceAll(s, "__", "")
1068	return strings.Join(strings.Fields(s), " ")
1069}
1070
1071func truncate(s string, n int) string {
1072	if len(s) <= n {
1073		return s
1074	}
1075	return s[:n] + "..."
1076}
1077
1078// keepURLs puts back any address the follow-up rewrite dropped. The rewrite is
1079// a model call and an address is the one thing in a question that cannot
1080// survive being paraphrased, since a character of it changes what gets read.
1081func keepURLs(question, standalone string) string {
1082	for _, u := range skills.URLsIn(question) {
1083		if !strings.Contains(standalone, u) {
1084			standalone += " " + u
1085		}
1086	}
1087	return standalone
1088}
1089
1090// listHosts names the pages being read the way a person would say them.
1091func listHosts(urls []string) string {
1092	out := make([]string, 0, len(urls))
1093	for _, u := range urls {
1094		out = append(out, hostname(u))
1095	}
1096	return strings.Join(out, ", ")
1097}