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

15.1 KB · 495 lines · Go Raw History
  1// Command search serves search.bythewood.me: ask a question, it runs
  2// DuckDuckGo, fetches and cleans the pages it finds, and writes an answer whose
  3// every sentence is checked against the passage it cites. The model is a 4B in
  4// a container with the GPU attached, started on demand, so asking nothing costs
  5// nothing.
  6package main
  7
  8import (
  9	"context"
 10	"encoding/json"
 11	"flag"
 12	"fmt"
 13	"html/template"
 14	"log/slog"
 15	"net/http"
 16	"os"
 17	"strings"
 18	"time"
 19
 20	"search.bythewood.me/web"
 21)
 22
 23const (
 24	listenAddr = ":8000"
 25
 26	// The public repository this site lives in. Every gated site here links to
 27	// its own source from its landing page.
 28	sourceURL = "https://github.com/overshard/orchard/tree/main/sites/search.bythewood.me"
 29
 30	// Must match the first hostname label, like every other source label.
 31	selfSource = "search"
 32
 33	// analyticsID is in the page source of every site; it is identity, not a
 34	// credential. Its own property, since a copied one silently files this
 35	// site's traffic under whichever site it was copied from.
 36	analyticsID = "5782ea95-0169-4095-b94a-0b2b420440ed"
 37)
 38
 39type site struct {
 40	engine   *Engine
 41	store    *Store
 42	hist     *History
 43	llm      *LLM
 44	sessions *Sessions
 45	budget   *Budget
 46	queue    *Queue
 47	assets   *Assets
 48	auth     *web.Authenticator
 49
 50	// devOpen skips the auth check so the UI can be worked on without a
 51	// session. It is gated on Reloaded, which is false in any build made with
 52	// -tags embed, so the shipped image cannot turn this on however the
 53	// environment is set.
 54	devOpen bool
 55}
 56
 57func devOpen() bool {
 58	if !Reloaded {
 59		return false
 60	}
 61	if os.Getenv("SEARCH_DEV_NOAUTH") == "" {
 62		return false
 63	}
 64	slog.Warn("auth is bypassed, this is a development build only")
 65	return true
 66}
 67
 68func env(key, fallback string) string {
 69	if v := os.Getenv(key); v != "" {
 70		return v
 71	}
 72	return fallback
 73}
 74
 75func main() {
 76	healthcheck := flag.Bool("healthcheck", false, "probe a running server on this host and exit")
 77	flag.Parse()
 78
 79	web.SetupLogging()
 80
 81	if *healthcheck {
 82		if err := web.HealthCheck("http://127.0.0.1:8000/healthz", 3*time.Second); err != nil {
 83			slog.Info(fmt.Sprintf("healthcheck: %v", err))
 84			os.Exit(1)
 85		}
 86		return
 87	}
 88
 89	// Tees stdout records to logging.bythewood.me; see web/shipper.go. It goes
 90	// after the healthcheck branch so a HEALTHCHECK does not start a queue it
 91	// will never flush.
 92	shipper := web.ShipLogs(selfSource, web.HTTPSink())
 93	defer shipper.Close()
 94
 95	dataDir := env("SITE_DATA", "build/data")
 96	store, err := OpenStore(dataDir)
 97	if err != nil {
 98		slog.Error("startup failed", slog.Any("err", err))
 99		os.Exit(1)
100	}
101	defer store.Close()
102
103	llm := NewLLM(env("LLM_URL", "http://orchard-llm:8000"), os.Getenv("LLM_KEY"))
104
105	budget := NewBudget()
106	hist, err := OpenHistory(dataDir)
107	if err != nil {
108		slog.Error("history open failed", slog.Any("err", err))
109		os.Exit(1)
110	}
111	defer hist.Close()
112
113	s := &site{
114		queue:    NewQueue(),
115		hist:     hist,
116		assets:   NewAssets(assets()),
117		auth:     web.NewAuthenticator(),
118		devOpen:  devOpen(),
119		budget:   budget,
120		engine:   NewEngine(store, llm, budget),
121		store:    store,
122		llm:      llm,
123		sessions: NewSessions(),
124	}
125
126	// Everything here is behind auth. This site spends Isaac's GPU and searches
127	// from his address on every question, so an open one would be a stranger's
128	// search engine running on his hardware.
129	if _, err := s.loadTemplates(); err != nil {
130		slog.Error("startup failed", slog.Any("err", err))
131		os.Exit(1)
132	}
133
134	mux := http.NewServeMux()
135	// The root is public and explains what this is, the way every other gated
136	// site here does. The tool itself is behind auth, because it spends a GPU
137	// and searches from Isaac's address on every question.
138	mux.HandleFunc("GET /{$}", s.landing)
139	mux.HandleFunc("GET /search", s.gate(s.app))
140	mux.HandleFunc("GET /stream", s.gate(s.ask))
141	mux.HandleFunc("POST /reset", s.gateJSON(s.reset))
142	mux.HandleFunc("GET /budget", s.gateJSON(s.budgetState))
143
144	// The history of what was asked, which is the one thing the cache does not
145	// hold and the only page here that can delete anything.
146	mux.HandleFunc("GET /history", s.gate(s.historyPage))
147	mux.HandleFunc("POST /rate", s.gateJSON(s.rate))
148	mux.HandleFunc("POST /forget", s.gateJSON(s.forget))
149
150	// Signing in happens on auth.bythewood.me. This stays so an old bookmark
151	// or a typed /login still lands somewhere sensible.
152	mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
153		http.Redirect(w, r, web.LoginURL(r), http.StatusSeeOther)
154	})
155
156	// Unauthenticated on purpose: the health strip on dash probes this over the
157	// bridge, and Caddy refuses it from outside.
158	mux.HandleFunc("GET /healthz", s.healthz)
159
160	mux.Handle("GET /static/", s.assets.Handler())
161
162	slog.Info("search serving",
163		slog.String("addr", listenAddr),
164		slog.String("llm", llm.BaseURL),
165		slog.Bool("assets_from_disk", Reloaded))
166	// Recovered is outermost so a panic in the pipeline becomes a 500 rather
167	// than taking the process and every question in flight with it.
168	handler := web.Chain(mux, web.Recovered, web.Logged)
169
170	if err := web.Serve(listenAddr, handler); err != nil {
171		slog.Error("server stopped", slog.Any("err", err))
172		os.Exit(1)
173	}
174}
175
176// loadTemplates parses from whichever source this build uses. In development
177// that is the disk, so a template edit shows on reload rather than at the next
178// rebuild.
179func (s *site) loadTemplates() (*template.Template, error) {
180	return template.New("").Funcs(template.FuncMap{
181		"hostname": hostname,
182		"asset":    s.assets.URL,
183		"num":      formatNum,
184		"reason":   reasonText,
185	}).ParseFS(assets(), "templates/*.html")
186}
187
188func (s *site) render(w http.ResponseWriter, name string, data any) {
189	tmpl, err := s.loadTemplates()
190	if err != nil {
191		slog.Error("template parse failed", slog.Any("err", err))
192		http.Error(w, "template error", http.StatusInternalServerError)
193		return
194	}
195	w.Header().Set("Content-Type", "text/html; charset=utf-8")
196	if err := tmpl.ExecuteTemplate(w, name, data); err != nil {
197		slog.Error("render failed", slog.Any("err", err))
198	}
199}
200
201func (s *site) gate(next http.HandlerFunc) http.HandlerFunc {
202	if s.devOpen {
203		return next
204	}
205	return s.auth.RequireAuth(next)
206}
207
208func (s *site) gateJSON(next http.HandlerFunc) http.HandlerFunc {
209	if s.devOpen {
210		return next
211	}
212	return s.auth.RequireAuthJSON(next)
213}
214
215// landing is what a signed out visitor sees. It says what this is and where the
216// source is, and nothing about what has been asked.
217func (s *site) landing(w http.ResponseWriter, r *http.Request) {
218	pages, chunks, sites := s.store.Stats()
219	s.render(w, "landing.html", map[string]any{
220		"Pages":         pages,
221		"Chunks":        chunks,
222		"Sites":         sites,
223		"SourceURL":     sourceURL,
224		"Authenticated": s.devOpen || s.auth.Authenticated(r),
225		"Analytics":     !Reloaded,
226		"AnalyticsID":   analyticsID,
227	})
228}
229
230func (s *site) app(w http.ResponseWriter, r *http.Request) {
231	pages, chunks, _ := s.store.Stats()
232	s.render(w, "app.html", map[string]any{
233		"Pages":       pages,
234		"Chunks":      chunks,
235		"LLMUp":       s.llm.Healthy(r.Context()),
236		"SessionID":   NewSessionID(),
237		"Budget":      s.budget.State(),
238		"Ambient":     AmbientFacts(),
239		"SourceURL":   sourceURL,
240		"Analytics":   !Reloaded,
241		"AnalyticsID": analyticsID,
242	})
243}
244
245func (s *site) reset(w http.ResponseWriter, r *http.Request) {
246	s.sessions.Reset(r.URL.Query().Get("sid"))
247	w.WriteHeader(http.StatusNoContent)
248}
249
250// ask streams the pipeline. Every step reports as it happens, because a
251// question can take fifteen seconds and a spinner that says nothing is the
252// difference between "working" and "broken".
253func (s *site) ask(w http.ResponseWriter, r *http.Request) {
254	question := strings.TrimSpace(r.URL.Query().Get("q"))
255	sid := r.URL.Query().Get("sid")
256	incognito := r.URL.Query().Get("incognito") == "1"
257	// chat's deep_search wants the history row skipped on every question it
258	// asks, since chat is already keeping that conversation, without claiming
259	// the turn was incognito when it was not.
260	nohistory := incognito || r.URL.Query().Get("nohistory") == "1"
261	if question == "" {
262		http.Error(w, "no question", http.StatusBadRequest)
263		return
264	}
265
266	// ResponseController rather than a type assertion for http.Flusher, because
267	// the request logger wraps the writer and an assertion would see the
268	// wrapper. It follows Unwrap down to the real one.
269	rc := http.NewResponseController(w)
270
271	// web/server.go sets no write bound for this site, and this clears any
272	// per-connection deadline anyway, so an answer that takes minutes is never
273	// cut mid-frame. It doubles as the check that this writer can be flushed.
274	if err := rc.SetWriteDeadline(time.Time{}); err != nil {
275		http.Error(w, "streaming unsupported", http.StatusInternalServerError)
276		return
277	}
278
279	w.Header().Set("Content-Type", "text/event-stream")
280	w.Header().Set("Cache-Control", "no-cache")
281	w.Header().Set("Connection", "keep-alive")
282	w.Header().Set("X-Accel-Buffering", "no")
283	rc.Flush()
284
285	send := func(event string, payload any) {
286		blob, err := json.Marshal(payload)
287		if err != nil {
288			return
289		}
290		fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, blob)
291		rc.Flush()
292	}
293
294	// Longer than one question needs, because it now covers waiting for the
295	// people ahead as well as answering.
296	ctx, cancel := context.WithTimeout(r.Context(), 20*time.Minute)
297	defer cancel()
298	// Every model call the pipeline makes hangs off this, so marking it here is
299	// what keeps the gateway from writing down a question this site is not
300	// writing down either.
301	if incognito {
302		ctx = WithIncognito(ctx)
303	}
304
305	// One question runs at a time, since there is one GPU and the model server
306	// holds a single slot. Waiting is shown rather than hidden.
307	release, ok := s.queue.Enter(ctx, func(q QueueState) {
308		send("queued", q)
309	})
310	if !ok {
311		return // the client went away while waiting
312	}
313	defer release()
314
315	progress := Progress(func(step, detail string) {
316		send("status", map[string]string{"step": step, "detail": detail})
317	})
318
319	history := s.sessions.History(sid)
320	if len(history) > 0 {
321		send("status", map[string]string{"step": "followup", "detail": "following on from the last answer"})
322	}
323
324	ans, err := s.engine.Run(ctx, question, history, progress)
325	if err != nil {
326		send("failed", map[string]string{"error": err.Error()})
327		return
328	}
329
330	if sid != "" {
331		s.sessions.Append(sid, Turn{Question: question, Answer: ans.Text})
332	}
333
334	// Incognito skips this row and the gateway's copy of the prompts, and
335	// nothing else. The pages fetched on the way still go in the archive, which
336	// Isaac decided is fine: it is public articles with no question attached,
337	// so nothing there reads back as what was asked. The row is what would.
338	var logged int64
339	if !nohistory {
340		id, err := s.hist.Log(ans, s.stamp())
341		if err != nil {
342			slog.Warn("history write", slog.Any("err", err))
343		}
344		logged = id
345	}
346
347	pages, chunks, _ := s.store.Stats()
348	send("answer", map[string]any{
349		"id":         logged,
350		"incognito":  incognito,
351		"budget":     s.budget.State(),
352		"question":   ans.Query,
353		"standalone": ans.Standalone,
354		"shape":      ans.Shape,
355		"skill":      ans.Skill,
356		"text":       ans.Text,
357		"html":       ans.HTML,
358		"sources":    ans.Sources,
359		"links":      ans.Links,
360		"citations":  ans.Citations,
361		"checks":     ans.Checks,
362		"deps":       ans.Deps,
363		"passages":   ans.Passages,
364		"queries":    ans.Queries,
365		"elapsed":    ans.Elapsed,
366		"warnings":   ans.Warnings,
367		"retried":    ans.Retried,
368		"support":    ans.Support,
369		"pages":      pages,
370		"chunks":     chunks,
371	})
372}
373
374// budgetState is polled by the page so the search allowance is visible before
375// someone runs into it rather than after.
376func (s *site) budgetState(w http.ResponseWriter, r *http.Request) {
377	w.Header().Set("Content-Type", "application/json")
378	json.NewEncoder(w).Encode(s.budget.State())
379}
380
381func (s *site) healthz(w http.ResponseWriter, r *http.Request) {
382	fmt.Fprintln(w, "ok")
383}
384
385func hostname(raw string) string {
386	raw = strings.TrimPrefix(strings.TrimPrefix(raw, "https://"), "http://")
387	if i := strings.IndexByte(raw, '/'); i > 0 {
388		raw = raw[:i]
389	}
390	return strings.TrimPrefix(raw, "www.")
391}
392
393// stamp says what produced an answer. The model comes off the last response
394// rather than the config, so it names the repository and quant actually loaded.
395func (s *site) stamp() Stamp {
396	return Stamp{
397		Model:    s.llm.Served(),
398		Prompts:  promptVersion(),
399		Sampling: samplingVersion(),
400		Build:    map[bool]string{true: "dev", false: "release"}[Reloaded],
401	}
402}
403
404func (s *site) historyPage(w http.ResponseWriter, r *http.Request) {
405	only := r.URL.Query().Get("only")
406	entries, err := s.hist.List(200, 0, only)
407	if err != nil {
408		slog.Error("history read", slog.Any("err", err))
409		http.Error(w, "history unavailable", http.StatusInternalServerError)
410		return
411	}
412	total, rated := s.hist.Count()
413	s.render(w, "history.html", map[string]any{
414		"Entries": entries,
415		"Only":    only,
416		"Total":   total,
417		"Rated":   rated,
418	})
419}
420
421// rate takes the thumb. The reason is a short enum rather than free text
422// because a bare thumb cannot say which step went wrong, and which step went
423// wrong is the entire value of collecting it.
424func (s *site) rate(w http.ResponseWriter, r *http.Request) {
425	var in struct {
426		ID      int64  `json:"id"`
427		Verdict int    `json:"verdict"`
428		Reason  string `json:"reason"`
429		Note    string `json:"note"`
430	}
431	if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.ID == 0 {
432		http.Error(w, "bad request", http.StatusBadRequest)
433		return
434	}
435	if !validReason(in.Reason) {
436		in.Reason = ""
437	}
438	if err := s.hist.Rate(in.ID, in.Verdict, in.Reason, truncate(in.Note, 500)); err != nil {
439		slog.Warn("rate failed", slog.Any("err", err))
440		http.Error(w, "could not save that", http.StatusInternalServerError)
441		return
442	}
443	w.Header().Set("Content-Type", "application/json")
444	fmt.Fprint(w, `{"ok":true}`)
445}
446
447// The reasons map onto the steps of the pipeline, so a month of them says
448// where to spend the effort rather than only how often it was wrong.
449// The wording matches the buttons on the answer, so a row on the history page
450// reads back as the thing that was actually clicked.
451var reasons = map[string]string{
452	"wrong":   "it is wrong",             // synthesis or validation
453	"stale":   "already happened",        // shape and planning
454	"missed":  "answered something else", // routing and shape
455	"sources": "bad sources",             // retrieval
456}
457
458func validReason(r string) bool { _, ok := reasons[r]; return ok }
459
460// reasonText is what the history page shows on a row. The stored value is the
461// key, so the wording can change without rewriting what was already collected.
462func reasonText(key string) string {
463	if text, ok := reasons[key]; ok {
464		return text
465	}
466	return key
467}
468
469func (s *site) forget(w http.ResponseWriter, r *http.Request) {
470	var in struct {
471		ID  int64 `json:"id"`
472		All bool  `json:"all"`
473	}
474	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
475		http.Error(w, "bad request", http.StatusBadRequest)
476		return
477	}
478	var err error
479	if in.All {
480		err = s.hist.DeleteAll()
481	} else if in.ID > 0 {
482		err = s.hist.Delete(in.ID)
483	} else {
484		http.Error(w, "bad request", http.StatusBadRequest)
485		return
486	}
487	if err != nil {
488		slog.Warn("forget failed", slog.Any("err", err))
489		http.Error(w, "could not delete that", http.StatusInternalServerError)
490		return
491	}
492	w.Header().Set("Content-Type", "application/json")
493	fmt.Fprint(w, `{"ok":true}`)
494}