Compare commits
19 Commits
content/ph
...
2ceb0c147c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ceb0c147c | |||
| cb45c462be | |||
| 6ac0e1ff47 | |||
| a76882de67 | |||
|
|
59580b6e45 | ||
| d793ab0871 | |||
|
|
286aa12bb0 | ||
|
|
f39f560c7f | ||
|
|
df864cc29e | ||
|
|
5c0622c1b1 | ||
| 9fae1c7dd8 | |||
| f9dc574172 | |||
| 24590cc351 | |||
| c95e5361e3 | |||
| 2906683f44 | |||
| c2b4e33897 | |||
| 84fc585c91 | |||
| fd9974d07e | |||
| ea2c722816 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ __pycache__/
|
||||
*.pyc
|
||||
|
||||
# build output
|
||||
/bin/
|
||||
/dist/
|
||||
/current/
|
||||
*.test
|
||||
|
||||
95
Makefile
95
Makefile
@@ -1,19 +1,106 @@
|
||||
# Ai Resume - build entry points.
|
||||
#
|
||||
# `make validate` is the single gate: it is what CI and deploy.sh call.
|
||||
# `make validate` is the content gate: it is what CI and deploy.sh call.
|
||||
# It exits 0 only on a clean content tree.
|
||||
#
|
||||
# `make build` is the site gate: it generates the templ components, compiles the
|
||||
# two small binaries and writes the pre-rendered site into dist/. One binary
|
||||
# (bin/resume-server) serves the whole site; there is no bundler and no Node.
|
||||
|
||||
PYTHON ?= python3
|
||||
VALIDATOR := $(PYTHON) tools/validate_content.py
|
||||
GO ?= go
|
||||
TEMPL ?= templ
|
||||
|
||||
.PHONY: validate test help
|
||||
# The runtime image is distroless/static (deploy/Dockerfile): no libc, no
|
||||
# dynamic loader. Everything the server needs is pure Go (modernc.org/sqlite),
|
||||
# so a static binary is buildable and is what the container can actually exec.
|
||||
# Without this the build host's cgo links the nix glibc loader and the container
|
||||
# fails with "exec /usr/bin/resume-server: no such file or directory".
|
||||
CGO_ENABLED ?= 0
|
||||
|
||||
BIN := bin
|
||||
DIST := dist
|
||||
|
||||
.PHONY: build dist generate dev test validate validate-self-test fmt vet clean help \
|
||||
corpus corpus-sync corpus-rebuild corpus-audit corpus-verify corpus-demo corpus-test
|
||||
|
||||
help:
|
||||
@echo "make build templ generate, compile bin/, no dist"
|
||||
@echo "make dist build, then pre-render content into dist/"
|
||||
@echo "make dev pre-render into dist/ and serve it on 127.0.0.1:8096"
|
||||
@echo "make generate run templ generate (generated files are not committed)"
|
||||
@echo "make test go test ./... plus the validator self-test"
|
||||
@echo "make validate validate content/** front-matter against the frozen schema"
|
||||
@echo "make test run the validator self-test (fixtures under test/fixtures/)"
|
||||
@echo "make fmt vet gofmt / go vet over cmd and internal"
|
||||
@echo "make clean remove bin/ and dist/"
|
||||
@echo ""
|
||||
@echo "corpus (Phase 4, python3 -m corpus.cli):"
|
||||
@echo "make corpus rebuild the corpus from content/ (drops the collection)"
|
||||
@echo "make corpus-sync incremental update; only changed documents are re-embedded"
|
||||
@echo "make corpus-audit freshness + drift audit (exit 1 on findings)"
|
||||
@echo "make corpus-verify check the index against content/ and Qdrant"
|
||||
@echo "make corpus-demo same query with and without an area filter"
|
||||
@echo "make corpus-test corpus unit tests (no container, no network)"
|
||||
|
||||
build: generate
|
||||
@mkdir -p $(BIN)
|
||||
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -trimpath -buildvcs=false -o $(BIN)/resume-build ./cmd/build
|
||||
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -trimpath -buildvcs=false -o $(BIN)/resume-server ./cmd/server
|
||||
|
||||
dist: build
|
||||
@$(BIN)/resume-build --dist $(DIST)
|
||||
|
||||
dev: dist
|
||||
@RESUME_DIST=$(DIST) $(BIN)/resume-server
|
||||
|
||||
generate:
|
||||
@command -v $(TEMPL) >/dev/null || { echo "templ is not on PATH; run this inside 'nix develop'"; exit 1; }
|
||||
@$(TEMPL) generate
|
||||
|
||||
test: generate validate-self-test
|
||||
@$(GO) test ./...
|
||||
|
||||
validate:
|
||||
@$(VALIDATOR)
|
||||
|
||||
test:
|
||||
validate-self-test:
|
||||
@$(VALIDATOR) --self-test
|
||||
|
||||
fmt:
|
||||
@gofmt -l -w cmd internal
|
||||
|
||||
vet:
|
||||
@$(GO) vet ./...
|
||||
|
||||
# -- corpus (Phase 4, docs/phase-4-corpus.md) --------------------------------
|
||||
#
|
||||
# The corpus is data, not a build artefact: it lives in data/corpus/ (gitignored)
|
||||
# and in Qdrant. RESUME_QDRANT_URL defaults to http://127.0.0.1:6333 and
|
||||
# RESUME_EMBED_PROVIDER defaults to local-hashing; production sets both in the
|
||||
# environment, never in this file.
|
||||
|
||||
CORPUS := $(PYTHON) -m corpus.cli
|
||||
|
||||
corpus: corpus-rebuild
|
||||
|
||||
corpus-rebuild:
|
||||
@$(CORPUS) rebuild
|
||||
|
||||
corpus-sync:
|
||||
@$(CORPUS) sync
|
||||
|
||||
corpus-audit:
|
||||
@$(CORPUS) audit --fail-on-findings
|
||||
|
||||
corpus-verify:
|
||||
@$(CORPUS) verify
|
||||
|
||||
corpus-demo:
|
||||
@$(CORPUS) demo-filter --query "distributed audio endpoints" --area edge
|
||||
|
||||
corpus-test:
|
||||
@$(PYTHON) -m unittest discover -s corpus/tests -t .
|
||||
|
||||
clean:
|
||||
@rm -rf $(BIN) $(DIST)
|
||||
|
||||
42
README.md
42
README.md
@@ -37,6 +37,42 @@ This folder **is** the git working copy of `sam/ai-resume`. One repo, one produc
|
||||
|
||||
Previously held an abandoned Vite/React scaffold. It was replaced by this build spec; the history is retained because it honestly records the false start, and it is referenced in the site's "alternatives considered" content.
|
||||
|
||||
## Build and run
|
||||
|
||||
The site is one Go binary serving pre-rendered HTML. There is no Node, no bundler and no
|
||||
`node_modules`; `go.sum` is the only dependency lock.
|
||||
|
||||
```sh
|
||||
nix develop # go, templ, sqlite3, make, curl
|
||||
make validate # content gate: front-matter against the frozen schema
|
||||
make test # go test ./... plus the validator self-test
|
||||
make dist # templ generate → bin/ → pre-rendered dist/
|
||||
make dev # build, then serve on http://127.0.0.1:8096
|
||||
```
|
||||
|
||||
`bin/resume-build` reads `content/` and writes `dist/` (pages, stage fragments, the fragment
|
||||
manifest, the SQLite FTS5 index, assets). `bin/resume-server` serves `dist/` and never reads
|
||||
`content/`, never calls a model, and never opens a socket to another service — that is what
|
||||
keeps acceptance test A1 true.
|
||||
|
||||
The two things that are not files are `/search` (FTS5 over page titles and body text) and
|
||||
`/api/stage/stream` (the presentation panel driven by an event stream instead of a route).
|
||||
|
||||
The retrieval corpus (Phase 4) is built separately, because it writes to a service rather
|
||||
than to `dist/`:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/qdrant.yml up -d # on the runtime host, loopback only
|
||||
make corpus # content/ -> chunks -> Qdrant + SQLite FTS5
|
||||
make corpus-verify # does the index agree with content/ and with Qdrant?
|
||||
make corpus-audit # stale documents, index drift, contract drift
|
||||
make corpus-test # 76 unit tests, no container and no network
|
||||
```
|
||||
|
||||
The site never calls Qdrant, so acceptance test A1 is unaffected: stopping the corpus
|
||||
services leaves every page working. Operator manual and measurements:
|
||||
`docs/phase-4-notes.md`.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Do not touch other systems.** No stopping containers, no editing other projects, no `nixos-rebuild`, no Caddy changes except the single block specified in Phase 3 — and that one Sam applies.
|
||||
@@ -48,10 +84,10 @@ Previously held an abandoned Vite/React scaffold. It was replaced by this build
|
||||
## Current state
|
||||
|
||||
- [x] Repo prepared, scaffold retired, build spec written
|
||||
- [ ] Phase 1 — content source of truth
|
||||
- [ ] Phase 2 — static site
|
||||
- [x] Phase 1 — content source of truth (40 documents across six areas, `make validate`)
|
||||
- [x] Phase 2 — static site (`make dist`, 45 pages, 86 stage fragments, FTS5 search)
|
||||
- [ ] Phase 3 — public deployment
|
||||
- [ ] Phase 4 — corpus
|
||||
- [x] Phase 4 — corpus and retrieval (51 chunks, FTS5 + Qdrant, freshness audit)
|
||||
- [ ] Phase 5 — AI overlay
|
||||
- [ ] Phase 6 — guardrails
|
||||
- [ ] Phase 7 — polish
|
||||
|
||||
58
cmd/build/main.go
Normal file
58
cmd/build/main.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Command build pre-renders the whole site into a directory.
|
||||
//
|
||||
// It reads content/ and writes dist/: every page as HTML, one file per stage
|
||||
// fragment, the fragment manifest, the SQLite FTS5 index, the assets and the
|
||||
// static files. No network call is made, and the output is what the server
|
||||
// serves — Mode A never renders a page per request.
|
||||
//
|
||||
// Configuration is from the environment, so the same binary is used by
|
||||
// `make build`, by CI and by the container image:
|
||||
//
|
||||
// RESUME_ROOT repository root (default ".")
|
||||
// RESUME_CONTENT content directory (default $RESUME_ROOT/content)
|
||||
// RESUME_DIST output directory (default $RESUME_ROOT/dist)
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "usage: build [--root DIR] [--content DIR] [--dist DIR]\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "environment: RESUME_ROOT, RESUME_CONTENT, RESUME_DIST\n")
|
||||
}
|
||||
root := flag.String("root", env("RESUME_ROOT", "."), "repository root")
|
||||
contentDir := flag.String("content", env("RESUME_CONTENT", ""), "content directory")
|
||||
distDir := flag.String("dist", env("RESUME_DIST", ""), "output directory")
|
||||
flag.Parse()
|
||||
|
||||
cfg := site.Config{Root: *root, ContentDir: *contentDir, OutDir: *distDir}
|
||||
s, err := site.New(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "build: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := s.Build(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "build: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
info, err := site.LoadBuildInfo(s.Config.OutDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "build: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("built %d pages, %d stage fragments, %d indexed documents into %s\n",
|
||||
info.Pages, info.Fragments, info.Indexed, s.Config.OutDir)
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
155
cmd/server/main.go
Normal file
155
cmd/server/main.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Command server serves the site the build wrote.
|
||||
//
|
||||
// It reads a directory produced by cmd/build and serves it on loopback. It does
|
||||
// not read content/, does not need Qdrant, OmniRoute or any other service, and
|
||||
// makes no outbound connection, so every page it serves works with the AI stack
|
||||
// stopped (acceptance test A1).
|
||||
//
|
||||
// Configuration comes from the environment so the same binary works under
|
||||
// `make dev`, systemd and compose:
|
||||
//
|
||||
// RESUME_ADDR listen address (default 127.0.0.1:8096)
|
||||
// RESUME_DIST build directory (default "dist")
|
||||
// RESUME_STREAM_DELAY pacing for the streamed panel, e.g. 350ms
|
||||
//
|
||||
// Flags with the same names override the environment for local work.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", env("RESUME_ADDR", server.DefaultAddr), "listen address")
|
||||
dist := flag.String("dist", env("RESUME_DIST", server.DefaultDist), "build directory")
|
||||
streamDelay := flag.Duration("stream-delay", envDuration("RESUME_STREAM_DELAY", server.DefaultStreamDelay), "delay between streamed panel frames")
|
||||
healthCheck := flag.Bool("health-check", false, "ask a running server for /healthz and exit 0 only if static is ready")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "usage: server [--addr HOST:PORT] [--dist DIR] [--stream-delay DURATION] [--health-check]\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "environment: RESUME_ADDR, RESUME_DIST, RESUME_STREAM_DELAY\n")
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
// The container image has no shell and no curl, so the health probe is this
|
||||
// binary talking to itself (deploy/compose.yml healthcheck).
|
||||
if *healthCheck {
|
||||
if err := probe(*addr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "health check failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("static ready")
|
||||
return
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
site, err := server.New(server.Config{
|
||||
Addr: *addr,
|
||||
DistDir: *dist,
|
||||
StreamDelay: *streamDelay,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("start failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer site.Close()
|
||||
|
||||
// No WriteTimeout on purpose: the stage stream keeps the response open for
|
||||
// as long as it is producing frames. Reads are bounded.
|
||||
srv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: site.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 64 << 10,
|
||||
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
log.Info("shutting down")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Warn("shutdown", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Info("listening", "addr", *addr, "dist", *dist)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error("serve failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// probe asks a running server whether the static site is ready. Only the static
|
||||
// field decides the exit code: Mode A must report ready with every AI service
|
||||
// stopped (acceptance test A1).
|
||||
func probe(addr string) error {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
res, err := client.Get("http://" + net.JoinHostPort(host, port) + "/healthz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("/healthz returned %s", res.Status)
|
||||
}
|
||||
var body struct {
|
||||
Static struct {
|
||||
Ready bool `json:"ready"`
|
||||
} `json:"static"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(&body); err != nil {
|
||||
return fmt.Errorf("decode /healthz: %w", err)
|
||||
}
|
||||
if !body.Static.Ready {
|
||||
return errors.New("static is not ready")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envDuration(key string, fallback time.Duration) time.Duration {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s=%q is not a duration: %v\n", key, v, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -49,7 +49,7 @@ The honest framing is in the evidence notes and is repeated here: *full-time par
|
||||
|
||||
## What next
|
||||
|
||||
The same three areas at a larger scale: AI and agentic systems, the platform engineering underneath them, and the constrained devices at the edge. Melbourne based and flexible about location and format — remote, hybrid, office, or travel.
|
||||
The same three areas at a larger scale: [AI and agentic systems](../ai/ai-systems.md), the [platform engineering underneath them](../platform/platform-estate.md), and the [constrained devices at the edge](../edge/edge-fleet.md). Melbourne based and flexible about location and format — remote, hybrid, office, or travel.
|
||||
|
||||
## Contact policy
|
||||
|
||||
@@ -60,16 +60,3 @@ This site publishes no address, no phone number and no family details. Contact i
|
||||
- **No computer science degree.** The trade is a longer, broader practical education; the pages on this site are the argument, not the certificate.
|
||||
- **A period of self-employment is described as such.** It is not dressed up as a sabbatical or as consulting.
|
||||
- **Interests are one honest sentence each.** The previous version of this site invented venues and results. That has been deleted rather than corrected in place.
|
||||
|
||||
## Drill-downs
|
||||
|
||||
- [Non-IT working history](l3/about-non-it-work.md) — level 3
|
||||
- [Education](l3/about-education.md) — level 3
|
||||
- [Interests — one honest line each](l3/about-interests.md) — level 3
|
||||
- [The AI estate](../ai/ai-systems.md) — level 2
|
||||
- [The platform estate](../platform/platform-estate.md) — level 2
|
||||
- [The edge fleet](../edge/edge-fleet.md) — level 2
|
||||
|
||||
## Period and confidence
|
||||
|
||||
`period: 1999–`. Confidence is `running`: this is the current self-description, verified against the evidence notes on 2026-10-26.
|
||||
|
||||
@@ -3,7 +3,7 @@ id: about-overview
|
||||
area: about
|
||||
level: 1
|
||||
title: "About"
|
||||
claim: "Twenty-five years of shipping software — built through self-teaching · a non-IT working life · and five years overseas — now aimed at AI systems · the platforms under them and the devices at the edge."
|
||||
claim: "Sam has shipped software for twenty-five years — self-taught, through a non-IT working life and five years overseas — and now builds AI systems, the platforms under them and the devices at the edge."
|
||||
confidence: running
|
||||
period: "1999–"
|
||||
tech: [NixOS, Neovim, Python, JavaScript, React, Go, HTMX]
|
||||
@@ -13,13 +13,4 @@ public: true
|
||||
last_verified: 2026-10-26
|
||||
tags: [about, narrative, background]
|
||||
---
|
||||
|
||||
# About
|
||||
|
||||
**Claim.** A career built by self-teaching — from a childhood in the 1980s with a borrowed language reference, through fifteen years of agency delivery, a working life outside technology, and five years overseas — now concentrated on AI systems, the platforms they run on, and the hardware at the edge.
|
||||
|
||||
**Status.** Current. Melbourne based, and flexible about how the work is done — remote, hybrid, office, or travel.
|
||||
|
||||
**What this page is for.** The other five areas prove what can be built. This one explains where the judgement came from: a non-technical working life, a fine-arts and philosophy education, and a period spent building a business while raising two children.
|
||||
|
||||
**Start here.** [The narrative](about-narrative.md) — including an honest account of 2020 to 2026.
|
||||
The other five areas prove what can be built; this one explains where the judgement came from. The narrative, including 2020 to 2026, is on the [narrative page](about-narrative.md).
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [about, education, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Education
|
||||
|
||||
**Status: stub.** Formal presentation lands in Phase 7. No certificates, transcripts or student identifiers are published.
|
||||
Formal presentation lands in Phase 7. No certificates, transcripts or student identifiers are published.
|
||||
|
||||
## The record
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [about, interests, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Interests · one honest line each
|
||||
|
||||
**Status: stub for the visual treatment.**
|
||||
|
||||
## Music
|
||||
Blues and jazz piano and blues harp; played in two bands. Studied music composition at university and kept the piano as the thing that resets the week.
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [about, history, work, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Non-IT working history
|
||||
|
||||
**Status: stub for the visual treatment.** The roles are final; the presentation lands in Phase 7.
|
||||
The roles are final; the presentation lands in Phase 7.
|
||||
|
||||
## The roles
|
||||
|
||||
|
||||
49
content/about/l3/about-sources.md
Normal file
49
content/about/l3/about-sources.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: about-sources
|
||||
area: about
|
||||
level: 3
|
||||
title: "What the assistant reads"
|
||||
claim: "Four sources, one pool, and a published rule for every one of them."
|
||||
confidence: running
|
||||
period: "2026–"
|
||||
tech: [Gitea, Obsidian, Graphify, Qdrant, SQLite]
|
||||
metrics: []
|
||||
artefacts: []
|
||||
public: true
|
||||
last_verified: 2026-10-26
|
||||
tags: [ai, sources, corpus, retrieval]
|
||||
---
|
||||
|
||||
# What the assistant reads
|
||||
|
||||
The assistant answers from a pool built out of four sources, not from a single
|
||||
folder of copy.
|
||||
|
||||
| Source | Tier | What it contributes |
|
||||
|---|---|---|
|
||||
| This site's content | 1 | The curated claims: what Sam says is true about the work |
|
||||
| The Gitea estate | 2 | README files, file trees, selected source files, commit messages and language statistics |
|
||||
| The notes vault | 2 | Project and area notes, in Sam's own words |
|
||||
| The knowledge graph | 2 | Nodes and edges over the vault, each joined back to the note it came from |
|
||||
|
||||
Tier 1 wins when the two disagree. A harvested README can be out of date; the
|
||||
curated claim is the one that is maintained.
|
||||
|
||||
Every chunk records the platform it came from, the URL or repository path it can
|
||||
be opened at, the area it feeds, its tier, its honest confidence and when it last
|
||||
changed. A chunk without an origin is refused, not defaulted.
|
||||
|
||||
The rules are not hidden in code. Each platform has a guide that says what it is,
|
||||
where it lives, what is included, what is excluded and whether it is enabled.
|
||||
Those guides are shown at [the sources page](/platforms/) and are the same files
|
||||
the ingestion pipeline reads.
|
||||
|
||||
## What is excluded
|
||||
|
||||
`node_modules`, virtualenvs, git internals, trash, editor state, the SilverBullet
|
||||
auth file, credential-shaped files, binaries and media. Media is recorded as an
|
||||
image reference and never turned into text. Personal and family notes are not
|
||||
read.
|
||||
|
||||
The finished pool is scanned for credential-shaped content before a run can
|
||||
succeed, so the exclusion list is checked rather than trusted.
|
||||
@@ -3,7 +3,7 @@ id: ai-overview
|
||||
area: ai
|
||||
level: 1
|
||||
title: "AI & Agentic Systems"
|
||||
claim: "Builds and operates sixteen or more AI pipelines — agent harnesses · model routing and cost control · retrieval over a tagged corpus · and a voice front-end that runs a house."
|
||||
claim: "Sam builds and operates sixteen or more AI pipelines: agent harnesses, model routing and cost control, retrieval over a tagged corpus, and a voice front-end that runs a house."
|
||||
confidence: running
|
||||
period: "2020–"
|
||||
tech: [LangGraph, Prefect, n8n, Qdrant, OmniRoute, Whisper, Piper, MiniMax]
|
||||
@@ -13,13 +13,4 @@ public: true
|
||||
last_verified: 2026-10-26
|
||||
tags: [ai, agents, routing, retrieval, voice]
|
||||
---
|
||||
|
||||
# AI & Agentic Systems
|
||||
|
||||
**Claim.** Sixteen or more AI pipelines are maintained in production — ten or more on n8n, Prefect and LangGraph, six on Paperclip — with one gateway in front of six model providers, retrieval over a metadata-tagged corpus, and a voice front-end that controls the house.
|
||||
|
||||
**Status.** Running. Every figure on this page traces to the evidence notes; the verification date is 2026-10-26.
|
||||
|
||||
**Why it reads as engineering rather than enthusiasm.** The pipelines run against real obligations — school portals, calendars, email, household audio — and they are operated: routed for cost, guarded before they act, and observed while they run.
|
||||
|
||||
**Start here.** [The capability page](ai-systems.md) explains what is actually run and what the trade-offs were. The three chips above resolve to artefacts below it.
|
||||
The pipelines run against real obligations — school portals, calendars, email, household audio — and they are operated rather than demonstrated. What actually runs, and what it cost to get there, is on the [capability page](ai-systems.md).
|
||||
|
||||
@@ -14,8 +14,6 @@ last_verified: 2026-10-26
|
||||
tags: [ai, agents, routing, retrieval, voice, guardrails]
|
||||
---
|
||||
|
||||
# Agent systems that are operated rather than demonstrated
|
||||
|
||||
**Capability statement.** This is a working estate, not a prototype: more than sixteen pipelines in production, six providers behind a single gateway, retrieval that filters on metadata before it searches, and a voice front-end used daily by people who did not build it.
|
||||
|
||||
## What is actually run
|
||||
@@ -55,16 +53,4 @@ tags: [ai, agents, routing, retrieval, voice, guardrails]
|
||||
- **Not everything is automated.** Parts of the house are automated end to end and parts still need a person to press the button. The estate is described as it is run, not as it was imagined.
|
||||
- **Cost is bounded by routing, not by hope.** Caps and a refusal path exist so a runaway session cannot become a bill; the routing layer keeps the median request on a cheap model.
|
||||
|
||||
## Drill-downs
|
||||
|
||||
- [House AI hub — service topology](l3/ai-hub-architecture.md) — level 3
|
||||
- [Model routing and cost control](l3/ai-omniroute-routing.md) — level 3
|
||||
- [Paperclip and n8n pipelines in production](l3/ai-paperclip-pipelines.md) — level 3
|
||||
- [Retrieval corpus and query graph](l3/ai-rag-corpus.md) — level 3
|
||||
- [Voice pipeline — speech in and speech out](l3/ai-voice-pipeline.md) — level 3
|
||||
- [Guardrails — hooks in the model path](l3/ai-guardrails.md) — level 3
|
||||
- [Memory in two layers](l3/ai-memory-layers.md) — level 3
|
||||
|
||||
## Period and confidence
|
||||
|
||||
`period: 2020–`. The start is anchored on the homelab year in the evidence notes, because the AI estate and the homelab have the same origin. Confidence is `running`: this is the current arrangement, verified 2026-10-26 against the evidence notes.
|
||||
The period starts at the homelab year in the evidence notes: the AI estate and the homelab have the same origin.
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [ai, guardrails, security, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Guardrails · hooks in the model path
|
||||
|
||||
**Status: stub.** A redacted injection-test result lands in Phase 6, when the guardrail library is wired into this site's own assistant.
|
||||
A redacted injection-test result lands in Phase 6, when the guardrail library is wired into this site's own assistant.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [ai, architecture, artefact]
|
||||
---
|
||||
|
||||
# Artefact — House AI hub service topology
|
||||
|
||||
**Status: stub.** The diagram and the generated topology dump land in Phase 7 (polish). This stub exists so the level 2 page has no dangling reference.
|
||||
The diagram and the generated topology dump land in Phase 7 (polish). This stub exists so the level 2 page has no dangling reference.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [ai, memory, context, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Memory in two layers · Engram and Headroom
|
||||
|
||||
**Status: stub.** A redacted session handoff and a before-and-after context measurement land in Phase 7.
|
||||
A redacted session handoff and a before-and-after context measurement land in Phase 7.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [ai, routing, cost, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Model routing and cost control
|
||||
|
||||
**Status: stub.** The routing decision path and a redacted cost table land in Phase 7. No keys or account identifiers are ever published.
|
||||
The routing decision path and a redacted cost table land in Phase 7. No keys or account identifiers are ever published.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [ai, pipelines, agents, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Paperclip and n8n pipelines in production
|
||||
|
||||
**Status: stub.** A redacted run log of one pipeline lands in Phase 7, alongside the pipeline inventory.
|
||||
A redacted run log of one pipeline lands in Phase 7, alongside the pipeline inventory.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [ai, retrieval, rag, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Retrieval corpus and query graph
|
||||
|
||||
**Status: stub.** The query graph diagram and an ingest run log land later; this site's own corpus build is Phase 4.
|
||||
Ingest and index are live; the diagram below shows both sides, and the query path is labelled as the design it is until Phase 5 ships.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
@@ -26,6 +24,12 @@ The ingest and query path: documents are chunked and tagged with metadata at ing
|
||||
|
||||
Retrieval designed around payload filtering and a hard stop, plus the reasoning for moving off ChromaDB — heavier footprint and weaker filtering — recorded as a decision rather than a migration.
|
||||
|
||||
## The pipeline
|
||||
|
||||

|
||||
|
||||
The separating line is the design decision: metadata is written at ingest, so the query can filter by area, level and source before it pays for a similarity search. The fourth node is the hard stop — an answer that cannot be supported by what was retrieved is refused rather than improvised.
|
||||
|
||||
## When it is delivered
|
||||
|
||||
The query graph diagram in Phase 7. The live demonstration of the same pattern is this site's own retrieval, delivered in Phase 4 and Phase 5.
|
||||
Ingest is delivered and measured (this site's own corpus, Phase 4). The query path is delivered in Phase 5; the streaming console is the visitor-facing half of it.
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [ai, voice, audio, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Voice pipeline · speech in and speech out
|
||||
|
||||
**Status: stub.** A short recording of music ducking under a spoken announcement lands in the optional voice phase. Text describes the chain until then.
|
||||
A short recording of music ducking under a spoken announcement lands in the optional voice phase. Text describes the chain until then.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
109
content/assets/diagrams/homelab-topology.svg
Normal file
109
content/assets/diagrams/homelab-topology.svg
Normal file
@@ -0,0 +1,109 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1110" height="600" viewBox="0 0 1110 600" role="img" aria-labelledby="topoTitle topoDesc" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
|
||||
<title id="topoTitle">Homelab topology: build host, runtime host and public edge</title>
|
||||
<desc id="topoDesc">Three hosts. The build host produces a release; the runtime host runs the site, the vector store, a workflow worker and the model gateway, all bound to loopback; the public edge terminates TLS and is the only host reachable from the internet. All three join one private mesh, and backups run in three tiers.</desc>
|
||||
|
||||
<style>
|
||||
/* On paper the diagram is ink on white, not a dark panel. Presentation
|
||||
attributes are the weakest source, so these rules win without editing
|
||||
the drawing. */
|
||||
@media print {
|
||||
rect { fill: #fff; }
|
||||
text { fill: #111; }
|
||||
text[fill="#6ee7c6"] { fill: #0b5c48; }
|
||||
text[fill="#8b98a8"] { fill: #444; }
|
||||
line, path { stroke: #555; }
|
||||
marker path { fill: #555; }
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0 0 10 5 0 10z" fill="#6ee7c6"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<g fill="#12161b" stroke="#33404d" stroke-width="1.5">
|
||||
<rect x="30" y="104" width="310" height="268" rx="10"/>
|
||||
<rect x="400" y="104" width="310" height="268" rx="10"/>
|
||||
<rect x="770" y="104" width="310" height="268" rx="10"/>
|
||||
</g>
|
||||
|
||||
<!-- internet -->
|
||||
<g>
|
||||
<rect x="810" y="24" width="230" height="46" rx="23" fill="#171d24" stroke="#33404d" stroke-width="1.5"/>
|
||||
<text x="925" y="52" text-anchor="middle" font-size="13" fill="#b3bcc8">public internet · https :443</text>
|
||||
<line x1="925" y1="70" x2="925" y2="100" stroke="#6ee7c6" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
</g>
|
||||
|
||||
<!-- host headers -->
|
||||
<g fill="#e6edf3" font-size="15" font-weight="600">
|
||||
<text x="54" y="136">Build and development</text>
|
||||
<text x="424" y="136">Runtime host</text>
|
||||
<text x="794" y="136">Public edge</text>
|
||||
</g>
|
||||
<g fill="#8b98a8" font-size="12" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">
|
||||
<text x="54" y="156">.27 · 4-screen desktop</text>
|
||||
<text x="424" y="156">.13 · 15 GiB · memory-bound</text>
|
||||
<text x="794" y="156">.35 · Ubuntu · 2 vCPU</text>
|
||||
</g>
|
||||
|
||||
<!-- build host items -->
|
||||
<g fill="#b3bcc8" font-size="12.5">
|
||||
<text x="54" y="192">· repository working copy</text>
|
||||
<text x="54" y="219">· agent fleet, panes and approvals</text>
|
||||
<text x="54" y="246">· content, diagrams and exhibits</text>
|
||||
<text x="54" y="273">· release bundles and hashes</text>
|
||||
</g>
|
||||
|
||||
<!-- runtime: loopback trust boundary -->
|
||||
<rect x="416" y="172" width="278" height="150" rx="8" fill="none" stroke="#6ee7c6" stroke-width="1.2" stroke-dasharray="5 4"/>
|
||||
<text x="430" y="192" font-size="11" fill="#6ee7c6" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">bound to 127.0.0.1</text>
|
||||
<g fill="#b3bcc8" font-size="12.5">
|
||||
<text x="430" y="216">· site server :8096</text>
|
||||
<text x="430" y="241">· Qdrant :6333 / :6334</text>
|
||||
<text x="430" y="266">· Prefect worker</text>
|
||||
<text x="430" y="291">· model gateway (shared)</text>
|
||||
</g>
|
||||
<text x="424" y="345" font-size="11.5" fill="#8b98a8">Caps equal swap; nothing listens publicly.</text>
|
||||
|
||||
<!-- edge host items -->
|
||||
<g fill="#b3bcc8" font-size="12.5">
|
||||
<text x="794" y="192">· Caddy terminates TLS</text>
|
||||
<text x="794" y="219">· proxies to the runtime host</text>
|
||||
<text x="794" y="246">· Gitea · Immich · Nextcloud</text>
|
||||
<text x="794" y="273">· one public listener, many sites</text>
|
||||
</g>
|
||||
|
||||
<!-- hand-off arrows -->
|
||||
<line x1="340" y1="230" x2="396" y2="230" stroke="#6ee7c6" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<text x="368" y="218" text-anchor="middle" font-size="10.5" fill="#8b98a8">deploy</text>
|
||||
<line x1="710" y1="230" x2="766" y2="230" stroke="#6ee7c6" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
<text x="738" y="218" text-anchor="middle" font-size="10.5" fill="#8b98a8">proxy</text>
|
||||
|
||||
<!-- mesh band -->
|
||||
<rect x="30" y="410" width="1050" height="56" rx="10" fill="#12161b" stroke="#33404d" stroke-width="1.2" stroke-dasharray="6 5"/>
|
||||
<text x="555" y="436" text-anchor="middle" font-size="13" fill="#b3bcc8">private mesh · SSH on every host · remote access without an exposed port</text>
|
||||
<text x="555" y="454" text-anchor="middle" font-size="11" fill="#8b98a8">no service is reachable because it happened to bind to every interface</text>
|
||||
<g stroke="#33404d" stroke-width="1" stroke-dasharray="3 4">
|
||||
<line x1="185" y1="372" x2="185" y2="410"/>
|
||||
<line x1="555" y1="372" x2="555" y2="410"/>
|
||||
<line x1="925" y1="372" x2="925" y2="410"/>
|
||||
</g>
|
||||
|
||||
<!-- backup tiers -->
|
||||
<text x="30" y="506" font-size="13" font-weight="600" fill="#e6edf3">Three backup tiers — each covers a different failure</text>
|
||||
<g fill="#12161b" stroke="#33404d" stroke-width="1.2">
|
||||
<rect x="30" y="522" width="330" height="52" rx="8"/>
|
||||
<rect x="390" y="522" width="330" height="52" rx="8"/>
|
||||
<rect x="750" y="522" width="330" height="52" rx="8"/>
|
||||
</g>
|
||||
<g fill="#b3bcc8" font-size="12.5">
|
||||
<text x="50" y="545">local snapshots, on the machine</text>
|
||||
<text x="410" y="545">a copy on another machine</text>
|
||||
<text x="770" y="545">encrypted object storage, offsite</text>
|
||||
</g>
|
||||
<g fill="#8b98a8" font-size="11">
|
||||
<text x="50" y="562">restore tested, runbook beside it</text>
|
||||
<text x="410" y="562">over the private mesh</text>
|
||||
<text x="770" y="562">retention stated in the job</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
119
content/assets/diagrams/rag-pipeline.svg
Normal file
119
content/assets/diagrams/rag-pipeline.svg
Normal file
@@ -0,0 +1,119 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1120" height="580" viewBox="0 0 1120 580" role="img" aria-labelledby="ragTitle ragDesc" font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
|
||||
<title id="ragTitle">Retrieval pipeline: ingest on top, four-node query path below</title>
|
||||
<desc id="ragDesc">Ingest reads content and estate notes, excludes secrets, chunks and tags each block, embeds through the model gateway and upserts into two Qdrant collections on loopback. The query path classifies the question, routes it to an area, retrieves with a payload filter, composes an answer from the retrieved context only, and verifies that every citation resolves to a page.</desc>
|
||||
|
||||
<style>
|
||||
/* On paper the diagram is ink on white, not a dark panel. Presentation
|
||||
attributes are the weakest source, so these rules win without editing
|
||||
the drawing. */
|
||||
@media print {
|
||||
rect { fill: #fff; }
|
||||
text { fill: #111; }
|
||||
text[fill="#6ee7c6"] { fill: #0b5c48; }
|
||||
text[fill="#8b98a8"] { fill: #444; }
|
||||
line, path { stroke: #555; }
|
||||
marker path { fill: #555; }
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0 0 10 5 0 10z" fill="#6ee7c6"/>
|
||||
</marker>
|
||||
<marker id="arrowDim" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0 0 10 5 0 10z" fill="#8b98a8"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- ingest lane -->
|
||||
<text x="30" y="42" font-size="14" font-weight="600" fill="#e6edf3">Ingest — one source of truth, one index</text>
|
||||
|
||||
<g fill="#12161b" stroke="#33404d" stroke-width="1.4">
|
||||
<rect x="30" y="60" width="244" height="92" rx="9"/>
|
||||
<rect x="302" y="60" width="244" height="92" rx="9"/>
|
||||
<rect x="574" y="60" width="244" height="92" rx="9"/>
|
||||
<rect x="846" y="60" width="244" height="92" rx="9"/>
|
||||
</g>
|
||||
<g fill="#e6edf3" font-size="13.5" font-weight="600">
|
||||
<text x="48" y="86">Sources</text>
|
||||
<text x="320" y="86">Normalise</text>
|
||||
<text x="592" y="86">Chunk and tag</text>
|
||||
<text x="864" y="86">Embed and upsert</text>
|
||||
</g>
|
||||
<g fill="#b3bcc8" font-size="12">
|
||||
<text x="48" y="110">content/ Markdown</text>
|
||||
<text x="48" y="129">estate notes</text>
|
||||
<text x="48" y="148">platform guides</text>
|
||||
<text x="320" y="110">front-matter parsed</text>
|
||||
<text x="320" y="129">secrets and drafts</text>
|
||||
<text x="320" y="148">excluded by rule</text>
|
||||
<text x="592" y="110">blocks, not lines</text>
|
||||
<text x="592" y="129">area · level · source</text>
|
||||
<text x="592" y="148">title · tags</text>
|
||||
<text x="864" y="110">model gateway only</text>
|
||||
<text x="864" y="129">deterministic ids</text>
|
||||
<text x="864" y="148">batched, resumable</text>
|
||||
</g>
|
||||
<g stroke="#6ee7c6" stroke-width="1.5" marker-end="url(#arrow)">
|
||||
<line x1="277" y1="106" x2="298" y2="106"/>
|
||||
<line x1="549" y1="106" x2="570" y2="106"/>
|
||||
<line x1="821" y1="106" x2="842" y2="106"/>
|
||||
</g>
|
||||
|
||||
<line x1="968" y1="152" x2="968" y2="184" stroke="#6ee7c6" stroke-width="1.5" marker-end="url(#arrow)"/>
|
||||
|
||||
<rect x="30" y="186" width="1060" height="62" rx="9" fill="#12161b" stroke="#6ee7c6" stroke-width="1.2" stroke-dasharray="5 4"/>
|
||||
<text x="50" y="210" font-size="13" font-weight="600" fill="#e6edf3">Qdrant on 127.0.0.1</text>
|
||||
<text x="50" y="231" font-size="12" fill="#b3bcc8">resume_corpus · one point per chunk resume_graph · one point per node and edge HNSW and payloads on disk · within a 400 MiB cap</text>
|
||||
<text x="50" y="272" font-size="11.5" fill="#8b98a8">A sync run re-embeds only the documents whose content, model or chunker version changed.</text>
|
||||
|
||||
<!-- query lane -->
|
||||
<text x="30" y="316" font-size="14" font-weight="600" fill="#e6edf3">Query</text>
|
||||
<text x="108" y="316" font-size="12" fill="#8b98a8">— four graph nodes. It grows past four only with a written reason.</text>
|
||||
|
||||
<line x1="776" y1="248" x2="776" y2="352" stroke="#8b98a8" stroke-width="1.3" stroke-dasharray="4 4" marker-end="url(#arrowDim)"/>
|
||||
<text x="788" y="330" font-size="11" fill="#8b98a8">payload-filtered search</text>
|
||||
|
||||
<g fill="#12161b" stroke="#33404d" stroke-width="1.4">
|
||||
<rect x="30" y="360" width="196" height="112" rx="9"/>
|
||||
<rect x="246" y="360" width="196" height="112" rx="9"/>
|
||||
<rect x="462" y="360" width="196" height="112" rx="9"/>
|
||||
<rect x="678" y="360" width="196" height="112" rx="9"/>
|
||||
<rect x="894" y="360" width="196" height="112" rx="9"/>
|
||||
</g>
|
||||
<g font-size="10.5" fill="#6ee7c6" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">
|
||||
<text x="46" y="382">CLASSIFY</text>
|
||||
<text x="262" y="382">ROUTE</text>
|
||||
<text x="478" y="382">RETRIEVE</text>
|
||||
<text x="694" y="382">COMPOSE</text>
|
||||
<text x="910" y="382">VERIFY</text>
|
||||
</g>
|
||||
<g fill="#e6edf3" font-size="13" font-weight="600">
|
||||
<text x="46" y="406">Read the question</text>
|
||||
<text x="262" y="406">Pick the areas</text>
|
||||
<text x="478" y="406">Find the evidence</text>
|
||||
<text x="694" y="406">Answer from it</text>
|
||||
<text x="910" y="406">Check citations</text>
|
||||
</g>
|
||||
<g fill="#b3bcc8" font-size="11.5">
|
||||
<text x="46" y="430">intent · injection</text>
|
||||
<text x="46" y="448">off-topic · PII</text>
|
||||
<text x="262" y="430">which pages can</text>
|
||||
<text x="262" y="448">answer this</text>
|
||||
<text x="478" y="430">vector + keyword</text>
|
||||
<text x="478" y="448">ranked, top five</text>
|
||||
<text x="694" y="430">context only</text>
|
||||
<text x="694" y="448">no outside claims</text>
|
||||
<text x="910" y="430">every claim maps</text>
|
||||
<text x="910" y="448">to a real page</text>
|
||||
</g>
|
||||
<g stroke="#6ee7c6" stroke-width="1.5" marker-end="url(#arrow)">
|
||||
<line x1="227" y1="416" x2="242" y2="416"/>
|
||||
<line x1="443" y1="416" x2="458" y2="416"/>
|
||||
<line x1="659" y1="416" x2="674" y2="416"/>
|
||||
<line x1="875" y1="416" x2="890" y2="416"/>
|
||||
</g>
|
||||
|
||||
<text x="30" y="500" font-size="12" fill="#b3bcc8">A refusal is a real outcome: an off-topic question, an injection attempt or a request over the cost cap stops at the first node with zero model spend.</text>
|
||||
<text x="30" y="526" font-size="11.5" fill="#8b98a8">Ingest runs today. The query path is the Phase 5 design; until it ships, every page is pre-rendered and needs no model.</text>
|
||||
<text x="30" y="550" font-size="11.5" fill="#8b98a8">The public AI has no tools, no filesystem, no network fetch and no memory between visitors — architectural, not a prompt instruction.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
@@ -14,8 +14,6 @@ last_verified: 2026-10-26
|
||||
tags: [edge, iot, esp32, lora, zigbee, audio]
|
||||
---
|
||||
|
||||
# Constrained devices · custom radio · and a record of what broke
|
||||
|
||||
**Capability statement.** Devices that must run for months on a small battery, talk over radio that was chosen for range rather than convenience, and fail in ways that are visible. Twenty or more nodes, nine zones, twenty automations, and a voice layer on top of all of it.
|
||||
|
||||
## What is actually run
|
||||
@@ -50,16 +48,4 @@ tags: [edge, iot, esp32, lora, zigbee, audio]
|
||||
- **Some things are still manual.** The garden automation is partly manual — some watering and lighting is scheduled, some is a person turning something on. The page says which is which instead of implying full coverage.
|
||||
- **The failures cost money and are kept.** A lizard destroyed a feeder built for it. The garden cameras were postponed with the parts already bought. The doorbell is being rebuilt rather than declared finished. Those are the most honest evidence of production experience on this site.
|
||||
|
||||
## Drill-downs
|
||||
|
||||
- [Edge architecture — power · radio · deep sleep](l3/edge-architecture.md) — level 3
|
||||
- [LoRa irrigation under solar power](l3/edge-lora-sprinkler.md) — level 3
|
||||
- [Hand-built Zigbee routers and lighting](l3/edge-zigbee.md) — level 3
|
||||
- [Distributed audio — eight endpoints with ducking](l3/edge-snapcast.md) — level 3
|
||||
- [Front door — vision · voice · and a rebuild](l3/edge-front-door.md) — level 3
|
||||
- [Sensors and actuation](l3/edge-sensors.md) — level 3
|
||||
- [The failures, kept in the record](l3/edge-failures.md) — level 3
|
||||
|
||||
## Period and confidence
|
||||
|
||||
`period: 2020–`, anchored on the homelab year in the evidence notes. Confidence is `running` for the fleet as a whole; individual nodes carry their own label, and the ones being rebuilt or postponed are labelled that way on their pages.
|
||||
Individual nodes carry their own label, and the ones being rebuilt or postponed are labelled that way on their pages.
|
||||
|
||||
@@ -3,7 +3,7 @@ id: edge-overview
|
||||
area: edge
|
||||
level: 1
|
||||
title: "Edge & Devices"
|
||||
claim: "Builds and maintains a twenty-node edge fleet — constrained devices · custom radio · solar power · deep sleep · and sensors — reporting into a single automation layer."
|
||||
claim: "Sam builds and maintains a twenty-node edge fleet — constrained devices on solar power and deep sleep, with custom radio and sensors — that reports into a single automation layer."
|
||||
confidence: running
|
||||
period: "2020–"
|
||||
tech: [ESP32, LoRa, Zigbee, MQTT, Home Assistant, Snapcast, Raspberry Pi]
|
||||
@@ -13,13 +13,4 @@ public: true
|
||||
last_verified: 2026-10-26
|
||||
tags: [edge, iot, esp32, lora, zigbee]
|
||||
---
|
||||
|
||||
# Edge & Devices
|
||||
|
||||
**Claim.** Twenty or more nodes — ten ESP32 devices, two hand-built Zigbee routers and eight Pi audio endpoints — across nine lighting zones and twenty automations, all reporting into one automation layer.
|
||||
|
||||
**Status.** Running. Some of it is finished, some is being rebuilt, and at least one thing was destroyed by the animal it was built for. All three states are shown.
|
||||
|
||||
**Why it is credible.** These devices are outdoors, on solar, in deep sleep, and they must keep working when nobody is looking. The failures on this page cost real time and are documented as failures.
|
||||
|
||||
**Start here.** [The capability page](edge-fleet.md) covers the architecture, the radio, the audio and what broke.
|
||||
The devices are outdoors on solar and asleep most of their lives, and they have to keep working when nobody is looking; what failed is written down. The architecture, the radio and the failures are on the [fleet page](edge-fleet.md).
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [edge, architecture, power, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Edge architecture · power · radio · deep sleep
|
||||
|
||||
**Status: stub.** One annotated diagram of a representative node and the radio path lands in Phase 7.
|
||||
One annotated diagram of a representative node and the radio path lands in Phase 7.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [edge, failures, artefact]
|
||||
---
|
||||
|
||||
# Artefact — The failures · kept in the record
|
||||
|
||||
**Status: in progress.** Two items are being rebuilt, one is postponed. The page is not finished because the work is not finished.
|
||||
Two items are being rebuilt, one is postponed. The page is not finished because the work is not finished.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [edge, doorbell, vision, voice, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Front door · vision · voice · and a rebuild
|
||||
|
||||
**Status: in progress.** The device was built and is being rebuilt. The page states this rather than describing it as finished.
|
||||
The device was built and is being rebuilt. The page states this rather than describing it as finished.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [edge, lora, solar, irrigation, artefact]
|
||||
---
|
||||
|
||||
# Artefact — LoRa irrigation under solar power
|
||||
|
||||
**Status: stub.** The bill of materials and the wake-on-radio change land in Phase 7.
|
||||
The bill of materials and the wake-on-radio change land in Phase 7.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [edge, sensors, actuation, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Sensors and actuation
|
||||
|
||||
**Status: stub.** A sensor and actuator table lands in Phase 7.
|
||||
A sensor and actuator table lands in Phase 7.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [edge, audio, snapcast, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Distributed audio · eight endpoints with ducking
|
||||
|
||||
**Status: stub.** The topology diagram and a short recording of ducking land in Phase 7.
|
||||
The topology diagram and a short recording of ducking land in Phase 7.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [edge, zigbee, lighting, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Hand-built Zigbee routers and lighting
|
||||
|
||||
**Status: stub.** The router build and a zone map land in Phase 7.
|
||||
The router build and a zone map land in Phase 7.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ last_verified: 2026-10-26
|
||||
tags: [history, agencies, brands, exhibits]
|
||||
---
|
||||
|
||||
# Fifteen years of delivery for agencies and their clients
|
||||
|
||||
**Capability statement.** Range under deadline. The work in this period ran from car classifieds and online betting to a bank's pitch piece, a broadcaster's on-demand banners and a publisher's education platform — usually with a date attached that could not move.
|
||||
|
||||
## The eras, briefly
|
||||
@@ -52,12 +50,4 @@ The point of the exhibit set is that the tooling changed repeatedly and the deli
|
||||
- **No client has been named beyond a brand.** Brands are named because they were public campaigns. Individuals, internal contacts and unpublished commercial detail are excluded.
|
||||
- **The exhibits are excerpts.** Around forty lines each, chosen to show judgement — structure, error handling, an exit plan — not to demonstrate volume.
|
||||
|
||||
## Drill-downs
|
||||
|
||||
- [The client wall](l3/history-client-wall.md) — level 3
|
||||
- [Six code exhibits across five eras](l3/history-code-exhibits.md) — level 3
|
||||
- [Products — the commercial era that followed](../products/products-business.md) — level 2
|
||||
|
||||
## Period and confidence
|
||||
|
||||
`period: 1999–2014`. Confidence is `historic`: this is work that is finished, and it is presented in the past tense throughout.
|
||||
This work is finished, and it is presented in the past tense throughout.
|
||||
|
||||
@@ -3,7 +3,7 @@ id: history-overview
|
||||
area: history
|
||||
level: 1
|
||||
title: "History"
|
||||
claim: "Delivered interactive work for twenty-two agencies and twenty-three brands between 1999 and 2014 — the commercial apprenticeship that produced the range."
|
||||
claim: "Sam delivered interactive work for twenty-two agencies and twenty-three brands between 1999 and 2014 — the commercial apprenticeship that produced the range."
|
||||
confidence: historic
|
||||
period: "1999–2014"
|
||||
tech: [ActionScript, PHP, JavaScript, MySQL, XML, React, Python, Go, HTMX]
|
||||
@@ -13,13 +13,4 @@ public: true
|
||||
last_verified: 2026-10-26
|
||||
tags: [history, agencies, brands, career]
|
||||
---
|
||||
|
||||
# History
|
||||
|
||||
**Claim.** Fifteen years of agency and product work — national campaigns, bank and broadcaster platforms, betting front-ends, and a lot of rescue missions — for twenty-two agencies and twenty-three brands.
|
||||
|
||||
**Status.** Historic. This is the 1999–2014 era, presented as what it was: the commercial apprenticeship that produced the range.
|
||||
|
||||
**What it is not.** It is not a Flash page. The languages changed six times across this period — ActionScript, PHP, JavaScript, React, Python, and now Go with HTMX — and the exhibits are chosen to show that rather than to show a decade.
|
||||
|
||||
**Start here.** [The engagement record](history-engagements.md) explains the eras, the work and what each exhibit demonstrates.
|
||||
The languages changed six times across the period — ActionScript, PHP, JavaScript, React, Python, and now Go with HTMX — and the exhibits are chosen to show that rather than to show a decade. The era-by-era record is on the [engagement record](history-engagements.md).
|
||||
|
||||
@@ -14,17 +14,66 @@ last_verified: 2026-10-26
|
||||
tags: [history, clients, artefact]
|
||||
---
|
||||
|
||||
# Artefact — The client wall
|
||||
|
||||
**Status: stub for the visual treatment.** The names are final; the layout, logo set and typography land in Phase 7 (the logo set is explicitly a Phase 7 decision).
|
||||
The names are final. The treatment is deliberate: one typeface, one size, one grid, and no logo collage — see *Why there are no logos here* below.
|
||||
|
||||
## The agencies
|
||||
|
||||
Clemenger · Agency Republic · Brand Union · Jackpot Joy · Bryce Ford · RMG Connect · Sporting Index · Adventis Digital · Trinity Street · Inferno · SAS Design · Ogilvy · Wunderman · Illumina · Barkers · Mook · Pearsons · Penguin · Draft London · Behan Consulting · MCM Entertainment · Carsales
|
||||
<div class="client-wall" role="list" aria-label="Agencies">
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Clemenger</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Agency Republic</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Brand Union</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Jackpot Joy</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Bryce Ford</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">RMG Connect</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Sporting Index</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Adventis Digital</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Trinity Street</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Inferno</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">SAS Design</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Ogilvy</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Wunderman</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Illumina</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Barkers</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Mook</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Pearsons</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Penguin</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Draft London</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Behan Consulting</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">MCM Entertainment</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Carsales</span></div>
|
||||
</div>
|
||||
|
||||
## The brands
|
||||
|
||||
Carlton Draught · Diageo · KPMG · McDonald's · Accenture · BBC National Archive · Bank of America · War of the Worlds · Madonna (Hard Candy) · PlayStation · HSBC · DHL · Reuters · Volvic · AutoCAD · Channel 4 · Pearsons · Shell · Stella Artois · Baileys Irish Cream · Parkgate Homes · Carlton Dry · Cottees Cordial
|
||||
<div class="client-wall" role="list" aria-label="Brands">
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Carlton Draught</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Diageo</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">KPMG</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">McDonald's</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Accenture</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">BBC National Archive</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Bank of America</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">War of the Worlds</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Madonna (Hard Candy)</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">PlayStation</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">HSBC</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">DHL</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Reuters</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Volvic</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">AutoCAD</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Channel 4</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Pearsons</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Shell</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Stella Artois</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Baileys Irish Cream</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Parkgate Homes</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Carlton Dry</span></div>
|
||||
<div class="client-tile" role="listitem"><span class="client-name">Cottees Cordial</span></div>
|
||||
</div>
|
||||
|
||||
## Why there are no logos here
|
||||
|
||||
A wall of mixed marks — some full colour, some monochrome, some missing — reads as broken, and the eight marks that exist for these names are not the eight the work is best remembered by. One typeface, one size and one grid says the same thing more clearly, and it sits beside the technical pages without competing with them. A mark is added only when it is the client's official mark and Sam has approved its use; none has been invented.
|
||||
|
||||
## Corrections carried into this list
|
||||
|
||||
@@ -38,4 +87,4 @@ The summary material said twenty-three agencies. The recovered, verified list ho
|
||||
|
||||
## When it is delivered
|
||||
|
||||
Phase 7 for the visual treatment. Names and one-line descriptions are approved as recovered and corrected.
|
||||
Delivered as a body of names and descriptions, and presented as a typographic wall from Phase 7. Names and one-line descriptions are approved as recovered and corrected.
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [history, code, exhibits, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Six code exhibits across five eras
|
||||
|
||||
**Status: stub.** The excerpts are selected from source that exists on disk; each one lands in Phase 7 with two sentences on why it is good code.
|
||||
The excerpts are selected from source that exists on disk; each one lands in Phase 7 with two sentences on why it is good code.
|
||||
|
||||
## The six
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [platform, backups, restore, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Backups and restore runbook
|
||||
|
||||
**Status: stub.** Redacted job definitions and the restore runbook land in Phase 7. Passphrase locations and remote paths are removed before publication.
|
||||
Redacted job definitions and the restore runbook land in Phase 7. Passphrase locations and remote paths are removed before publication.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [platform, docker, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Container inventory
|
||||
|
||||
**Status: stub.** A digest of the inventory — grouped by purpose, with addresses removed — lands in Phase 7.
|
||||
A digest of the inventory — grouped by purpose, with addresses removed — lands in Phase 7.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [platform, networking, security, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Network layout and remote access
|
||||
|
||||
**Status: stub.** The topology diagram lands in Phase 7 with addresses and hostnames redacted.
|
||||
The diagram below shows three hosts and one trust boundary; internal addresses, hostnames and public DNS names are generalised, not published.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
@@ -26,6 +24,12 @@ The layout: hosts on a private mesh for remote access, SSH on every machine, a p
|
||||
|
||||
Security posture chosen rather than inherited: no service is reachable because it happened to bind to all interfaces, and there is exactly one place where the outside world meets the estate.
|
||||
|
||||
## The layout
|
||||
|
||||

|
||||
|
||||
Read it left to right: work is built on one machine, runs on a memory-bound machine with every service bound to loopback, and is published by a single edge that terminates TLS. The dashed boundary is the point of the whole arrangement — crossing it is a deliberate change to the edge configuration, not a side effect of starting a container. Below both, the mesh carries remote access and the second backup tier, so neither depends on a port being open.
|
||||
|
||||
## When it is delivered
|
||||
|
||||
Phase 7. Internal addressing, hostnames and public DNS names are excluded from publication by the redaction pass.
|
||||
Delivered. Internal addressing, hostnames and public DNS names are excluded from publication by the redaction pass.
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [platform, nixos, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Declarative multi-host configuration
|
||||
|
||||
**Status: stub.** A redacted excerpt of the configuration tree lands in Phase 7. No hostnames, addresses or keys are published.
|
||||
A redacted excerpt of the configuration tree lands in Phase 7. No hostnames, addresses or keys are published.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [platform, signage, hdmi-cec, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Signage infrastructure · screens as a managed fleet
|
||||
|
||||
**Status: stub.** The CEC command set and a layout diagram land in Phase 7.
|
||||
The CEC command set and a layout diagram land in Phase 7.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [platform, nix, packaging, artefact]
|
||||
---
|
||||
|
||||
# Artefact — A hand-written Nix package with an exit plan
|
||||
|
||||
**Status: stub.** The whole file is short enough to publish verbatim; it lands in Phase 7.
|
||||
The whole file is short enough to publish verbatim; it lands in Phase 7.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ last_verified: 2026-10-26
|
||||
tags: [platform, devops, nixos, docker, backups, networking]
|
||||
---
|
||||
|
||||
# A small estate run to a documented standard
|
||||
|
||||
**Capability statement.** Four hosts, fifty or more containers, configuration in version control instead of in a shell history, and one place where traffic enters. The estate is deliberately small because it is run rather than demonstrated.
|
||||
|
||||
## What is actually run
|
||||
@@ -51,16 +49,3 @@ tags: [platform, devops, nixos, docker, backups, networking]
|
||||
- **Self-hosting is a choice with a cost.** Monitoring, backups, ingress and the AI gateway are all operated here, which means the failure modes are also operated here. The mitigation is documentation and measurement, not optimism.
|
||||
- **Rejected on purpose.** A long list of tools was evaluated and refused — Airflow for RAM, self-hosted Langfuse for six containers it could not afford, LiteLLM because it would duplicate the existing gateway, and a pile of documentation platforms. "Alternatives considered" is kept because the reasoning is the senior artifact.
|
||||
- **Not everything is automated.** Some operations are still a documented command run by a person. The runbook exists precisely because the automations do not cover everything.
|
||||
|
||||
## Drill-downs
|
||||
|
||||
- [Declarative multi-host configuration](l3/platform-nixos.md) — level 3
|
||||
- [A hand-written Nix package with an exit plan](l3/platform-tuios-package.md) — level 3
|
||||
- [Container inventory](l3/platform-containers.md) — level 3
|
||||
- [Backups and restore runbook](l3/platform-backups.md) — level 3
|
||||
- [Network layout and remote access](l3/platform-network.md) — level 3
|
||||
- [Signage infrastructure — screens as a managed fleet](l3/platform-signage.md) — level 3
|
||||
|
||||
## Period and confidence
|
||||
|
||||
`period: 2020–`, anchored on the homelab year in the evidence notes. Confidence is `running`: this is the current estate, verified 2026-10-26.
|
||||
|
||||
@@ -3,7 +3,7 @@ id: platform-overview
|
||||
area: platform
|
||||
level: 1
|
||||
title: "Platform & Infrastructure"
|
||||
claim: "Runs a multi-machine NixOS and Proxmox estate — fifty-plus containers · declarative configuration · encrypted backups with a written restore runbook · and public ingress — sized to the hardware rather than the budget."
|
||||
claim: "Sam runs a multi-machine NixOS and Proxmox estate sized to the hardware rather than the budget: fifty-plus containers, declarative configuration, encrypted backups with a written restore runbook, and public ingress."
|
||||
confidence: running
|
||||
period: "2020–"
|
||||
tech: [NixOS, Proxmox, Docker, Caddy, Tailscale, Borg, Kopia, AWS S3]
|
||||
@@ -13,13 +13,4 @@ public: true
|
||||
last_verified: 2026-10-26
|
||||
tags: [platform, devops, nixos, docker, backups]
|
||||
---
|
||||
|
||||
# Platform & Infrastructure
|
||||
|
||||
**Claim.** Fifty or more containers on a four-host estate, configuration declared rather than hand-applied, encrypted backups on three machines with a restore runbook beside them, and one public listener at the edge.
|
||||
|
||||
**Status.** Running. Figures trace to the approved metric set and the infrastructure reference; verified 2026-10-26.
|
||||
|
||||
**The constraint that shaped it.** The runtime host is memory-bound rather than CPU-bound. Every service in this estate carries a memory cap and is bound to loopback, which is why the platform reads as engineering under constraint instead of hardware shopping.
|
||||
|
||||
**Start here.** [The capability page](platform-estate.md) covers what runs, where, and what was rejected.
|
||||
The runtime host is memory-bound rather than CPU-bound, so every service carries a memory cap and is bound to loopback. What runs, where, and what was rejected is on the [estate page](platform-estate.md).
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [products, signage, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Commercial signage on commodity hardware
|
||||
|
||||
**Status: delivered, with a rebuild in progress.** The product shipped and customers still run it. The rebuild on the current stack is not finished and is labelled as unfinished everywhere it is mentioned.
|
||||
The product shipped and customers still run it. The rebuild on the current stack is not finished and is labelled as unfinished everywhere it is mentioned.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [products, cms, media, artefact]
|
||||
---
|
||||
|
||||
# Artefact — The site builder behind the signage
|
||||
|
||||
**Status: delivered, retained.** Retired as a general-purpose product; kept as the content engine for the signage.
|
||||
Retired as a general-purpose product; kept as the content engine for the signage.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ last_verified: 2026-10-26
|
||||
tags: [products, where-woof, artefact]
|
||||
---
|
||||
|
||||
# Artefact — Where-Woof · deployed and pre-market
|
||||
|
||||
**Status: running, pre-market.** This is the exact wording the site uses, deliberately: deployed is true, commercial is not.
|
||||
Deployed is true, commercial is not — that is the exact wording the site uses, deliberately.
|
||||
|
||||
## What the artefact is
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ last_verified: 2026-10-26
|
||||
tags: [products, business, signage, where-woof, positioning]
|
||||
---
|
||||
|
||||
# Software sold and operated — with the status stated
|
||||
|
||||
**Capability statement.** Building software for a client and building software as a product are different jobs. This page is the second one: a market that was overpriced, a substitute built on hardware that costs a fraction of the incumbent systems, customers who adopted it, and the operational cost of keeping it running.
|
||||
|
||||
## The commercial story
|
||||
@@ -55,13 +53,6 @@ The third product is different in kind: Where-Woof is a QR and RFID tag system f
|
||||
- **One product is pre-revenue by design.** Where-Woof is deployed before it is sold. Describing it as commercial would be a lie that a careful reader would find in a minute.
|
||||
- **Support is the hidden cost.** Customers running the product for years is the strongest evidence of value and the strongest argument for keeping the surface small.
|
||||
|
||||
## Drill-downs
|
||||
Signage infrastructure is also platform work: [screens as a managed fleet](../platform/l3/platform-signage.md).
|
||||
|
||||
- [Commercial signage on commodity hardware](l3/products-signage.md) — level 3
|
||||
- [The site builder behind it](l3/products-websitebuilder.md) — level 3
|
||||
- [Where-Woof — deployed and pre-market](l3/products-where-woof.md) — level 3
|
||||
- [Signage infrastructure — screens as a managed fleet](../platform/l3/platform-signage.md) — level 3
|
||||
|
||||
## Period and confidence
|
||||
|
||||
`period: 2014–`, anchored on the first freelance product work in the résumé. Confidence is `running`: the products exist and customers still use one of them. Individual products carry their own status, and the two caveats — a rebuild in progress and a launch not yet made — are in the table above rather than in a footnote.
|
||||
Each product carries its own status, and the two caveats — a rebuild in progress and a launch not yet made — are in the table above rather than in a footnote.
|
||||
|
||||
@@ -3,7 +3,7 @@ id: products-overview
|
||||
area: products
|
||||
level: 1
|
||||
title: "Products"
|
||||
claim: "Identified an overpriced market and shipped a cheaper substitute on commodity hardware — twelve adoptions over time and three businesses still running it."
|
||||
claim: "Sam identified an overpriced market and shipped a cheaper substitute on commodity hardware: twelve adoptions over time and three businesses still running it."
|
||||
confidence: running
|
||||
period: "2014–"
|
||||
tech: [PHP, JavaScript, three.js, FFmpeg, ImageMagick, HDMI-CEC, Raspberry Pi, ESP32]
|
||||
@@ -13,13 +13,4 @@ public: true
|
||||
last_verified: 2026-10-26
|
||||
tags: [products, signage, business, where-woof]
|
||||
---
|
||||
|
||||
# Products
|
||||
|
||||
**Claim.** A digital signage product built as deliberate disruption to thousand-dollar wall systems — three businesses still run it — plus the site builder that produced the marketing sites and a lost-property tag system that is deployed but not yet sold.
|
||||
|
||||
**Status.** Running. The counts are the corrected ones: three businesses active now, twelve adoptions over time, and Where-Woof deployed without a market launch.
|
||||
|
||||
**Why it is on an engineering CV.** Selling, deploying and still supporting software for other businesses is a different discipline from writing it. Two of the three products carry honest caveats that are stated rather than hidden: the signage platform is being rebuilt, and Where-Woof has not gone to market.
|
||||
|
||||
**Start here.** [The commercial story](products-business.md) covers what each product is, what state it is in, and what it cost to run.
|
||||
Building, selling, deploying and still supporting software for other businesses is a different discipline from writing it, and the caveats on these three products are stated rather than hidden. The commercial story is on the [products page](products-business.md).
|
||||
|
||||
22
corpus/__init__.py
Normal file
22
corpus/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Phase 4 — the retrievable corpus.
|
||||
|
||||
`content/` is the source of truth. This package turns it into the two indexes
|
||||
the AI console reads:
|
||||
|
||||
* **vectors** in Qdrant, one point per chunk, with the payload that lets the
|
||||
query path filter *before* searching (`area`, `confidence`);
|
||||
* **keywords** in SQLite FTS5, one row per chunk, so exact tokens (repo names,
|
||||
`LoRaWAN`, `MQTT`, dates) are not lost the way they are with vectors alone
|
||||
(decision D10, hybrid retrieval).
|
||||
|
||||
Everything here is driven by `python3 -m corpus.cli`; `docs/phase-4-notes.md`
|
||||
is the operator manual and records what was measured. The pipeline is
|
||||
Provider-agnostic by design: `corpus/embeddings.py` is the only place that knows
|
||||
how a vector is made, and the provider + model + dimension are recorded per
|
||||
collection so a provider change is a governed migration, not silent corruption.
|
||||
|
||||
Standard library only, plus the repository's own validator (`tools/`). No new
|
||||
dependency, no Node, no framework (AGENTS.md section 4.1).
|
||||
"""
|
||||
|
||||
__all__ = ["config", "loader", "chunker", "payloads", "embeddings", "qdrant", "store", "pipeline", "freshness"]
|
||||
8
corpus/__main__.py
Normal file
8
corpus/__main__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""`python3 -m corpus` is the same as `python3 -m corpus.cli`."""
|
||||
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
287
corpus/chunker.py
Normal file
287
corpus/chunker.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""Code-aware chunking.
|
||||
|
||||
The rules come from `docs/phase-4-corpus.md` §4.1:
|
||||
|
||||
* 400–800 tokens per chunk, about 12% overlap;
|
||||
* **never split inside a fenced block, a table or a front-matter block.**
|
||||
|
||||
The last rule is structural here, not a post-hoc check: the body is parsed into
|
||||
*blocks* first and chunks are packed out of whole blocks, so a boundary can only
|
||||
ever fall between blocks. Fenced code, tables, headings, list items and
|
||||
paragraphs are all blocks; the large ones cannot be subdivided.
|
||||
|
||||
Front-matter is removed by `corpus.loader` before this module sees the body, and
|
||||
parsing a body that still has front-matter is an error, so it cannot be split
|
||||
either.
|
||||
|
||||
`tokens` are estimated with a word-and-punctuation regex. A real BPE tokenizer
|
||||
counts 15–30% more for prose and more again for code, so a chunk that measures
|
||||
800 here is a conservative upper bound.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Packing targets (docs/phase-4-corpus.md §4.1).
|
||||
TOKEN_TARGET = 600
|
||||
TOKEN_MAX = 800
|
||||
TOKEN_MIN = 400
|
||||
OVERLAP_RATIO = 0.12
|
||||
|
||||
FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
|
||||
HEADING_RE = re.compile(r"^ {0,3}#{1,6}\s")
|
||||
LIST_RE = re.compile(r"^(\s*)([-*+]|\d{1,3}[.)])\s+")
|
||||
RULE_RE = re.compile(r"^ {0,3}([-*_])(\s*\1){2,}\s*$")
|
||||
TOKEN_RE = re.compile(r"\w+|[^\w\s]", re.UNICODE)
|
||||
|
||||
PROTECTED_KINDS = ("fence", "table")
|
||||
|
||||
|
||||
class ChunkError(RuntimeError):
|
||||
"""The body could not be chunked without breaking the rules."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Block:
|
||||
"""An indivisible unit of Markdown. Never split, only packed."""
|
||||
|
||||
kind: str # fence | table | heading | list | quote | para | rule
|
||||
text: str
|
||||
tokens: int
|
||||
line_start: int # 1-based, relative to the body this block came from
|
||||
line_end: int
|
||||
|
||||
@property
|
||||
def protected(self) -> bool:
|
||||
return self.kind in PROTECTED_KINDS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Chunk:
|
||||
index: int
|
||||
text: str
|
||||
tokens: int
|
||||
line_start: int
|
||||
line_end: int
|
||||
blocks: tuple
|
||||
oversize: bool = False
|
||||
|
||||
@property
|
||||
def kinds(self) -> tuple:
|
||||
return tuple(block.kind for block in self.blocks)
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
"""Word-plus-punctuation token estimate. Deterministic and cheap."""
|
||||
return len(TOKEN_RE.findall(text))
|
||||
|
||||
|
||||
def parse_blocks(body: str) -> list:
|
||||
"""Split a Markdown body into atomic blocks.
|
||||
|
||||
Raises ChunkError if the body still carries a front-matter block: that is a
|
||||
loader bug, and silently chunking it would split something that must not be
|
||||
split.
|
||||
"""
|
||||
if body.lstrip().startswith("---"):
|
||||
raise ChunkError(
|
||||
"body still starts with a front-matter block; strip front-matter "
|
||||
"before chunking (corpus.loader does this)"
|
||||
)
|
||||
|
||||
lines = body.split("\n")
|
||||
blocks = []
|
||||
i = 0
|
||||
total = len(lines)
|
||||
while i < total:
|
||||
raw = lines[i]
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
i += 1
|
||||
continue
|
||||
start = i
|
||||
fence = FENCE_RE.match(raw)
|
||||
if fence:
|
||||
i = _read_fence(lines, i, fence.group(1))
|
||||
kind = "fence"
|
||||
elif stripped.startswith("|"):
|
||||
while i < total and lines[i].strip().startswith("|"):
|
||||
i += 1
|
||||
kind = "table"
|
||||
elif HEADING_RE.match(raw):
|
||||
i += 1
|
||||
kind = "heading"
|
||||
elif RULE_RE.match(raw):
|
||||
i += 1
|
||||
kind = "rule"
|
||||
elif LIST_RE.match(raw):
|
||||
i = _read_list_item(lines, i)
|
||||
kind = "list"
|
||||
elif stripped.startswith(">"):
|
||||
while i < total and lines[i].strip().startswith(">"):
|
||||
i += 1
|
||||
kind = "quote"
|
||||
else:
|
||||
i = _read_paragraph(lines, i)
|
||||
kind = "para"
|
||||
text = "\n".join(lines[start:i])
|
||||
blocks.append(
|
||||
Block(kind=kind, text=text, tokens=estimate_tokens(text), line_start=start + 1, line_end=i)
|
||||
)
|
||||
return blocks
|
||||
|
||||
|
||||
def _read_fence(lines: list, i: int, marker: str) -> int:
|
||||
"""Return the index just past a fenced block. Unterminated runs to EOF."""
|
||||
char = marker[0]
|
||||
width = len(marker)
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
candidate = FENCE_RE.match(lines[i])
|
||||
if candidate and candidate.group(1)[0] == char and len(candidate.group(1)) >= width:
|
||||
return i + 1
|
||||
i += 1
|
||||
return len(lines)
|
||||
|
||||
|
||||
def _read_list_item(lines: list, i: int) -> int:
|
||||
"""Return the index just past one top-level list item, continuations included."""
|
||||
base_indent = len(LIST_RE.match(lines[i]).group(1))
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
raw = lines[i]
|
||||
if not raw.strip():
|
||||
# A blank line continues the item only if an indented line follows.
|
||||
if i + 1 < len(lines) and lines[i + 1].startswith(" " * (base_indent + 1)):
|
||||
i += 1
|
||||
continue
|
||||
return i
|
||||
indent = len(raw) - len(raw.lstrip())
|
||||
if indent > base_indent or LIST_RE.match(raw) and len(LIST_RE.match(raw).group(1)) > base_indent:
|
||||
i += 1
|
||||
continue
|
||||
if LIST_RE.match(raw):
|
||||
return i
|
||||
if indent <= base_indent:
|
||||
return i
|
||||
i += 1
|
||||
return i
|
||||
|
||||
|
||||
def _read_paragraph(lines: list, i: int) -> int:
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
raw = lines[i]
|
||||
stripped = raw.strip()
|
||||
if not stripped:
|
||||
return i
|
||||
if FENCE_RE.match(raw) or HEADING_RE.match(raw) or RULE_RE.match(raw):
|
||||
return i
|
||||
if stripped.startswith("|") or stripped.startswith(">") or LIST_RE.match(raw):
|
||||
return i
|
||||
i += 1
|
||||
return i
|
||||
|
||||
|
||||
def chunk_blocks(blocks: list) -> list:
|
||||
"""Pack blocks into windows of at most TOKEN_MAX, with ~12% overlap."""
|
||||
chunks = []
|
||||
total = len(blocks)
|
||||
if total == 0:
|
||||
return chunks
|
||||
i = 0
|
||||
while i < total:
|
||||
start = i
|
||||
tokens = 0
|
||||
end = i
|
||||
while end < total:
|
||||
block = blocks[end]
|
||||
if end > start:
|
||||
if tokens + block.tokens > TOKEN_MAX:
|
||||
break
|
||||
# Prefer breaking in front of a heading.
|
||||
if block.kind == "heading" and tokens >= int(TOKEN_TARGET * 0.75):
|
||||
break
|
||||
tokens += block.tokens
|
||||
end += 1
|
||||
if tokens >= TOKEN_TARGET and end < total and blocks[end].kind == "heading":
|
||||
break
|
||||
window = blocks[start:end]
|
||||
chunks.append(_make_chunk(len(chunks), window))
|
||||
if end >= total:
|
||||
break
|
||||
# Back up over trailing blocks until roughly OVERLAP_RATIO of the window
|
||||
# repeats, leaving at least one block behind so the walk always advances.
|
||||
budget = max(1, int(round(TOKEN_TARGET * OVERLAP_RATIO)))
|
||||
carried = 0
|
||||
back = end
|
||||
while back - 1 > start and carried + blocks[back - 1].tokens <= budget * 2:
|
||||
carried += blocks[back - 1].tokens
|
||||
back -= 1
|
||||
if carried >= budget:
|
||||
break
|
||||
i = max(back, start + 1)
|
||||
return chunks
|
||||
|
||||
|
||||
def _make_chunk(index: int, window: list) -> Chunk:
|
||||
text = "\n\n".join(block.text for block in window)
|
||||
return Chunk(
|
||||
index=index,
|
||||
text=text,
|
||||
tokens=sum(block.tokens for block in window),
|
||||
line_start=window[0].line_start,
|
||||
line_end=window[-1].line_end,
|
||||
blocks=tuple(window),
|
||||
oversize=len(window) == 1 and window[0].tokens > TOKEN_MAX,
|
||||
)
|
||||
|
||||
|
||||
def chunk_document(body: str) -> list:
|
||||
"""Chunk one document body. The pipeline calls this and nothing else."""
|
||||
chunks = chunk_blocks(parse_blocks(body))
|
||||
verify_no_split(chunks, protected_regions(body))
|
||||
return chunks
|
||||
|
||||
|
||||
def protected_regions(body: str) -> list:
|
||||
"""Line ranges (1-based, inclusive) that a chunk boundary must not cross.
|
||||
|
||||
Recomputed from the body text alone, independently of the packing code, so
|
||||
the check is not the implementation grading its own homework.
|
||||
"""
|
||||
regions = []
|
||||
lines = body.split("\n")
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
raw = lines[i]
|
||||
fence = FENCE_RE.match(raw)
|
||||
if fence:
|
||||
end = _read_fence(lines, i, fence.group(1))
|
||||
regions.append((i + 1, end))
|
||||
i = end
|
||||
continue
|
||||
if raw.strip().startswith("|"):
|
||||
start = i
|
||||
while i < len(lines) and lines[i].strip().startswith("|"):
|
||||
i += 1
|
||||
regions.append((start + 1, i))
|
||||
continue
|
||||
i += 1
|
||||
return regions
|
||||
|
||||
|
||||
def verify_no_split(chunks: list, regions: list) -> None:
|
||||
"""Raise ChunkError if any chunk boundary cuts a protected region."""
|
||||
for chunk in chunks:
|
||||
for first, last in regions:
|
||||
spans_start = chunk.line_start <= first <= chunk.line_end
|
||||
spans_end = chunk.line_start <= last <= chunk.line_end
|
||||
covers = chunk.line_start <= first and chunk.line_end >= last
|
||||
if (spans_start or spans_end) and not covers:
|
||||
raise ChunkError(
|
||||
f"chunk {chunk.index} splits protected lines {first}-{last} "
|
||||
f"(chunk covers {chunk.line_start}-{chunk.line_end})"
|
||||
)
|
||||
257
corpus/cli.py
Normal file
257
corpus/cli.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""Command line entry point: `python3 -m corpus.cli <command>`.
|
||||
|
||||
Commands
|
||||
|
||||
sync incremental update of the corpus (the normal command)
|
||||
rebuild drop the collection and rebuild it from content/ (recovery)
|
||||
audit freshness audit; reports stale documents, index drift
|
||||
verify does the index agree with content/ and with Qdrant?
|
||||
demo-filter the same query with and without an `area` filter
|
||||
hybrid vectors + FTS5 fused with reciprocal rank fusion
|
||||
measure Qdrant memory at 500 / 5,000 / 20,000 chunks
|
||||
info what this build thinks it is configured to do
|
||||
|
||||
Exit codes: 0 success, 1 findings or verification problems, 2 configuration or
|
||||
usage error. Every command accepts `--json` where a machine-readable result is
|
||||
useful (Prefect reads JSON, a human reads the table).
|
||||
|
||||
Configuration is environment-first (`corpus/config.py`); the flags exist so a
|
||||
one-off run can point at a scratch Qdrant without exporting variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from . import config as config_module
|
||||
from . import freshness, pipeline, store as store_module
|
||||
from .config import Config, ConfigError
|
||||
from .qdrant import Qdrant, QdrantError
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
if args.command is None:
|
||||
parser.print_help()
|
||||
return 2
|
||||
|
||||
overrides = {
|
||||
"root": args.root,
|
||||
"qdrant_url": args.qdrant_url,
|
||||
"collection": args.collection,
|
||||
"provider": args.provider,
|
||||
"model": args.model,
|
||||
"dim": args.dim,
|
||||
"db_path": args.db,
|
||||
"data_dir": args.data,
|
||||
}
|
||||
try:
|
||||
cfg = Config.from_env(**overrides)
|
||||
except ConfigError as exc:
|
||||
print(f"configuration error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
log = (lambda _msg: None) if args.quiet else (lambda msg: print(msg, file=sys.stderr))
|
||||
|
||||
try:
|
||||
handler = {
|
||||
"sync": _cmd_sync,
|
||||
"rebuild": _cmd_rebuild,
|
||||
"audit": _cmd_audit,
|
||||
"verify": _cmd_verify,
|
||||
"demo-filter": _cmd_demo_filter,
|
||||
"hybrid": _cmd_hybrid,
|
||||
"measure": _cmd_measure,
|
||||
"info": _cmd_info,
|
||||
}[args.command]
|
||||
return handler(cfg, args, log)
|
||||
except (pipeline.PipelineError, ConfigError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
except QdrantError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python3 -m corpus.cli",
|
||||
description="Build and audit the Ai Resume retrieval corpus (docs/phase-4-corpus.md).",
|
||||
)
|
||||
parser.add_argument("--root", help="repository root (default: parent of corpus/)")
|
||||
parser.add_argument("--qdrant-url", help="Qdrant REST URL (default http://127.0.0.1:6333)")
|
||||
parser.add_argument("--collection", help="Qdrant collection name (default resume_corpus)")
|
||||
parser.add_argument("--provider", choices=sorted(config_module.PROVIDERS), help="embedding provider")
|
||||
parser.add_argument("--model", help="embedding model name")
|
||||
parser.add_argument("--dim", type=int, help="embedding dimension")
|
||||
parser.add_argument("--db", help="SQLite corpus database path")
|
||||
parser.add_argument("--data", help="corpus data directory")
|
||||
parser.add_argument("--quiet", action="store_true", help="progress on stderr only becomes silent")
|
||||
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
sync = sub.add_parser("sync", help="incremental update")
|
||||
sync.add_argument("--json", action="store_true")
|
||||
|
||||
rebuild = sub.add_parser("rebuild", help="drop the collection and rebuild from content/")
|
||||
rebuild.add_argument("--json", action="store_true")
|
||||
|
||||
audit = sub.add_parser("audit", help="freshness and drift audit")
|
||||
audit.add_argument("--json", action="store_true")
|
||||
audit.add_argument("--today", help="audit as of this date (YYYY-MM-DD), for tests")
|
||||
audit.add_argument("--fail-on-findings", action="store_true", help="exit 1 when anything is found")
|
||||
audit.add_argument("--alert", action="store_true", help="POST the report to RESUME_APPRISE_URL")
|
||||
|
||||
verify = sub.add_parser("verify", help="check the index against content/ and Qdrant")
|
||||
verify.add_argument("--json", action="store_true")
|
||||
|
||||
demo = sub.add_parser("demo-filter", help="same query with and without an area filter")
|
||||
demo.add_argument("--query", required=True)
|
||||
demo.add_argument("--area", help="area to filter on (ai, platform, edge, products, history, about)")
|
||||
demo.add_argument("--limit", type=int, default=5)
|
||||
demo.add_argument("--json", action="store_true")
|
||||
|
||||
hybrid = sub.add_parser("hybrid", help="vectors + FTS5 fused with RRF")
|
||||
hybrid.add_argument("--query", required=True)
|
||||
hybrid.add_argument("--area")
|
||||
hybrid.add_argument("--limit", type=int, default=5)
|
||||
hybrid.add_argument("--json", action="store_true")
|
||||
|
||||
measure = sub.add_parser("measure", help="Qdrant memory at three corpus sizes")
|
||||
measure.add_argument("--sizes", default="500,5000,20000")
|
||||
measure.add_argument("--container", default="", help="Qdrant container name for docker stats")
|
||||
measure.add_argument("--keep", action="store_true", help="keep the measurement collection")
|
||||
measure.add_argument("--json", action="store_true")
|
||||
|
||||
sub.add_parser("info", help="print the resolved configuration")
|
||||
return parser
|
||||
|
||||
|
||||
# -- commands --------------------------------------------------------------
|
||||
|
||||
|
||||
def _cmd_sync(cfg, args, log) -> int:
|
||||
stats = pipeline.run(cfg, mode="sync", log=log)
|
||||
if args.json:
|
||||
print(json.dumps(stats.to_dict(), indent=2, sort_keys=True))
|
||||
else:
|
||||
print(stats.summary())
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_rebuild(cfg, args, log) -> int:
|
||||
stats = pipeline.run(cfg, mode="rebuild", log=log)
|
||||
if args.json:
|
||||
print(json.dumps(stats.to_dict(), indent=2, sort_keys=True))
|
||||
else:
|
||||
print(stats.summary())
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_audit(cfg, args, log) -> int:
|
||||
today = datetime.date.fromisoformat(args.today) if args.today else None
|
||||
report = freshness.audit(cfg, today=today, log=log)
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(freshness.render_text(report))
|
||||
if args.alert:
|
||||
try:
|
||||
sent = freshness.alert(report, cfg.apprise_url)
|
||||
if not sent:
|
||||
log("alert: RESUME_APPRISE_URL is not set; nothing sent")
|
||||
except Exception as exc: # noqa: BLE001 - an alert failure must not hide the audit
|
||||
print(f"warning: could not send the Apprise alert: {exc}", file=sys.stderr)
|
||||
if args.fail_on_findings and not report["ok"]:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_verify(cfg, args, log) -> int:
|
||||
problems = pipeline.verify(cfg, log=log)
|
||||
if args.json:
|
||||
print(json.dumps({"ok": not problems, "problems": problems}, indent=2))
|
||||
else:
|
||||
if problems:
|
||||
print(f"FAIL: {len(problems)} problem(s)")
|
||||
for problem in problems:
|
||||
print(f" - {problem}")
|
||||
else:
|
||||
print("OK: the index agrees with content/ and Qdrant")
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
def _cmd_demo_filter(cfg, args, log) -> int:
|
||||
result = pipeline.demo_area_filter(cfg, args.query, area=args.area, limit=args.limit)
|
||||
if args.json:
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
print(f"query: {result['query']!r}")
|
||||
print("")
|
||||
print(f"without a filter ({len(result['unfiltered'])} result(s)):")
|
||||
for row in result["unfiltered"]:
|
||||
print(f" {row['score']:.3f} [{row['area']}/{row['confidence']}] {row['title']} {row['url']}")
|
||||
print("")
|
||||
if not args.area:
|
||||
print("no --area given, so the filtered set is empty; re-run with e.g. --area edge")
|
||||
return 0
|
||||
print(f"with area={args.area!r} ({len(result['filtered'])} result(s)):")
|
||||
for row in result["filtered"]:
|
||||
print(f" {row['score']:.3f} [{row['area']}/{row['confidence']}] {row['title']} {row['url']}")
|
||||
wrong = [row for row in result["filtered"] if row["area"] != args.area]
|
||||
if wrong:
|
||||
print(f"FAIL: {len(wrong)} filtered result(s) are not in area {args.area!r}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_hybrid(cfg, args, log) -> int:
|
||||
rows = pipeline.hybrid_search(cfg, args.query, area=args.area, limit=args.limit)
|
||||
if args.json:
|
||||
print(json.dumps(rows, indent=2, sort_keys=True, default=str))
|
||||
return 0
|
||||
print(f"hybrid (RRF) query={args.query!r} area={args.area!r}: {len(rows)} result(s)")
|
||||
for row in rows:
|
||||
print(f" {row['score']:.4f} {row.get('title')} {row.get('url')} [{row['id'][:8]}]")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_measure(cfg, args, log) -> int:
|
||||
from . import measure as measure_module
|
||||
|
||||
sizes = [int(part) for part in args.sizes.split(",") if part.strip()]
|
||||
report = measure_module.measure(
|
||||
cfg, sizes=sizes, container=args.container, keep=args.keep, log=log
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
return 0
|
||||
print(measure_module.render_text(report))
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_info(cfg, args, log) -> int:
|
||||
print(cfg.describe())
|
||||
qdrant = Qdrant(cfg.qdrant_url, timeout=cfg.timeout)
|
||||
print("")
|
||||
print(f"qdrant: {'answering, version ' + qdrant.version() if qdrant.health() else 'NOT answering'}")
|
||||
if qdrant.collection_exists(cfg.collection):
|
||||
print(f"collection {cfg.collection}: {qdrant.point_count(cfg.collection)} point(s)")
|
||||
else:
|
||||
print(f"collection {cfg.collection}: does not exist")
|
||||
if os.path.exists(cfg.db_path):
|
||||
with store_module.Store(cfg.db_path) as store:
|
||||
print(f"store: {store.chunk_count()} chunk(s) across {store.source_count()} source(s)")
|
||||
print(f"contract: {json.dumps(store.get_meta('collection'), sort_keys=True)}")
|
||||
else:
|
||||
print(f"store: {cfg.db_path} does not exist")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
160
corpus/config.py
Normal file
160
corpus/config.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Configuration for the corpus pipeline. Environment only.
|
||||
|
||||
Nothing in this file holds a credential. The OmniRoute key is read from the
|
||||
process environment, which systemd populates from
|
||||
`~/.config/environment.d/10-secrets.conf` (AGENTS.md section 4.4). `describe()`
|
||||
is the only way the pipeline prints its configuration, and it redacts the key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Bumped whenever chunk boundaries or payload shape change. A stored corpus
|
||||
# carrying a different version is stale by definition and gets rebuilt.
|
||||
CHUNKER_VERSION = "blocks-v1"
|
||||
|
||||
# Provider defaults. `local-hashing` is a deterministic, offline provider used
|
||||
# by the tests, by `make corpus-test`, and by any rebuild on a host with no
|
||||
# model gateway. It is NOT a semantic embedding model: it exists so the whole
|
||||
# pipeline can be exercised without a provider decision, and so a rebuild never
|
||||
# depends on the network. The production provider is `omniroute`.
|
||||
PROVIDERS = {
|
||||
"local-hashing": ("local-hashing-v1", 256),
|
||||
"omniroute": ("text-embedding-3-small", 1536),
|
||||
}
|
||||
|
||||
DEFAULT_QDRANT_URL = "http://127.0.0.1:6333"
|
||||
DEFAULT_COLLECTION = "resume_corpus"
|
||||
DEFAULT_PROVIDER = "local-hashing"
|
||||
DEFAULT_OMNIROUTE_URL = "https://omniroute.lab.audasmedia.com.au"
|
||||
|
||||
# Freshness horizon: a document whose `last_verified` is older than this is
|
||||
# reported stale by the nightly audit. docs/phase-4-notes.md section 5.
|
||||
DEFAULT_MAX_AGE_DAYS = 180
|
||||
DEFAULT_WARN_DAYS = 30
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""A configuration value is missing or unusable."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
"""Everything the pipeline needs, resolved once."""
|
||||
|
||||
root: str
|
||||
content_dir: str
|
||||
data_dir: str
|
||||
db_path: str
|
||||
qdrant_url: str
|
||||
collection: str
|
||||
provider: str
|
||||
model: str
|
||||
dim: int
|
||||
api_url: str
|
||||
api_key: str
|
||||
batch_size: int
|
||||
timeout: float
|
||||
max_age_days: int
|
||||
warn_days: int
|
||||
apprise_url: str
|
||||
vectors_on_disk: bool = False
|
||||
|
||||
# -- construction -----------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, root: str | None = None, **overrides) -> "Config":
|
||||
"""Build a Config from the environment, then apply explicit overrides.
|
||||
|
||||
Overrides exist so the CLI can accept flags without a second code path;
|
||||
they are applied last so a flag always wins over the environment.
|
||||
"""
|
||||
root = os.path.abspath(root or os.environ.get("RESUME_ROOT") or _default_root())
|
||||
content_dir = os.environ.get("RESUME_CONTENT") or os.path.join(root, "content")
|
||||
data_dir = os.environ.get("RESUME_CORPUS_DATA") or os.path.join(root, "data", "corpus")
|
||||
provider = os.environ.get("RESUME_EMBED_PROVIDER") or DEFAULT_PROVIDER
|
||||
if provider not in PROVIDERS:
|
||||
raise ConfigError(
|
||||
f"RESUME_EMBED_PROVIDER={provider!r} is not one of {sorted(PROVIDERS)}"
|
||||
)
|
||||
default_model, default_dim = PROVIDERS[provider]
|
||||
|
||||
values = dict(
|
||||
root=root,
|
||||
content_dir=os.path.abspath(content_dir),
|
||||
data_dir=os.path.abspath(data_dir),
|
||||
db_path=os.path.abspath(
|
||||
os.environ.get("RESUME_CORPUS_DB") or os.path.join(data_dir, "corpus.db")
|
||||
),
|
||||
qdrant_url=(os.environ.get("RESUME_QDRANT_URL") or DEFAULT_QDRANT_URL).rstrip("/"),
|
||||
collection=os.environ.get("RESUME_COLLECTION") or DEFAULT_COLLECTION,
|
||||
provider=provider,
|
||||
model=os.environ.get("RESUME_EMBED_MODEL") or default_model,
|
||||
dim=int(os.environ.get("RESUME_EMBED_DIM") or default_dim),
|
||||
api_url=(os.environ.get("OMNIROUTE_URL") or DEFAULT_OMNIROUTE_URL).rstrip("/"),
|
||||
api_key=os.environ.get("OMNIROUTE_API_KEY") or "",
|
||||
batch_size=int(os.environ.get("RESUME_EMBED_BATCH") or 32),
|
||||
timeout=float(os.environ.get("RESUME_QDRANT_TIMEOUT") or 30.0),
|
||||
max_age_days=int(
|
||||
os.environ.get("RESUME_MAX_AGE_DAYS") or DEFAULT_MAX_AGE_DAYS
|
||||
),
|
||||
warn_days=int(os.environ.get("RESUME_WARN_DAYS") or DEFAULT_WARN_DAYS),
|
||||
apprise_url=os.environ.get("RESUME_APPRISE_URL") or "",
|
||||
vectors_on_disk=(os.environ.get("RESUME_QDRANT_VECTORS_ON_DISK") or "").lower()
|
||||
in ("1", "true", "yes"),
|
||||
)
|
||||
values.update({k: v for k, v in overrides.items() if v not in (None, "")})
|
||||
cfg = cls(**values)
|
||||
cfg.check()
|
||||
return cfg
|
||||
|
||||
# -- validation -------------------------------------------------------
|
||||
|
||||
def check(self) -> None:
|
||||
if self.dim <= 0:
|
||||
raise ConfigError(f"embedding dimension must be positive, got {self.dim}")
|
||||
if self.batch_size <= 0:
|
||||
raise ConfigError(f"batch size must be positive, got {self.batch_size}")
|
||||
if self.provider == "omniroute" and not self.api_key:
|
||||
raise ConfigError(
|
||||
"provider 'omniroute' needs OMNIROUTE_API_KEY in the environment "
|
||||
"(~/.config/environment.d/10-secrets.conf). It is never read from this repo."
|
||||
)
|
||||
if not os.path.isdir(self.content_dir):
|
||||
raise ConfigError(f"content directory not found: {self.content_dir}")
|
||||
|
||||
# -- presentation -----------------------------------------------------
|
||||
|
||||
def describe(self) -> str:
|
||||
"""Human-readable configuration. Never includes the API key value."""
|
||||
key = "set" if self.api_key else "unset"
|
||||
return (
|
||||
f"root={self.root}\n"
|
||||
f"content={self.content_dir}\n"
|
||||
f"data={self.data_dir}\n"
|
||||
f"db={self.db_path}\n"
|
||||
f"qdrant={self.qdrant_url} collection={self.collection}\n"
|
||||
f"provider={self.provider} model={self.model} dim={self.dim} "
|
||||
f"batch={self.batch_size}\n"
|
||||
f"api_url={self.api_url} api_key={key}\n"
|
||||
f"freshness=max_age_days:{self.max_age_days} warn_days:{self.warn_days}\n"
|
||||
f"qdrant vectors_on_disk={self.vectors_on_disk}\n"
|
||||
f"chunker={CHUNKER_VERSION}"
|
||||
)
|
||||
|
||||
# -- derived paths ----------------------------------------------------
|
||||
|
||||
@property
|
||||
def chunker_version(self) -> str:
|
||||
return CHUNKER_VERSION
|
||||
|
||||
@property
|
||||
def meta_path(self) -> str:
|
||||
return os.path.join(self.data_dir, "collection.json")
|
||||
|
||||
|
||||
def _default_root() -> str:
|
||||
"""The repository root: the parent of this package."""
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
241
corpus/embeddings.py
Normal file
241
corpus/embeddings.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""The one place a vector is made.
|
||||
|
||||
`docs/phase-4-corpus.md` §4.3: *"Record the embedding model and dimension per
|
||||
collection. A provider change is a governed migration, not a silent corruption.
|
||||
Provider decision pending Sam — build provider-agnostic behind one interface."*
|
||||
|
||||
So: one interface (`Provider`), two implementations, and the provider name,
|
||||
model and dimension are written to the collection metadata by the pipeline. A
|
||||
later run with a different model refuses to mix vectors into the same
|
||||
collection; it reports a governed migration instead.
|
||||
|
||||
**local-hashing** is deterministic, offline, and *not semantic*. It hashes word
|
||||
tokens into a fixed-width vector. It exists so that the pipeline, its tests and
|
||||
a from-scratch rebuild never depend on a network or on an unmade decision. It is
|
||||
the default for `make corpus-test` and for the Qdrant memory measurement.
|
||||
|
||||
**omniroute** is the production path: an OpenAI-compatible `/v1/embeddings`
|
||||
call through OmniRoute, which is the only permitted model gateway
|
||||
(AGENTS.md §4.5). No provider SDK, no direct vendor call, key from the
|
||||
environment only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
class EmbeddingError(RuntimeError):
|
||||
"""Embedding failed. The pipeline aborts rather than writing partial vectors."""
|
||||
|
||||
|
||||
class Provider:
|
||||
"""Interface every embedding provider implements."""
|
||||
|
||||
name = "provider"
|
||||
model = "model"
|
||||
dim = 0
|
||||
|
||||
def embed(self, texts: list) -> list:
|
||||
raise NotImplementedError
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Call counters. The DoD proves incremental mode with these numbers."""
|
||||
return {"calls": 0, "texts": 0}
|
||||
|
||||
def reset_stats(self) -> None:
|
||||
pass
|
||||
|
||||
def describe(self) -> dict:
|
||||
return {"provider": self.name, "model": self.model, "dim": self.dim}
|
||||
|
||||
|
||||
class LocalHashingProvider(Provider):
|
||||
"""Deterministic feature hashing. Offline, fast, not semantic.
|
||||
|
||||
Every token is hashed with sha256 (never Python's salted `hash()`), so the
|
||||
same text produces the same vector on every host and every run. Vectors are
|
||||
L2-normalised, which suits cosine distance.
|
||||
"""
|
||||
|
||||
name = "local-hashing"
|
||||
|
||||
def __init__(self, model: str = "local-hashing-v1", dim: int = 256) -> None:
|
||||
if dim <= 0:
|
||||
raise EmbeddingError(f"local-hashing needs a positive dimension, got {dim}")
|
||||
self.model = model
|
||||
self.dim = dim
|
||||
self._calls = 0
|
||||
self._texts = 0
|
||||
|
||||
def embed(self, texts: list) -> list:
|
||||
self._calls += 1
|
||||
self._texts += len(texts)
|
||||
return [self._vector(text) for text in texts]
|
||||
|
||||
def _vector(self, text: str) -> list:
|
||||
vector = [0.0] * self.dim
|
||||
for token in _tokens(text):
|
||||
digest = hashlib.sha256(token.encode("utf-8")).digest()
|
||||
index = int.from_bytes(digest[:8], "big") % self.dim
|
||||
sign = 1.0 if digest[8] & 1 else -1.0
|
||||
vector[index] += sign
|
||||
norm = math.sqrt(sum(value * value for value in vector))
|
||||
if norm == 0.0:
|
||||
# An empty text still needs a unit vector of the right width.
|
||||
vector[0] = 1.0
|
||||
return vector
|
||||
return [value / norm for value in vector]
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {"calls": self._calls, "texts": self._texts}
|
||||
|
||||
def reset_stats(self) -> None:
|
||||
self._calls = 0
|
||||
self._texts = 0
|
||||
|
||||
|
||||
class OmniRouteProvider(Provider):
|
||||
"""OpenAI-compatible `/v1/embeddings` through OmniRoute.
|
||||
|
||||
Retries on 429 and 5xx with exponential backoff (the risk table in
|
||||
`docs/phase-4-corpus.md` calls this out for bulk ingest). The key is never
|
||||
logged, never put in an error message, and never read from this repository.
|
||||
"""
|
||||
|
||||
name = "omniroute"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
dim: int,
|
||||
batch_size: int = 32,
|
||||
timeout: float = 30.0,
|
||||
attempts: int = 4,
|
||||
backoff: float = 1.5,
|
||||
) -> None:
|
||||
if not api_key:
|
||||
raise EmbeddingError(
|
||||
"OmniRoute needs OMNIROUTE_API_KEY in the environment "
|
||||
"(~/.config/environment.d/10-secrets.conf)"
|
||||
)
|
||||
self.api_url = api_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self.model = model
|
||||
self.dim = dim
|
||||
self.batch_size = max(1, batch_size)
|
||||
self.timeout = timeout
|
||||
self.attempts = max(1, attempts)
|
||||
self.backoff = backoff
|
||||
self._calls = 0
|
||||
self._texts = 0
|
||||
|
||||
def embed(self, texts: list) -> list:
|
||||
out = []
|
||||
for start in range(0, len(texts), self.batch_size):
|
||||
batch = texts[start : start + self.batch_size]
|
||||
out.extend(self._embed_batch(batch))
|
||||
return out
|
||||
|
||||
def _embed_batch(self, batch: list) -> list:
|
||||
body = json.dumps({"model": self.model, "input": batch}).encode("utf-8")
|
||||
last_error = None
|
||||
for attempt in range(self.attempts):
|
||||
request = urllib.request.Request(
|
||||
f"{self.api_url}/v1/embeddings",
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
},
|
||||
)
|
||||
self._calls += 1
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
vectors = _vectors_from_response(payload, self.model, self.dim)
|
||||
self._texts += len(batch)
|
||||
return vectors
|
||||
except urllib.error.HTTPError as exc:
|
||||
last_error = f"HTTP {exc.code} from {self.api_url}/v1/embeddings"
|
||||
exc.close()
|
||||
# 4xx other than 429 is our fault: do not hammer the gateway.
|
||||
if exc.code != 429 and exc.code < 500:
|
||||
raise EmbeddingError(f"{last_error} (not retryable)") from None
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
last_error = f"cannot reach {self.api_url}: {exc.reason}"
|
||||
except EmbeddingError:
|
||||
raise
|
||||
if attempt + 1 < self.attempts:
|
||||
time.sleep(self.backoff ** attempt)
|
||||
raise EmbeddingError(f"{last_error} after {self.attempts} attempts")
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {"calls": self._calls, "texts": self._texts}
|
||||
|
||||
def reset_stats(self) -> None:
|
||||
self._calls = 0
|
||||
self._texts = 0
|
||||
|
||||
|
||||
def _vectors_from_response(payload: dict, model: str, dim: int) -> list:
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, list) or not data:
|
||||
raise EmbeddingError("embedding response has no 'data' array")
|
||||
ordered = sorted(data, key=lambda item: item.get("index", 0))
|
||||
vectors = []
|
||||
for item in ordered:
|
||||
vector = item.get("embedding")
|
||||
if not isinstance(vector, list) or not vector:
|
||||
raise EmbeddingError("embedding response item has no 'embedding' array")
|
||||
if dim and len(vector) != dim:
|
||||
raise EmbeddingError(
|
||||
f"provider returned dimension {len(vector)} but the collection is "
|
||||
f"configured for {dim}; refusing to mix dimensions"
|
||||
)
|
||||
vectors.append([float(value) for value in vector])
|
||||
returned_model = payload.get("model")
|
||||
if returned_model and model and returned_model != model:
|
||||
raise EmbeddingError(
|
||||
f"provider answered as model {returned_model!r}, not {model!r}; "
|
||||
"a model change must be a governed migration"
|
||||
)
|
||||
return vectors
|
||||
|
||||
|
||||
def build_provider(cfg) -> Provider:
|
||||
"""Construct the provider named by the configuration."""
|
||||
if cfg.provider == "local-hashing":
|
||||
return LocalHashingProvider(model=cfg.model, dim=cfg.dim)
|
||||
if cfg.provider == "omniroute":
|
||||
return OmniRouteProvider(
|
||||
api_url=cfg.api_url,
|
||||
api_key=cfg.api_key,
|
||||
model=cfg.model,
|
||||
dim=cfg.dim,
|
||||
batch_size=cfg.batch_size,
|
||||
timeout=cfg.timeout,
|
||||
)
|
||||
raise EmbeddingError(f"unknown embedding provider {cfg.provider!r}")
|
||||
|
||||
|
||||
def _tokens(text: str) -> list:
|
||||
out = []
|
||||
current = []
|
||||
for char in text.lower():
|
||||
if char.isalnum():
|
||||
current.append(char)
|
||||
elif current:
|
||||
out.append("".join(current))
|
||||
current = []
|
||||
if current:
|
||||
out.append("".join(current))
|
||||
return out
|
||||
197
corpus/freshness.py
Normal file
197
corpus/freshness.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Freshness audit — the thing that stops the corpus rotting silently.
|
||||
|
||||
The risk table in `docs/phase-4-corpus.md` names it: *"Silent staleness… a
|
||||
stale index is otherwise an invisible quality failure."* So the audit is a first
|
||||
class command, it runs nightly from Prefect, and it alerts through the existing
|
||||
Apprise service when it finds something.
|
||||
|
||||
It reports four kinds of finding, and they are different problems:
|
||||
|
||||
* **stale** — a document whose `last_verified` is older than the horizon. The
|
||||
claim on the page may no longer be true.
|
||||
* **due soon** — inside the warning window. Re-verify before it becomes stale.
|
||||
* **index drift** — the source changed (or disappeared, or was never indexed)
|
||||
since it was embedded. Retrieval is answering from an older world.
|
||||
* **contract drift** — the collection was built with a different model,
|
||||
dimension or chunker than the running configuration.
|
||||
|
||||
Exit code is 0 for a clean audit and 1 when findings exist and `--fail-on-findings`
|
||||
was passed, so the Prefect deployment can alert on the result without parsing
|
||||
text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
from . import loader, store as store_module
|
||||
from .config import CHUNKER_VERSION
|
||||
from .pipeline import _contract
|
||||
from .qdrant import Qdrant
|
||||
|
||||
|
||||
def audit(cfg, today: datetime.date = None, log=None) -> dict:
|
||||
"""Return the audit report as a plain dict (JSON-serialisable)."""
|
||||
log = log or (lambda _msg: None)
|
||||
today = today or datetime.date.today()
|
||||
documents = loader.load_documents(cfg)
|
||||
|
||||
findings = {"stale": [], "due_soon": [], "index_drift": [], "contract_drift": []}
|
||||
|
||||
for doc in documents:
|
||||
try:
|
||||
verified = datetime.date.fromisoformat(doc.last_verified)
|
||||
except ValueError:
|
||||
findings["index_drift"].append(
|
||||
{"source_path": doc.path, "problem": f"invalid last_verified {doc.last_verified!r}"}
|
||||
)
|
||||
continue
|
||||
age = (today - verified).days
|
||||
entry = {
|
||||
"source_path": doc.path,
|
||||
"title": doc.title,
|
||||
"area": doc.area,
|
||||
"confidence": doc.confidence,
|
||||
"last_verified": doc.last_verified,
|
||||
"age_days": age,
|
||||
}
|
||||
if age > cfg.max_age_days:
|
||||
findings["stale"].append(entry)
|
||||
elif age > cfg.max_age_days - cfg.warn_days:
|
||||
findings["due_soon"].append(entry)
|
||||
|
||||
with store_module.Store(cfg.db_path) as store:
|
||||
manifest = store.manifest()
|
||||
on_disk = {doc.path: doc for doc in documents}
|
||||
|
||||
for doc in documents:
|
||||
row = manifest.get(doc.path)
|
||||
if row is None:
|
||||
findings["index_drift"].append(
|
||||
{"source_path": doc.path, "problem": "not indexed (run sync)"}
|
||||
)
|
||||
continue
|
||||
if row["content_hash"] != doc.digest:
|
||||
findings["index_drift"].append(
|
||||
{"source_path": doc.path, "problem": "source changed since it was embedded"}
|
||||
)
|
||||
if row["chunker_version"] != CHUNKER_VERSION:
|
||||
findings["index_drift"].append(
|
||||
{
|
||||
"source_path": doc.path,
|
||||
"problem": f"chunked with {row['chunker_version']}, current {CHUNKER_VERSION}",
|
||||
}
|
||||
)
|
||||
|
||||
for source in sorted(set(manifest) - set(on_disk)):
|
||||
findings["index_drift"].append(
|
||||
{"source_path": source, "problem": "indexed but no longer in content/"}
|
||||
)
|
||||
|
||||
stored_contract = store.get_meta("collection")
|
||||
if stored_contract != _contract(cfg):
|
||||
findings["contract_drift"].append(
|
||||
{"problem": "collection contract does not match this configuration",
|
||||
"stored": stored_contract, "configured": _contract(cfg)}
|
||||
)
|
||||
|
||||
qdrant = Qdrant(cfg.qdrant_url, timeout=cfg.timeout)
|
||||
reachable = qdrant.health()
|
||||
points = None
|
||||
if reachable and qdrant.collection_exists(cfg.collection):
|
||||
points = qdrant.point_count(cfg.collection)
|
||||
elif not reachable:
|
||||
findings["contract_drift"].append(
|
||||
{"problem": f"Qdrant is not answering at {cfg.qdrant_url}"}
|
||||
)
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"today": today.isoformat(),
|
||||
"horizon": {
|
||||
"max_age_days": cfg.max_age_days,
|
||||
"warn_days": cfg.warn_days,
|
||||
},
|
||||
"corpus": {
|
||||
"documents": len(documents),
|
||||
"chunks": store.chunk_count(),
|
||||
"sources": store.source_count(),
|
||||
"collection": cfg.collection,
|
||||
"collection_points": points,
|
||||
"provider": cfg.provider,
|
||||
"model": cfg.model,
|
||||
"dim": cfg.dim,
|
||||
"chunker_version": CHUNKER_VERSION,
|
||||
},
|
||||
"findings": findings,
|
||||
"summary": {
|
||||
"stale": len(findings["stale"]),
|
||||
"due_soon": len(findings["due_soon"]),
|
||||
"index_drift": len(findings["index_drift"]),
|
||||
"contract_drift": len(findings["contract_drift"]),
|
||||
},
|
||||
# `due_soon` is a warning, not a failure: the document is still
|
||||
# inside its verification horizon. Stale, drift and contract
|
||||
# problems are failures.
|
||||
"ok": not (
|
||||
findings["stale"] or findings["index_drift"] or findings["contract_drift"]
|
||||
),
|
||||
}
|
||||
log(f"audit: {json.dumps(report['summary'], sort_keys=True)}")
|
||||
return report
|
||||
|
||||
|
||||
def render_text(report: dict) -> str:
|
||||
"""Human-readable audit, for a terminal or an Apprise body."""
|
||||
lines = []
|
||||
corpus = report["corpus"]
|
||||
lines.append(
|
||||
f"corpus: {corpus['documents']} document(s), {corpus['chunks']} chunk(s), "
|
||||
f"{corpus['collection_points']} vector(s) in {corpus['collection']}"
|
||||
)
|
||||
lines.append(
|
||||
f"horizon: last_verified within {report['horizon']['max_age_days']} days "
|
||||
f"(warning at {report['horizon']['max_age_days'] - report['horizon']['warn_days']} days)"
|
||||
)
|
||||
findings = report["findings"]
|
||||
for kind, label in (
|
||||
("stale", "STALE — past the verification horizon"),
|
||||
("due_soon", "DUE SOON — verify before it goes stale"),
|
||||
("index_drift", "INDEX DRIFT — the index does not match content/"),
|
||||
("contract_drift", "CONTRACT DRIFT — rebuild required"),
|
||||
):
|
||||
rows = findings[kind]
|
||||
if not rows:
|
||||
continue
|
||||
lines.append("")
|
||||
lines.append(f"{label} ({len(rows)})")
|
||||
for row in rows:
|
||||
if "problem" in row:
|
||||
lines.append(f" - {row.get('source_path', '<collection>')}: {row['problem']}")
|
||||
else:
|
||||
lines.append(
|
||||
f" - {row['source_path']} ({row['area']}, {row['confidence']}): "
|
||||
f"last_verified {row['last_verified']} ({row['age_days']} days ago)"
|
||||
)
|
||||
if report["ok"]:
|
||||
lines.append("")
|
||||
lines.append("OK: nothing stale, no drift." + (" (see warnings above)" if findings["due_soon"] else ""))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def alert(report: dict, apprise_url: str, timeout: float = 10.0) -> bool:
|
||||
"""POST the audit to the existing Apprise service. No-op without a URL."""
|
||||
if not apprise_url:
|
||||
return False
|
||||
title = "ai-resume corpus audit: findings" if not report["ok"] else "ai-resume corpus audit: OK"
|
||||
body = json.dumps({"title": title, "body": render_text(report), "type": "warning" if not report["ok"] else "info"})
|
||||
request = urllib.request.Request(
|
||||
apprise_url,
|
||||
data=body.encode("utf-8"),
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout):
|
||||
return True
|
||||
162
corpus/loader.py
Normal file
162
corpus/loader.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Load `content/` into documents, reusing the Phase 1 validator's parser.
|
||||
|
||||
There is exactly one front-matter dialect in this repository and exactly one
|
||||
parser for it: `tools/validate_content.py`. This module imports that parser
|
||||
rather than reimplementing it, so the corpus can never disagree with the gate
|
||||
about what a document says.
|
||||
|
||||
The gate itself is `validate_tree` from the same file. The pipeline runs it
|
||||
before it chunks anything: an invalid content tree stops the run, it never
|
||||
reaches the vector store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
VALIDATOR_NAME = "validate_content"
|
||||
|
||||
|
||||
class ContentError(RuntimeError):
|
||||
"""The content tree is unreadable or invalid; the run must stop."""
|
||||
|
||||
|
||||
def load_validator(root: str):
|
||||
"""Import tools/validate_content.py as a module (cached per root)."""
|
||||
cached = sys.modules.get(VALIDATOR_NAME)
|
||||
if cached is not None and getattr(cached, "__validator_root__", None) == root:
|
||||
return cached
|
||||
path = os.path.join(root, "tools", "validate_content.py")
|
||||
if not os.path.exists(path):
|
||||
raise ContentError(f"validator not found at {path}")
|
||||
spec = importlib.util.spec_from_file_location(VALIDATOR_NAME, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ContentError(f"cannot load validator at {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
module.__validator_root__ = root # type: ignore[attr-defined]
|
||||
sys.modules[VALIDATOR_NAME] = module
|
||||
return module
|
||||
|
||||
|
||||
def to_python(node):
|
||||
"""Convert a validator front-matter Node into plain Python values."""
|
||||
if node is None or node.kind == "null":
|
||||
return None
|
||||
if node.kind == "scalar":
|
||||
return node.value
|
||||
if node.kind == "seq":
|
||||
return [to_python(item) for item in node.value]
|
||||
if node.kind == "map":
|
||||
return {key: to_python(value) for key, value in node.value.items()}
|
||||
raise ContentError(f"unexpected front-matter node kind {node.kind!r}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Document:
|
||||
"""One Markdown file plus the front-matter the schema guarantees."""
|
||||
|
||||
path: str # repository-relative, posix, e.g. "content/edge/l3/edge-zigbee.md"
|
||||
source_id: str
|
||||
area: str
|
||||
level: int
|
||||
title: str
|
||||
claim: str
|
||||
confidence: str
|
||||
period: str
|
||||
tech: list
|
||||
metrics: list
|
||||
artefacts: list
|
||||
public: bool
|
||||
last_verified: str
|
||||
tags: list
|
||||
body: str
|
||||
digest: str # sha256 of the file bytes: the incremental-mode key
|
||||
url: str # canonical page URL, matching internal/content.PageURL
|
||||
|
||||
# Filled in by the pipeline as it goes; not part of the schema.
|
||||
chunk_count: int = field(default=0)
|
||||
|
||||
@property
|
||||
def artefact_urls(self) -> list:
|
||||
return [str(a.get("url", "")) for a in self.artefacts if a.get("url")]
|
||||
|
||||
|
||||
def page_url(area: str, doc_id: str, level: int) -> str:
|
||||
"""Canonical URL for a document. Mirrors internal/content.PageURL exactly."""
|
||||
if level == 1:
|
||||
return f"/{area}/"
|
||||
if level == 2:
|
||||
return f"/{area}/{doc_id}/"
|
||||
return f"/{area}/a/{doc_id}/"
|
||||
|
||||
|
||||
def digest_of(path: str) -> str:
|
||||
"""sha256 of the raw file bytes — the same digest for any platform."""
|
||||
with open(path, "rb") as handle:
|
||||
return hashlib.sha256(handle.read()).hexdigest()
|
||||
|
||||
|
||||
def load_documents(cfg) -> list:
|
||||
"""Load every document under content/, sorted by path.
|
||||
|
||||
Raises ContentError when the gate fails, so a broken content tree never
|
||||
produces a half-correct index.
|
||||
"""
|
||||
validator = load_validator(cfg.root)
|
||||
violations, count = validator.validate_tree(cfg.root, cfg.content_dir, quiet=True)
|
||||
if violations:
|
||||
detail = "\n ".join(violations[:20])
|
||||
more = "" if len(violations) <= 20 else f"\n … and {len(violations) - 20} more"
|
||||
raise ContentError(
|
||||
f"content gate failed with {len(violations)} violation(s) "
|
||||
f"across {count} document(s). Run `make validate`:\n {detail}{more}"
|
||||
)
|
||||
|
||||
documents = []
|
||||
for path in validator.iter_documents(cfg.content_dir):
|
||||
documents.append(_load_one(cfg, validator, path))
|
||||
documents.sort(key=lambda d: d.path)
|
||||
if not documents:
|
||||
raise ContentError(f"no documents found under {cfg.content_dir}")
|
||||
return documents
|
||||
|
||||
|
||||
def _load_one(cfg, validator, path: str) -> Document:
|
||||
rel = os.path.relpath(path, cfg.root).replace(os.sep, "/")
|
||||
fm_lines, body, problem = validator.split_front_matter(path)
|
||||
if problem:
|
||||
raise ContentError(f"{rel}: {problem}")
|
||||
try:
|
||||
root_node = validator.parse_front_matter(fm_lines)
|
||||
except validator.YamlError as exc:
|
||||
raise ContentError(f"{rel}:{exc.line}: invalid YAML: {exc.msg}") from exc
|
||||
fields = to_python(root_node)
|
||||
if not isinstance(fields, dict):
|
||||
raise ContentError(f"{rel}: front-matter must be a mapping")
|
||||
|
||||
level = int(fields["level"])
|
||||
doc = Document(
|
||||
path=rel,
|
||||
source_id=str(fields["id"]),
|
||||
area=str(fields["area"]),
|
||||
level=level,
|
||||
title=str(fields["title"]),
|
||||
claim=str(fields["claim"]),
|
||||
confidence=str(fields["confidence"]),
|
||||
period=str(fields["period"]),
|
||||
tech=list(fields.get("tech") or []),
|
||||
metrics=list(fields.get("metrics") or []),
|
||||
artefacts=list(fields.get("artefacts") or []),
|
||||
public=bool(fields.get("public")),
|
||||
last_verified=str(fields["last_verified"]),
|
||||
tags=list(fields.get("tags") or []),
|
||||
body=body,
|
||||
digest=digest_of(path),
|
||||
url=page_url(str(fields["area"]), str(fields["id"]), level),
|
||||
)
|
||||
return doc
|
||||
242
corpus/measure.py
Normal file
242
corpus/measure.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Qdrant memory at 500 / 5,000 / 20,000 chunks.
|
||||
|
||||
`docs/phase-4-corpus.md` requires the measurement and `reference/stack.md` is
|
||||
where the number is recorded. The corpus itself is small; the point of the
|
||||
measurement is to show the *shape* of the growth so the 400 m cap is a known
|
||||
quantity rather than a hope.
|
||||
|
||||
Method:
|
||||
|
||||
1. Create an empty collection and sample memory — the baseline.
|
||||
2. For each size: recreate the collection, upsert that many chunks with payloads
|
||||
shaped exactly like production payloads (same fields, same text lengths),
|
||||
wait for the write to settle, then sample.
|
||||
|
||||
Memory is read from Qdrant's own Prometheus `app_process_resident_memory_bytes`,
|
||||
which is the process that would be capped, not the container sandbox. When
|
||||
`--container` is also given the container's counter is recorded next to it.
|
||||
|
||||
The measurement collection is dropped afterwards unless `--keep` is passed, and
|
||||
it is never the production collection name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import statistics
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import chunker, embeddings, loader, payloads
|
||||
from .qdrant import Qdrant
|
||||
|
||||
# Qdrant 1.20 exposes memory_resident_bytes; older builds used the Prometheus
|
||||
# default app_process_resident_memory_bytes. Accept either.
|
||||
RSS_METRIC = re.compile(
|
||||
r"^(?:memory_resident_bytes|app_process_resident_memory_bytes)\s+([0-9.eE+]+)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
INGEST_BATCH = 250
|
||||
INGEST_SETTLE = 0.4
|
||||
MEMUSAGE = re.compile(r"([0-9.]+)\s*([KMG]i?B)", re.IGNORECASE)
|
||||
CAP_MIB = 400 # reference/stack.md memory budget for Qdrant
|
||||
|
||||
|
||||
def measure(cfg, sizes=(500, 5000, 20000), container: str = "", keep: bool = False, log=None) -> dict:
|
||||
log = log or (lambda _msg: None)
|
||||
collection = f"{cfg.collection}_measure"
|
||||
provider = embeddings.build_provider(cfg)
|
||||
qdrant = Qdrant(cfg.qdrant_url, timeout=max(cfg.timeout, 60.0))
|
||||
if not qdrant.health():
|
||||
raise RuntimeError(f"Qdrant is not answering at {cfg.qdrant_url}")
|
||||
|
||||
templates = _templates(cfg, provider)
|
||||
log(f"measure: {len(templates)} payload template(s), dim={cfg.dim}, collection={collection}")
|
||||
|
||||
qdrant.drop_collection(collection)
|
||||
_create(qdrant, cfg, collection)
|
||||
time.sleep(1.0)
|
||||
baseline = _sample(qdrant, container)
|
||||
|
||||
samples = []
|
||||
for size in sorted(int(value) for value in sizes):
|
||||
qdrant.drop_collection(collection)
|
||||
_create(qdrant, cfg, collection)
|
||||
points = _points(templates, size, provider)
|
||||
started = time.time()
|
||||
# Throttled ingest: the peak during a bulk write is what trips the
|
||||
# cgroup limit, not the settled size. One batch at a time, sampling as we
|
||||
# go, so the measurement reports the peak and not just the end state.
|
||||
sent = 0
|
||||
peak = baseline["rss_mib"]
|
||||
for start in range(0, len(points), INGEST_BATCH):
|
||||
sent += qdrant.upsert(collection, points[start : start + INGEST_BATCH], batch=INGEST_BATCH)
|
||||
time.sleep(INGEST_SETTLE)
|
||||
reading = _rss_mib(qdrant)
|
||||
if reading is not None:
|
||||
peak = max(peak, reading)
|
||||
elapsed = round(time.time() - started, 2)
|
||||
points_now = qdrant.point_count(collection)
|
||||
time.sleep(2.0)
|
||||
reading = _sample(qdrant, container)
|
||||
growth = reading["rss_mib"] - baseline["rss_mib"]
|
||||
samples.append(
|
||||
{
|
||||
"chunks_requested": size,
|
||||
"points": points_now,
|
||||
"sent": sent,
|
||||
"upsert_seconds": elapsed,
|
||||
"rss_mib": reading["rss_mib"],
|
||||
"peak_rss_mib": round(peak, 1),
|
||||
"container_mib": reading["container_mib"],
|
||||
"growth_mib": round(growth, 1),
|
||||
"bytes_per_chunk": int(growth * 1024 * 1024 / size) if size else None,
|
||||
}
|
||||
)
|
||||
log(
|
||||
f"measure: {size} chunk(s) -> {points_now} point(s), rss {reading['rss_mib']:.1f} MiB "
|
||||
f"(+{growth:.1f} MiB), upsert {elapsed}s"
|
||||
)
|
||||
|
||||
if not keep:
|
||||
qdrant.drop_collection(collection)
|
||||
|
||||
report = {
|
||||
"collection": collection,
|
||||
"dim": cfg.dim,
|
||||
"distance": "Cosine",
|
||||
"provider": cfg.provider,
|
||||
"model": cfg.model,
|
||||
"qdrant_version": qdrant.version(),
|
||||
"cap_mib": CAP_MIB,
|
||||
"baseline": baseline,
|
||||
"samples": samples,
|
||||
"within_cap": all(
|
||||
max(sample["rss_mib"], sample["peak_rss_mib"]) <= CAP_MIB for sample in samples
|
||||
),
|
||||
"measured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"kept": keep,
|
||||
}
|
||||
return report
|
||||
|
||||
|
||||
def _create(qdrant: Qdrant, cfg, collection: str) -> None:
|
||||
"""Same collection settings as production, so the memory reading means something."""
|
||||
qdrant.create_collection(
|
||||
collection, cfg.dim, vectors_on_disk=cfg.vectors_on_disk
|
||||
)
|
||||
|
||||
|
||||
def _templates(cfg, provider) -> list:
|
||||
"""Real payloads from content/, so field count and text length are realistic."""
|
||||
documents = loader.load_documents(cfg)
|
||||
out = []
|
||||
for doc in documents:
|
||||
for chunk in chunker.chunk_document(doc.body):
|
||||
payload = payloads.build_payload(doc, chunk, cfg)
|
||||
payload["id"] = "template"
|
||||
out.append(payload)
|
||||
if not out:
|
||||
raise RuntimeError("no content to build a measurement from")
|
||||
return out
|
||||
|
||||
|
||||
def _points(templates: list, size: int, provider) -> list:
|
||||
"""`size` points with unique ids, unique source paths and real payload shape."""
|
||||
points = []
|
||||
texts = []
|
||||
for index in range(size):
|
||||
template = templates[index % len(templates)]
|
||||
payload = dict(template)
|
||||
payload["id"] = str(uuid.UUID(hex=hashlib.sha256(f"measure:{index}".encode()).hexdigest()[:32]))
|
||||
payload["source_path"] = f"measure/{template['area']}/{index}.md"
|
||||
payload["chunk_index"] = index
|
||||
points.append({"id": payload["id"], "payload": payload})
|
||||
texts.append(f"{index} {payload['text']}")
|
||||
vectors = provider.embed(texts)
|
||||
for point, vector in zip(points, vectors):
|
||||
point["vector"] = vector
|
||||
return points
|
||||
|
||||
|
||||
def _sample(qdrant: Qdrant, container: str) -> dict:
|
||||
"""Three readings two seconds apart; report the median so a GC pause is not the number."""
|
||||
readings = []
|
||||
for attempt in range(3):
|
||||
readings.append(_rss_mib(qdrant))
|
||||
if attempt < 2:
|
||||
time.sleep(2.0)
|
||||
readings = [value for value in readings if value is not None]
|
||||
rss = round(statistics.median(readings), 1) if readings else 0.0
|
||||
return {"rss_mib": rss, "container_mib": _container_mib(container) if container else None}
|
||||
|
||||
|
||||
def _rss_mib(qdrant: Qdrant):
|
||||
text = qdrant.metrics()
|
||||
if not text:
|
||||
return None
|
||||
match = RSS_METRIC.search(text)
|
||||
if not match:
|
||||
return None
|
||||
return float(match.group(1)) / (1024 * 1024)
|
||||
|
||||
|
||||
def _container_mib(container: str):
|
||||
"""Container memory from `docker stats`. None when docker or the container is absent."""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["docker", "stats", "--no-stream", "--format", "{{.MemUsage}}", container],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
match = MEMUSAGE.search(completed.stdout)
|
||||
if not match:
|
||||
return None
|
||||
value = float(match.group(1))
|
||||
unit = match.group(2).upper()
|
||||
if unit.startswith("G"):
|
||||
value *= 1024
|
||||
elif unit.startswith("K"):
|
||||
value /= 1024
|
||||
return round(value, 1)
|
||||
|
||||
|
||||
def render_text(report: dict) -> str:
|
||||
lines = [
|
||||
f"Qdrant {report['qdrant_version']} · collection {report['collection']} · "
|
||||
f"dim {report['dim']} cosine · provider {report['provider']}/{report['model']}",
|
||||
f"baseline (empty collection): {report['baseline']['rss_mib']:.1f} MiB",
|
||||
"",
|
||||
f"{'chunks':>8} {'points':>8} {'rss MiB':>8} {'peak':>7} {'growth':>7} {'B/chunk':>8} {'upsert s':>8}",
|
||||
]
|
||||
for sample in report["samples"]:
|
||||
per_chunk = sample["bytes_per_chunk"] or 0
|
||||
lines.append(
|
||||
f"{sample['chunks_requested']:>8} {sample['points']:>8} {sample['rss_mib']:>8.1f} "
|
||||
f"{sample['peak_rss_mib']:>7.1f} {sample['growth_mib']:>+7.1f} {per_chunk:>8} "
|
||||
f"{sample['upsert_seconds']:>8}"
|
||||
)
|
||||
lines.append("")
|
||||
verdict = "within" if report["within_cap"] else "OVER"
|
||||
lines.append(f"cap {report['cap_mib']} MiB: {verdict} cap at every measured size")
|
||||
if report["kept"]:
|
||||
lines.append(f"collection {report['collection']} was kept for inspection")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .config import Config
|
||||
|
||||
cfg = Config.from_env(dim=int(sys.argv[1]) if len(sys.argv) > 1 else None)
|
||||
print(render_text(measure(cfg, container="", log=print)))
|
||||
155
corpus/payloads.py
Normal file
155
corpus/payloads.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Chunk payloads: the thing the retrieval filter runs against.
|
||||
|
||||
`docs/phase-4-corpus.md` §4.2 fixes the payload. `area` and `confidence` are the
|
||||
two that matter: they let the query path narrow *before* it searches, and they
|
||||
let an answer qualify unfinished work instead of presenting it as delivered.
|
||||
|
||||
**A missing or invalid field fails the run.** Nothing in this module supplies a
|
||||
default value: `validate_payload` raises on the first field that is absent,
|
||||
empty or out of enum, and the pipeline reports which document and chunk it was.
|
||||
That is the DoD item "a missing field fails the run rather than defaulting".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
AREAS = ("ai", "platform", "edge", "products", "history", "about")
|
||||
LEVELS = (1, 2, 3)
|
||||
SOURCE_TYPES = ("doc", "code", "adr", "metric")
|
||||
CONFIDENCE = ("delivered", "running", "in-progress", "abandoned", "historic")
|
||||
|
||||
# Fields every payload must carry (docs/phase-4-corpus.md §4.2), plus the ones
|
||||
# the index needs to answer "where did this come from?" without a second lookup.
|
||||
REQUIRED_FIELDS = (
|
||||
"area",
|
||||
"level",
|
||||
"source_type",
|
||||
"confidence",
|
||||
"period",
|
||||
"artefacts",
|
||||
"last_verified",
|
||||
"text",
|
||||
)
|
||||
PROVENANCE_FIELDS = (
|
||||
"id",
|
||||
"source_path",
|
||||
"source_id",
|
||||
"chunk_index",
|
||||
"title",
|
||||
"claim",
|
||||
"url",
|
||||
"content_hash",
|
||||
"embedding_model",
|
||||
"embedding_dim",
|
||||
"chunker_version",
|
||||
)
|
||||
|
||||
|
||||
class PayloadError(RuntimeError):
|
||||
"""A payload is incomplete or out of enum. The run must stop."""
|
||||
|
||||
|
||||
def source_type_for(doc) -> str:
|
||||
"""Classify a document. Everything in content/ is a doc unless it says so.
|
||||
|
||||
The schema has no `source_type` field and the schema is frozen, so the
|
||||
classification is derived from tags the schema does allow.
|
||||
"""
|
||||
tags = {str(tag).lower() for tag in doc.tags}
|
||||
if "adr" in tags:
|
||||
return "adr"
|
||||
if "metric" in tags:
|
||||
return "metric"
|
||||
if "code" in tags:
|
||||
return "code"
|
||||
return "doc"
|
||||
|
||||
|
||||
def build_payload(doc, chunk, cfg) -> dict:
|
||||
"""Build one chunk payload from a document and a chunk.
|
||||
|
||||
Values are copied, never guessed: if the document does not carry a field,
|
||||
the payload does not gain one.
|
||||
"""
|
||||
payload = {
|
||||
# -- the retrieval filter (docs/phase-4-corpus.md §4.2) ------------
|
||||
"area": doc.area,
|
||||
"level": doc.level,
|
||||
"source_type": source_type_for(doc),
|
||||
"confidence": doc.confidence,
|
||||
"period": doc.period,
|
||||
"artefacts": [str(a.get("url", "")) for a in doc.artefacts],
|
||||
"last_verified": doc.last_verified,
|
||||
"text": chunk.text,
|
||||
# -- provenance ----------------------------------------------------
|
||||
"id": None, # filled by the pipeline once the id is computed
|
||||
"source_path": doc.path,
|
||||
"source_id": doc.source_id,
|
||||
"chunk_index": chunk.index,
|
||||
"title": doc.title,
|
||||
"claim": doc.claim,
|
||||
"url": doc.url,
|
||||
"content_hash": doc.digest,
|
||||
"embedding_model": cfg.model,
|
||||
"embedding_dim": cfg.dim,
|
||||
"chunker_version": cfg.chunker_version,
|
||||
# -- supporting evidence, used by the answer to cite ---------------
|
||||
"public": bool(doc.public),
|
||||
"tech": list(doc.tech),
|
||||
"tags": list(doc.tags),
|
||||
"chunk_tokens": chunk.tokens,
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def validate_payload(payload: dict, where: str = "chunk") -> None:
|
||||
"""Raise PayloadError if a required field is missing, empty or out of enum."""
|
||||
for field in REQUIRED_FIELDS + PROVENANCE_FIELDS:
|
||||
if field == "id" and payload.get("id") is None:
|
||||
raise PayloadError(f"{where}: payload field 'id' was not set")
|
||||
if field not in payload:
|
||||
raise PayloadError(f"{where}: payload is missing required field {field!r}")
|
||||
|
||||
area = payload["area"]
|
||||
if area not in AREAS:
|
||||
raise PayloadError(f"{where}: area {area!r} is not one of {list(AREAS)}")
|
||||
if payload["level"] not in LEVELS:
|
||||
raise PayloadError(f"{where}: level {payload['level']!r} is not one of {list(LEVELS)}")
|
||||
if payload["source_type"] not in SOURCE_TYPES:
|
||||
raise PayloadError(
|
||||
f"{where}: source_type {payload['source_type']!r} is not one of {list(SOURCE_TYPES)}"
|
||||
)
|
||||
if payload["confidence"] not in CONFIDENCE:
|
||||
raise PayloadError(
|
||||
f"{where}: confidence {payload['confidence']!r} is not one of {list(CONFIDENCE)}"
|
||||
)
|
||||
|
||||
for field in ("period", "title", "claim", "url", "source_path", "source_id", "embedding_model"):
|
||||
value = payload[field]
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise PayloadError(f"{where}: field {field!r} must be a non-empty string")
|
||||
|
||||
text = payload["text"]
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise PayloadError(f"{where}: field 'text' must be non-empty")
|
||||
|
||||
if not isinstance(payload["artefacts"], list):
|
||||
raise PayloadError(f"{where}: field 'artefacts' must be a list (empty is allowed)")
|
||||
|
||||
_check_date(payload["last_verified"], where)
|
||||
if not isinstance(payload["embedding_dim"], int) or payload["embedding_dim"] <= 0:
|
||||
raise PayloadError(f"{where}: field 'embedding_dim' must be a positive integer")
|
||||
if not isinstance(payload["chunk_index"], int) or payload["chunk_index"] < 0:
|
||||
raise PayloadError(f"{where}: field 'chunk_index' must be a non-negative integer")
|
||||
if not isinstance(payload["content_hash"], str) or len(payload["content_hash"]) != 64:
|
||||
raise PayloadError(f"{where}: field 'content_hash' must be a sha256 hex digest")
|
||||
|
||||
|
||||
def _check_date(value: str, where: str) -> None:
|
||||
try:
|
||||
datetime.date.fromisoformat(str(value))
|
||||
except ValueError as exc:
|
||||
raise PayloadError(
|
||||
f"{where}: field 'last_verified' must be an ISO date (YYYY-MM-DD), got {value!r}"
|
||||
) from exc
|
||||
426
corpus/pipeline.py
Normal file
426
corpus/pipeline.py
Normal file
@@ -0,0 +1,426 @@
|
||||
"""The pipeline: content/ -> validate -> chunk -> enrich -> embed -> upsert.
|
||||
|
||||
One entry point, `run`, with two modes:
|
||||
|
||||
* **rebuild** — drop the collection, empty the store, embed everything. This is
|
||||
the "one command rebuilds the corpus from an empty volume" path.
|
||||
* **sync** — the default. Only documents whose content hash, embedding model,
|
||||
embedding dimension or chunker version changed are re-chunked and
|
||||
re-embedded. Everything else is left alone.
|
||||
|
||||
The order matters. Payloads are built and validated for *every* changed document
|
||||
before a single vector is written, so an invalid payload stops the run with an
|
||||
empty diff rather than leaving half an index behind.
|
||||
|
||||
Deterministic ids (`sha256(source_path + chunk_index + content_hash)`) mean
|
||||
re-running updates points instead of duplicating them, and the id is a valid
|
||||
UUID so Qdrant accepts it as a point id without a mapping table.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
|
||||
from . import chunker, embeddings, loader, payloads, store as store_module
|
||||
from .config import CHUNKER_VERSION
|
||||
from .qdrant import Qdrant, QdrantError
|
||||
|
||||
|
||||
class PipelineError(RuntimeError):
|
||||
"""The run cannot continue without corrupting the index."""
|
||||
|
||||
|
||||
def chunk_id(source_path: str, chunk_index: int, content_hash: str) -> str:
|
||||
"""Deterministic point id: sha256(source_path + chunk_index + content_hash).
|
||||
|
||||
Rendered as a UUID because Qdrant only accepts unsigned integers or UUIDs as
|
||||
point ids; the first 128 bits of the digest keep collisions improbable.
|
||||
"""
|
||||
digest = hashlib.sha256(
|
||||
f"{source_path}\x00{chunk_index}\x00{content_hash}".encode("utf-8")
|
||||
).hexdigest()
|
||||
return str(uuid.UUID(hex=digest[:32]))
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunStats:
|
||||
mode: str = "sync"
|
||||
documents: int = 0
|
||||
added: int = 0
|
||||
updated: int = 0
|
||||
unchanged: int = 0
|
||||
removed: int = 0
|
||||
chunks_written: int = 0
|
||||
chunks_deleted: int = 0
|
||||
embed_calls: int = 0
|
||||
texts_embedded: int = 0
|
||||
points_sent: int = 0
|
||||
collection_points: int = 0
|
||||
store_chunks: int = 0
|
||||
duration_s: float = 0.0
|
||||
collection: str = ""
|
||||
provider: str = ""
|
||||
model: str = ""
|
||||
dim: int = 0
|
||||
chunker_version: str = CHUNKER_VERSION
|
||||
notes: list = field(default_factory=list)
|
||||
|
||||
def summary(self) -> str:
|
||||
rows = [
|
||||
f"mode={self.mode} documents={self.documents} chunks={self.store_chunks}",
|
||||
f"documents added={self.added} updated={self.updated} unchanged={self.unchanged} removed={self.removed}",
|
||||
f"chunks written={self.chunks_written} deleted={self.chunks_deleted}",
|
||||
f"embedding calls={self.embed_calls} texts={self.texts_embedded}",
|
||||
f"points sent={self.points_sent} collection points={self.collection_points}",
|
||||
f"provider={self.provider} model={self.model} dim={self.dim} chunker={self.chunker_version}",
|
||||
f"collection={self.collection} duration={self.duration_s:.2f}s",
|
||||
]
|
||||
rows.extend(self.notes)
|
||||
return "\n".join(rows)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def run(cfg, mode: str = "sync", log=None) -> RunStats:
|
||||
"""Build or refresh the corpus. Returns what happened; raises on any error."""
|
||||
if mode not in ("sync", "rebuild"):
|
||||
raise PipelineError(f"unknown mode {mode!r}; expected 'sync' or 'rebuild'")
|
||||
started = time.time()
|
||||
log = log or (lambda _msg: None)
|
||||
stats = RunStats(
|
||||
mode=mode,
|
||||
collection=cfg.collection,
|
||||
provider=cfg.provider,
|
||||
model=cfg.model,
|
||||
dim=cfg.dim,
|
||||
chunker_version=cfg.chunker_version,
|
||||
)
|
||||
|
||||
documents = loader.load_documents(cfg)
|
||||
stats.documents = len(documents)
|
||||
log(f"validated {len(documents)} document(s)")
|
||||
|
||||
provider = embeddings.build_provider(cfg)
|
||||
qdrant = Qdrant(cfg.qdrant_url, timeout=cfg.timeout)
|
||||
if not qdrant.health():
|
||||
raise PipelineError(
|
||||
f"Qdrant is not answering at {cfg.qdrant_url}. Start it "
|
||||
"(deploy/qdrant.yml) or point RESUME_QDRANT_URL at a running instance."
|
||||
)
|
||||
|
||||
with store_module.Store(cfg.db_path) as store:
|
||||
contract = _contract(cfg)
|
||||
mode = _check_contract(store, qdrant, cfg, contract, mode)
|
||||
|
||||
_ensure_collection(qdrant, cfg)
|
||||
|
||||
manifest = store.manifest()
|
||||
changed, removed = _classify(documents, manifest, cfg, mode)
|
||||
stats.unchanged = len(documents) - len(changed)
|
||||
stats.removed = len(removed)
|
||||
log(
|
||||
f"incremental plan: {len(changed)} to embed, "
|
||||
f"{stats.unchanged} unchanged, {len(removed)} removed"
|
||||
)
|
||||
|
||||
for path in removed:
|
||||
stats.chunks_deleted += store.delete_document(path)
|
||||
qdrant.delete_by_source(cfg.collection, path)
|
||||
log(f"removed {path}")
|
||||
|
||||
for doc in changed:
|
||||
payloads_for_doc = _prepare(doc, cfg, manifest)
|
||||
stats.chunks_written += len(payloads_for_doc)
|
||||
|
||||
vectors = provider.embed([p["text"] for p in payloads_for_doc])
|
||||
if len(vectors) != len(payloads_for_doc):
|
||||
raise PipelineError(
|
||||
f"{doc.path}: provider returned {len(vectors)} vectors for "
|
||||
f"{len(payloads_for_doc)} chunks"
|
||||
)
|
||||
|
||||
points = []
|
||||
for payload, vector in zip(payloads_for_doc, vectors):
|
||||
if len(vector) != cfg.dim:
|
||||
raise PipelineError(
|
||||
f"{doc.path}: vector dimension {len(vector)} != configured {cfg.dim}"
|
||||
)
|
||||
points.append({"id": payload["id"], "vector": vector, "payload": payload})
|
||||
|
||||
# Order: remove what this document used to have (chunk counts change),
|
||||
# then write the new rows and points. A crash between the two leaves an
|
||||
# index that reports as incomplete, never one that claims to be whole.
|
||||
stats.chunks_deleted += store.delete_document(doc.path)
|
||||
qdrant.delete_by_source(cfg.collection, doc.path)
|
||||
stats.points_sent += qdrant.upsert(cfg.collection, points)
|
||||
store.write_document(doc, payloads_for_doc)
|
||||
|
||||
if doc.path in manifest:
|
||||
stats.updated += 1
|
||||
log(f"updated {doc.path} ({len(points)} chunk(s))")
|
||||
else:
|
||||
stats.added += 1
|
||||
log(f"added {doc.path} ({len(points)} chunk(s))")
|
||||
|
||||
provider_stats = provider.stats()
|
||||
stats.embed_calls = provider_stats["calls"]
|
||||
stats.texts_embedded = provider_stats["texts"]
|
||||
stats.store_chunks = store.chunk_count()
|
||||
stats.collection_points = qdrant.point_count(cfg.collection)
|
||||
|
||||
if stats.collection_points != stats.store_chunks:
|
||||
raise PipelineError(
|
||||
"index is inconsistent after the run: "
|
||||
f"{stats.collection_points} point(s) in Qdrant but "
|
||||
f"{stats.store_chunks} chunk(s) in SQLite. Run `python3 -m corpus.cli verify`."
|
||||
)
|
||||
|
||||
store.set_meta("collection", contract)
|
||||
store.set_meta("last_run", {**stats.to_dict(), "finished_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
|
||||
_write_collection_file(cfg, store, stats)
|
||||
|
||||
stats.duration_s = round(time.time() - started, 3)
|
||||
return stats
|
||||
|
||||
|
||||
def _contract(cfg) -> dict:
|
||||
"""What this collection *is*. Recorded so a change is a migration, not drift."""
|
||||
return {
|
||||
"provider": cfg.provider,
|
||||
"model": cfg.model,
|
||||
"dim": cfg.dim,
|
||||
"chunker_version": cfg.chunker_version,
|
||||
"distance": "Cosine",
|
||||
}
|
||||
|
||||
|
||||
def _check_contract(store, qdrant, cfg, contract: dict, mode: str) -> str:
|
||||
stored = store.get_meta("collection")
|
||||
if mode == "rebuild":
|
||||
qdrant.drop_collection(cfg.collection)
|
||||
store.reset()
|
||||
return mode
|
||||
if stored is None:
|
||||
# No contract yet: either a fresh store, or one whose database was lost.
|
||||
if qdrant.collection_exists(cfg.collection) and qdrant.point_count(cfg.collection) > 0:
|
||||
raise PipelineError(
|
||||
f"collection {cfg.collection!r} holds points but this store has no "
|
||||
"collection contract (was data/corpus/corpus.db deleted?). "
|
||||
"Confirm you want to overwrite it: python3 -m corpus.cli rebuild"
|
||||
)
|
||||
return mode
|
||||
if stored != contract:
|
||||
raise PipelineError(
|
||||
"the collection contract does not match this configuration — refusing to "
|
||||
"mix vectors in one collection (docs/phase-4-corpus.md §4.3).\n"
|
||||
f" stored: {json.dumps(stored, sort_keys=True)}\n"
|
||||
f" config: {json.dumps(contract, sort_keys=True)}\n"
|
||||
"A provider or chunker change is a governed migration: run "
|
||||
"`python3 -m corpus.cli rebuild` once Sam has approved the change."
|
||||
)
|
||||
return mode
|
||||
|
||||
|
||||
def _ensure_collection(qdrant: Qdrant, cfg) -> None:
|
||||
if not qdrant.collection_exists(cfg.collection):
|
||||
qdrant.create_collection(
|
||||
cfg.collection, cfg.dim, vectors_on_disk=cfg.vectors_on_disk
|
||||
)
|
||||
return
|
||||
info = qdrant.collection_info(cfg.collection)
|
||||
vectors = ((info.get("config") or {}).get("params") or {}).get("vectors")
|
||||
size = None
|
||||
if isinstance(vectors, dict):
|
||||
size = vectors.get("size")
|
||||
if size is not None and int(size) != int(cfg.dim):
|
||||
raise PipelineError(
|
||||
f"collection {cfg.collection!r} has vectors of size {size} but this run "
|
||||
f"is configured for {cfg.dim}. That is a governed migration: rebuild."
|
||||
)
|
||||
|
||||
|
||||
def _classify(documents: list, manifest: dict, cfg, mode: str):
|
||||
"""Which documents need work, and which are gone."""
|
||||
changed = []
|
||||
on_disk = set()
|
||||
for doc in documents:
|
||||
on_disk.add(doc.path)
|
||||
if mode == "rebuild":
|
||||
changed.append(doc)
|
||||
continue
|
||||
entry = manifest.get(doc.path)
|
||||
if entry is None:
|
||||
changed.append(doc)
|
||||
continue
|
||||
if (
|
||||
entry["content_hash"] != doc.digest
|
||||
or entry["embedding_model"] != cfg.model
|
||||
or int(entry["embedding_dim"]) != int(cfg.dim)
|
||||
or entry["chunker_version"] != cfg.chunker_version
|
||||
):
|
||||
changed.append(doc)
|
||||
removed = sorted(path for path in manifest if path not in on_disk)
|
||||
return changed, removed
|
||||
|
||||
|
||||
def _prepare(doc, cfg, manifest: dict) -> list:
|
||||
"""Chunk one document and build validated payloads. No network, no writes."""
|
||||
try:
|
||||
chunks = chunker.chunk_document(doc.body)
|
||||
except chunker.ChunkError as exc:
|
||||
raise PipelineError(f"{doc.path}: {exc}") from exc
|
||||
if not chunks:
|
||||
raise PipelineError(f"{doc.path}: produced no chunks (empty body?)")
|
||||
doc.chunk_count = len(chunks)
|
||||
|
||||
out = []
|
||||
for chunk in chunks:
|
||||
payload = payloads.build_payload(doc, chunk, cfg)
|
||||
payload["id"] = chunk_id(doc.path, chunk.index, doc.digest)
|
||||
try:
|
||||
payloads.validate_payload(payload, where=f"{doc.path}#chunk-{chunk.index}")
|
||||
except payloads.PayloadError as exc:
|
||||
raise PipelineError(str(exc)) from exc
|
||||
out.append(payload)
|
||||
return out
|
||||
|
||||
|
||||
def _write_collection_file(cfg, store, stats: RunStats) -> None:
|
||||
"""Mirror the contract to data/corpus/collection.json for the operator."""
|
||||
body = {
|
||||
"collection": cfg.collection,
|
||||
"contract": _contract(cfg),
|
||||
"qdrant_url": cfg.qdrant_url,
|
||||
"chunks": store.chunk_count(),
|
||||
"sources": store.source_count(),
|
||||
"last_run": store.get_meta("last_run"),
|
||||
}
|
||||
with open(cfg.meta_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(body, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
|
||||
|
||||
# -- verification and demonstration ---------------------------------------
|
||||
|
||||
|
||||
def verify(cfg, log=None) -> list:
|
||||
"""Return a list of problems. Empty means the index agrees with content/."""
|
||||
log = log or (lambda _msg: None)
|
||||
problems = []
|
||||
documents = loader.load_documents(cfg)
|
||||
qdrant = Qdrant(cfg.qdrant_url, timeout=cfg.timeout)
|
||||
if not qdrant.health():
|
||||
return [f"Qdrant is not answering at {cfg.qdrant_url}"]
|
||||
|
||||
contract = _contract(cfg)
|
||||
with store_module.Store(cfg.db_path) as store:
|
||||
stored = store.get_meta("collection")
|
||||
if stored != contract:
|
||||
problems.append(f"collection contract mismatch: stored={stored} config={contract}")
|
||||
|
||||
if not qdrant.collection_exists(cfg.collection):
|
||||
problems.append(f"collection {cfg.collection!r} does not exist")
|
||||
return problems
|
||||
|
||||
info = qdrant.collection_info(cfg.collection)
|
||||
size = (((info.get("config") or {}).get("params") or {}).get("vectors") or {}).get("size")
|
||||
if size is not None and int(size) != cfg.dim:
|
||||
problems.append(f"collection vector size {size} != configured {cfg.dim}")
|
||||
|
||||
points_by_source = qdrant.scroll_sources(cfg.collection)
|
||||
rows = store.chunk_payloads()
|
||||
by_source = {}
|
||||
for row in rows:
|
||||
by_source.setdefault(row["source_path"], []).append(row)
|
||||
try:
|
||||
payloads.validate_payload(row, where=f"{row['source_path']}#chunk-{row['chunk_index']}")
|
||||
except payloads.PayloadError as exc:
|
||||
problems.append(str(exc))
|
||||
|
||||
for doc in documents:
|
||||
payload_rows = by_source.get(doc.path, [])
|
||||
if not payload_rows:
|
||||
problems.append(f"{doc.path}: no chunks in the index (run sync)")
|
||||
continue
|
||||
if payload_rows[0]["content_hash"] != doc.digest:
|
||||
problems.append(f"{doc.path}: source changed since it was embedded")
|
||||
expected = len(payload_rows)
|
||||
actual = points_by_source.get(doc.path, 0)
|
||||
if actual != expected:
|
||||
problems.append(
|
||||
f"{doc.path}: {expected} chunk row(s) but {actual} vector(s) in Qdrant"
|
||||
)
|
||||
|
||||
for source in sorted(set(by_source) - {doc.path for doc in documents}):
|
||||
problems.append(f"{source}: indexed but no longer in content/ (run sync)")
|
||||
|
||||
total_points = sum(points_by_source.values())
|
||||
if total_points != store.chunk_count():
|
||||
problems.append(
|
||||
f"{total_points} vector(s) in Qdrant but {store.chunk_count()} chunk(s) in SQLite"
|
||||
)
|
||||
log(
|
||||
f"verify: {store.chunk_count()} chunk(s) across {store.source_count()} source(s), "
|
||||
f"{total_points} vector(s) in Qdrant"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def demo_area_filter(cfg, query: str, area: str = None, limit: int = 5) -> dict:
|
||||
"""Same query with and without an `area` filter — the DoD's filter evidence."""
|
||||
provider = embeddings.build_provider(cfg)
|
||||
qdrant = Qdrant(cfg.qdrant_url, timeout=cfg.timeout)
|
||||
vector = provider.embed([query])[0]
|
||||
unfiltered = qdrant.search(cfg.collection, vector, limit=limit)
|
||||
filtered = qdrant.search(cfg.collection, vector, limit=limit, area=area) if area else []
|
||||
return {
|
||||
"query": query,
|
||||
"area": area,
|
||||
"unfiltered": [_hit(row) for row in unfiltered],
|
||||
"filtered": [_hit(row) for row in filtered],
|
||||
}
|
||||
|
||||
|
||||
def _hit(row: dict) -> dict:
|
||||
payload = row.get("payload") or {}
|
||||
return {
|
||||
"score": round(row["score"], 4),
|
||||
"area": payload.get("area"),
|
||||
"confidence": payload.get("confidence"),
|
||||
"title": payload.get("title"),
|
||||
"url": payload.get("url"),
|
||||
"source_path": payload.get("source_path"),
|
||||
"chunk_index": payload.get("chunk_index"),
|
||||
"preview": (payload.get("text") or "").replace("\n", " ")[:120],
|
||||
}
|
||||
|
||||
|
||||
def hybrid_search(cfg, query: str, area: str = None, limit: int = 5) -> list:
|
||||
"""Vectors + FTS5 fused with reciprocal rank fusion (decision D10).
|
||||
|
||||
Phase 5 owns the query graph; this is here because Phase 4 is the phase that
|
||||
has to prove the two halves line up — same ids, same metadata, one ranking.
|
||||
"""
|
||||
provider = embeddings.build_provider(cfg)
|
||||
qdrant = Qdrant(cfg.qdrant_url, timeout=cfg.timeout)
|
||||
vector = provider.embed([query])[0]
|
||||
ranked = {}
|
||||
for rank, hit in enumerate(qdrant.search(cfg.collection, vector, limit=limit * 4, area=area)):
|
||||
entry = ranked.setdefault(hit["id"], {"id": hit["id"], "payload": hit["payload"], "score": 0.0})
|
||||
entry["score"] += 1.0 / (60 + rank)
|
||||
with store_module.Store(cfg.db_path) as store:
|
||||
for rank, hit in enumerate(store.keyword_search(query, area=area, limit=limit * 4)):
|
||||
entry = ranked.setdefault(hit["id"], {"id": hit["id"], "payload": {}, "score": 0.0})
|
||||
entry.setdefault("keyword", hit["snippet"])
|
||||
entry["score"] += 1.0 / (60 + rank)
|
||||
ordered = sorted(ranked.values(), key=lambda row: row["score"], reverse=True)[:limit]
|
||||
for row in ordered:
|
||||
payload = row.get("payload") or {}
|
||||
row["title"] = payload.get("title")
|
||||
row["url"] = payload.get("url")
|
||||
return ordered
|
||||
269
corpus/qdrant.py
Normal file
269
corpus/qdrant.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""Qdrant REST client — standard library only.
|
||||
|
||||
No `qdrant-client` dependency: the four calls this pipeline needs are HTTP
|
||||
requests, and AGENTS.md §4.1 freezes the stack. `_request` is the single
|
||||
transport hook, which is what the tests replace to run the whole pipeline
|
||||
without a container.
|
||||
|
||||
Qdrant is bound to `127.0.0.1:6333` on the runtime host (`.13`) and is never
|
||||
publicly reachable; Caddy is the only public listener.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
DISTANCE_COSINE = "Cosine"
|
||||
|
||||
# The runtime host (.13) is memory-bound: 2.3 GiB free and 8 GiB of swap already
|
||||
# in use, so Qdrant gets a 400 m cap and must live inside it
|
||||
# (reference/stack.md). Measured on 2026-10-26: the *default* HNSW and payload
|
||||
# settings are OOM-killed at the cap between 5,000 and 20,000 chunks of 1024
|
||||
# dimensions. Keeping the HNSW graph and the payloads on disk, and using a
|
||||
# smaller graph (m=8, ef_construct=64), fits the cap because the kernel can
|
||||
# reclaim those page-cache pages instead of killing the process. Vectors stay in
|
||||
# RAM because they are the part search reads on every query.
|
||||
#
|
||||
# The trade is build time and a little search latency; the corpus is ~50 chunks
|
||||
# today and the public site has no query burst. See docs/phase-4-notes.md §7.
|
||||
MEMORY_FRUGAL_DEFAULTS = {
|
||||
"on_disk_payload": True,
|
||||
"hnsw_m": 8,
|
||||
"hnsw_ef_construct": 64,
|
||||
"vectors_on_disk": False,
|
||||
}
|
||||
|
||||
# Segment behaviour under a cap. `memmap_threshold` makes Qdrant mmap segments
|
||||
# this size and larger (page cache the kernel can reclaim instead of OOM), and
|
||||
# `indexing_threshold` is raised so a bulk ingest does not build the HNSW graph
|
||||
# while it is still writing segments. Measured: 20,000 chunks at 1024
|
||||
# dimensions peaked at 296 MiB during ingest and settled to 96 MiB.
|
||||
MEMORY_FRUGAL_OPTIMIZERS = {
|
||||
"default_segment_number": 2,
|
||||
"memmap_threshold": 5000,
|
||||
"indexing_threshold": 20000,
|
||||
}
|
||||
|
||||
|
||||
class QdrantError(RuntimeError):
|
||||
"""A Qdrant call failed, or Qdrant is not reachable."""
|
||||
|
||||
|
||||
class Qdrant:
|
||||
def __init__(self, url: str = "http://127.0.0.1:6333", timeout: float = 30.0, attempts: int = 3) -> None:
|
||||
self.url = url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.attempts = max(1, attempts)
|
||||
|
||||
# -- transport --------------------------------------------------------
|
||||
|
||||
def _request(self, method: str, path: str, body=None) -> dict:
|
||||
"""One HTTP call with retry/backoff. The only place that touches the network."""
|
||||
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||
last_error = None
|
||||
for attempt in range(self.attempts):
|
||||
request = urllib.request.Request(
|
||||
f"{self.url}{path}",
|
||||
data=data,
|
||||
method=method,
|
||||
headers={"Content-Type": "application/json"} if data else {},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
return json.loads(raw) if raw.strip() else {}
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")[:400]
|
||||
exc.close()
|
||||
if exc.code == 404:
|
||||
raise QdrantNotFound(f"{method} {path}: {detail}") from None
|
||||
if exc.code != 429 and exc.code < 500:
|
||||
raise QdrantError(f"{method} {path} failed: HTTP {exc.code} {detail}") from None
|
||||
last_error = f"HTTP {exc.code} {detail}"
|
||||
except (urllib.error.URLError, TimeoutError, http.client.HTTPException, ConnectionError) as exc:
|
||||
# RemoteDisconnected and friends arrive here when the server dies
|
||||
# mid-request (an OOM kill looks exactly like this).
|
||||
last_error = f"cannot reach {self.url}: {getattr(exc, 'reason', exc)}"
|
||||
if attempt + 1 < self.attempts:
|
||||
time.sleep(0.5 * (2 ** attempt))
|
||||
raise QdrantError(f"{method} {path} failed after {self.attempts} attempts: {last_error}")
|
||||
|
||||
# -- cluster ----------------------------------------------------------
|
||||
|
||||
def health(self) -> bool:
|
||||
"""True when Qdrant answers. Never raises."""
|
||||
try:
|
||||
self._request("GET", "/")
|
||||
return True
|
||||
except (QdrantError, QdrantNotFound):
|
||||
return False
|
||||
|
||||
def version(self) -> str:
|
||||
response = self._request("GET", "/")
|
||||
return str(response.get("version", "unknown"))
|
||||
|
||||
# -- collections ------------------------------------------------------
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
try:
|
||||
self._request("GET", f"/collections/{name}")
|
||||
return True
|
||||
except QdrantNotFound:
|
||||
return False
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dim: int,
|
||||
distance: str = DISTANCE_COSINE,
|
||||
on_disk_payload: bool = None,
|
||||
hnsw_m: int = None,
|
||||
hnsw_ef_construct: int = None,
|
||||
vectors_on_disk: bool = None,
|
||||
) -> None:
|
||||
"""Create a collection sized for a memory-bound host (see MEMORY_FRUGAL_DEFAULTS)."""
|
||||
defaults = MEMORY_FRUGAL_DEFAULTS
|
||||
body = {
|
||||
"vectors": {
|
||||
"size": int(dim),
|
||||
"distance": distance,
|
||||
"on_disk": bool(
|
||||
defaults["vectors_on_disk"] if vectors_on_disk is None else vectors_on_disk
|
||||
),
|
||||
},
|
||||
"hnsw_config": {
|
||||
"m": int(defaults["hnsw_m"] if hnsw_m is None else hnsw_m),
|
||||
"ef_construct": int(
|
||||
defaults["hnsw_ef_construct"] if hnsw_ef_construct is None else hnsw_ef_construct
|
||||
),
|
||||
"on_disk": True,
|
||||
},
|
||||
"on_disk_payload": bool(
|
||||
defaults["on_disk_payload"] if on_disk_payload is None else on_disk_payload
|
||||
),
|
||||
"optimizers_config": dict(MEMORY_FRUGAL_OPTIMIZERS),
|
||||
}
|
||||
self._request("PUT", f"/collections/{name}", body)
|
||||
|
||||
def drop_collection(self, name: str) -> None:
|
||||
try:
|
||||
self._request("DELETE", f"/collections/{name}")
|
||||
except QdrantNotFound:
|
||||
pass
|
||||
|
||||
def collection_info(self, name: str) -> dict:
|
||||
response = self._request("GET", f"/collections/{name}")
|
||||
return dict(response.get("result") or {})
|
||||
|
||||
def point_count(self, name: str) -> int:
|
||||
response = self._request("POST", f"/collections/{name}/points/count", {"exact": True})
|
||||
return int((response.get("result") or {}).get("count", 0))
|
||||
|
||||
# -- points -----------------------------------------------------------
|
||||
|
||||
def upsert(self, name: str, points: list, batch: int = 256) -> int:
|
||||
"""Upsert points in batches. Returns the number of points sent."""
|
||||
sent = 0
|
||||
for start in range(0, len(points), max(1, batch)):
|
||||
chunk = points[start : start + max(1, batch)]
|
||||
self._request(
|
||||
"PUT",
|
||||
f"/collections/{name}/points?wait=true",
|
||||
{"points": chunk},
|
||||
)
|
||||
sent += len(chunk)
|
||||
return sent
|
||||
|
||||
def delete_by_source(self, name: str, source_path: str) -> None:
|
||||
"""Delete every point that came from one document.
|
||||
|
||||
Re-chunking a document can produce fewer chunks than before, so the old
|
||||
points are removed rather than overwritten. Deterministic ids make
|
||||
duplicates impossible; this makes orphans impossible.
|
||||
"""
|
||||
self._request(
|
||||
"POST",
|
||||
f"/collections/{name}/points/delete?wait=true",
|
||||
{"filter": {"must": [{"key": "source_path", "match": {"value": source_path}}]}},
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
name: str,
|
||||
vector: list,
|
||||
limit: int = 5,
|
||||
area: str = None,
|
||||
confidence: str = None,
|
||||
with_payload: bool = True,
|
||||
) -> list:
|
||||
"""Nearest neighbours, optionally filtered *before* the search."""
|
||||
body = {
|
||||
"query": [float(value) for value in vector],
|
||||
"limit": int(limit),
|
||||
"with_payload": bool(with_payload),
|
||||
}
|
||||
filters = _filter_conditions(area, confidence)
|
||||
if filters:
|
||||
body["filter"] = {"must": filters}
|
||||
try:
|
||||
response = self._request("POST", f"/collections/{name}/points/query", body)
|
||||
points = ((response.get("result") or {}).get("points")) or []
|
||||
except QdrantNotFound:
|
||||
# Older Qdrant exposes the same operation as /points/search.
|
||||
response = self._request("POST", f"/collections/{name}/points/search", body)
|
||||
points = response.get("result") or []
|
||||
return [
|
||||
{
|
||||
"id": point.get("id"),
|
||||
"score": float(point.get("score") or 0.0),
|
||||
"payload": point.get("payload") or {},
|
||||
}
|
||||
for point in points
|
||||
]
|
||||
|
||||
def scroll_sources(self, name: str, limit: int = 1000) -> dict:
|
||||
"""Map source_path -> number of vectors, for consistency checks."""
|
||||
counts = {}
|
||||
offset = None
|
||||
while True:
|
||||
body = {"limit": limit, "with_payload": ["source_path"], "with_vector": False}
|
||||
if offset is not None:
|
||||
body["offset"] = offset
|
||||
response = self._request("POST", f"/collections/{name}/points/scroll", body)
|
||||
result = response.get("result") or {}
|
||||
points = result.get("points") or []
|
||||
for point in points:
|
||||
source = (point.get("payload") or {}).get("source_path", "<missing>")
|
||||
counts[source] = counts.get(source, 0) + 1
|
||||
offset = result.get("next_page_offset")
|
||||
if not points or offset is None:
|
||||
return counts
|
||||
|
||||
def metrics(self) -> str:
|
||||
"""Prometheus text from Qdrant's /metrics route (used by the memory measurement).
|
||||
|
||||
Returns "" when the route is not exposed, so callers can fall back to
|
||||
reading the container's own memory counters.
|
||||
"""
|
||||
try:
|
||||
with urllib.request.urlopen(f"{self.url}/metrics", timeout=self.timeout) as response:
|
||||
return response.read().decode("utf-8", "replace")
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError):
|
||||
return ""
|
||||
|
||||
|
||||
class QdrantNotFound(QdrantError):
|
||||
"""A 404 from Qdrant: the collection or route does not exist."""
|
||||
|
||||
|
||||
def _filter_conditions(area: str = None, confidence: str = None) -> list:
|
||||
conditions = []
|
||||
if area:
|
||||
conditions.append({"key": "area", "match": {"value": area}})
|
||||
if confidence:
|
||||
conditions.append({"key": "confidence", "match": {"value": confidence}})
|
||||
return conditions
|
||||
295
corpus/store.py
Normal file
295
corpus/store.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""SQLite: the keyword index and the incremental state.
|
||||
|
||||
Two jobs, one file (`RESUME_CORPUS_DB`, default `data/corpus/corpus.db`):
|
||||
|
||||
* **`chunk_fts`** — an FTS5 index over chunk text with the same filter columns
|
||||
the vectors carry. This is the keyword half of hybrid retrieval (decision
|
||||
D10). Phase 2 indexes *pages* for the site's own search box; this indexes
|
||||
*chunks*, because retrieval hands the model a passage, not a page.
|
||||
* **`manifest`** — one row per source document: its content hash, its chunk
|
||||
count and the embedding model/dimension it was embedded with. Incremental mode
|
||||
is a hash comparison against this table, and nothing else.
|
||||
|
||||
`meta` records the collection contract (provider, model, dimension, chunker
|
||||
version) so a provider change is detected instead of silently corrupting the
|
||||
index.
|
||||
|
||||
Standard library `sqlite3` only. The same file is opened read-only by the Phase
|
||||
5 retrieval service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
import unicodedata
|
||||
|
||||
SCHEMA_VERSION = "1"
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_path TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
area TEXT NOT NULL,
|
||||
level INTEGER NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
confidence TEXT NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
last_verified TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
claim TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
embedding_model TEXT NOT NULL,
|
||||
embedding_dim INTEGER NOT NULL,
|
||||
chunker_version TEXT NOT NULL,
|
||||
chunk_tokens INTEGER NOT NULL,
|
||||
public INTEGER NOT NULL,
|
||||
artefacts TEXT NOT NULL,
|
||||
tech TEXT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
text TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS chunks_source ON chunks(source_path);
|
||||
CREATE INDEX IF NOT EXISTS chunks_area ON chunks(area);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS chunk_fts USING fts5(
|
||||
id UNINDEXED,
|
||||
source_path UNINDEXED,
|
||||
title,
|
||||
terms,
|
||||
text,
|
||||
area UNINDEXED,
|
||||
confidence UNINDEXED,
|
||||
tokenize = 'porter unicode61 remove_diacritics 2'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS manifest (
|
||||
source_path TEXT PRIMARY KEY,
|
||||
content_hash TEXT NOT NULL,
|
||||
chunk_count INTEGER NOT NULL,
|
||||
embedding_model TEXT NOT NULL,
|
||||
embedding_dim INTEGER NOT NULL,
|
||||
chunker_version TEXT NOT NULL,
|
||||
last_verified TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
# Column order used by every INSERT in this module.
|
||||
_CHUNK_COLUMNS = (
|
||||
"id", "source_path", "source_id", "chunk_index", "area", "level", "source_type",
|
||||
"confidence", "period", "last_verified", "title", "claim", "url", "content_hash",
|
||||
"embedding_model", "embedding_dim", "chunker_version", "chunk_tokens",
|
||||
"public", "artefacts", "tech", "tags", "text",
|
||||
)
|
||||
|
||||
_JSON_FIELDS = ("artefacts", "tech", "tags")
|
||||
|
||||
|
||||
class StoreError(RuntimeError):
|
||||
"""The corpus database is unreadable or inconsistent."""
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: str) -> None:
|
||||
self.path = path
|
||||
os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
|
||||
self.db = sqlite3.connect(path)
|
||||
self.db.row_factory = sqlite3.Row
|
||||
self.db.execute("PRAGMA journal_mode = WAL")
|
||||
self.db.execute("PRAGMA synchronous = NORMAL")
|
||||
self.db.executescript(SCHEMA)
|
||||
self.set_meta("schema_version", SCHEMA_VERSION)
|
||||
|
||||
# -- lifecycle --------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
self.db.close()
|
||||
|
||||
def __enter__(self) -> "Store":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc) -> None:
|
||||
self.close()
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Empty every table. Used by a from-scratch rebuild."""
|
||||
for table in ("chunk_fts", "chunks", "manifest", "meta"):
|
||||
self.db.execute(f"DELETE FROM {table}")
|
||||
self.db.commit()
|
||||
|
||||
# -- writing ----------------------------------------------------------
|
||||
|
||||
def write_document(self, doc, payloads: list) -> None:
|
||||
"""Replace all rows for one document: chunks, FTS rows and manifest."""
|
||||
self.delete_document(doc.path, keep_manifest=True)
|
||||
for payload in payloads:
|
||||
values = []
|
||||
for column in _CHUNK_COLUMNS:
|
||||
value = payload[column]
|
||||
if column in _JSON_FIELDS:
|
||||
value = json.dumps(value, sort_keys=True)
|
||||
values.append(value)
|
||||
cursor = self.db.execute(
|
||||
f"INSERT INTO chunks ({', '.join(_CHUNK_COLUMNS)}) "
|
||||
f"VALUES ({', '.join('?' for _ in _CHUNK_COLUMNS)})",
|
||||
values,
|
||||
)
|
||||
# `terms` is the keyword surface: the words a visitor or a recruiter
|
||||
# would type that live in front-matter rather than the body — tech
|
||||
# names, tags, the one-line claim. Without it, a search for "zigbee"
|
||||
# misses a page whose body says "mesh". It is indexed, never retrieved.
|
||||
terms = " ".join(
|
||||
str(value)
|
||||
for value in (
|
||||
payload["claim"],
|
||||
payload["title"],
|
||||
" ".join(payload["tech"]),
|
||||
" ".join(payload["tags"]),
|
||||
)
|
||||
if value
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO chunk_fts (rowid, id, source_path, title, terms, text, area, confidence) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
cursor.lastrowid,
|
||||
payload["id"],
|
||||
payload["source_path"],
|
||||
payload["title"],
|
||||
terms,
|
||||
payload["text"],
|
||||
payload["area"],
|
||||
payload["confidence"],
|
||||
),
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT OR REPLACE INTO manifest "
|
||||
"(source_path, content_hash, chunk_count, embedding_model, embedding_dim, "
|
||||
" chunker_version, last_verified, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
doc.path,
|
||||
doc.digest,
|
||||
len(payloads),
|
||||
payloads[0]["embedding_model"] if payloads else "",
|
||||
payloads[0]["embedding_dim"] if payloads else 0,
|
||||
payloads[0]["chunker_version"] if payloads else "",
|
||||
doc.last_verified,
|
||||
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
),
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def delete_document(self, source_path: str, keep_manifest: bool = False) -> int:
|
||||
rowids = [
|
||||
row["rowid"]
|
||||
for row in self.db.execute("SELECT rowid FROM chunks WHERE source_path = ?", (source_path,))
|
||||
]
|
||||
for rowid in rowids:
|
||||
self.db.execute("DELETE FROM chunk_fts WHERE rowid = ?", (rowid,))
|
||||
self.db.execute("DELETE FROM chunks WHERE source_path = ?", (source_path,))
|
||||
if not keep_manifest:
|
||||
self.db.execute("DELETE FROM manifest WHERE source_path = ?", (source_path,))
|
||||
self.db.commit()
|
||||
return len(rowids)
|
||||
|
||||
# -- reading ----------------------------------------------------------
|
||||
|
||||
def manifest(self) -> dict:
|
||||
return {
|
||||
row["source_path"]: dict(row)
|
||||
for row in self.db.execute("SELECT * FROM manifest")
|
||||
}
|
||||
|
||||
def chunk_count(self) -> int:
|
||||
return int(self.db.execute("SELECT count(*) FROM chunks").fetchone()[0])
|
||||
|
||||
def source_count(self) -> int:
|
||||
return int(self.db.execute("SELECT count(DISTINCT source_path) FROM chunks").fetchone()[0])
|
||||
|
||||
def chunk_ids(self) -> list:
|
||||
return [row["id"] for row in self.db.execute("SELECT id FROM chunks ORDER BY id")]
|
||||
|
||||
def chunk_payloads(self, source_path: str = None) -> list:
|
||||
"""Every stored payload, optionally for one document. Used by verify()."""
|
||||
sql = "SELECT * FROM chunks"
|
||||
args = ()
|
||||
if source_path:
|
||||
sql += " WHERE source_path = ?"
|
||||
args = (source_path,)
|
||||
sql += " ORDER BY source_path, chunk_index"
|
||||
out = []
|
||||
for row in self.db.execute(sql, args):
|
||||
payload = dict(row)
|
||||
for field in _JSON_FIELDS:
|
||||
payload[field] = json.loads(payload[field])
|
||||
out.append(payload)
|
||||
return out
|
||||
|
||||
def keyword_search(self, query: str, area: str = None, confidence: str = None, limit: int = 10) -> list:
|
||||
"""BM25 over chunk text, optionally filtered before ranking."""
|
||||
match = match_expression(query)
|
||||
if not match:
|
||||
return []
|
||||
sql = [
|
||||
"SELECT c.id, c.url, c.title, c.area, c.confidence, c.source_path,",
|
||||
" c.chunk_index, snippet(chunk_fts, 4, '<mark>', '</mark>', '…', 18) AS snippet,",
|
||||
" bm25(chunk_fts) AS score",
|
||||
"FROM chunk_fts JOIN chunks c ON c.rowid = chunk_fts.rowid",
|
||||
"WHERE chunk_fts MATCH ?",
|
||||
]
|
||||
args = [match]
|
||||
if area:
|
||||
sql.append("AND c.area = ?")
|
||||
args.append(area)
|
||||
if confidence:
|
||||
sql.append("AND c.confidence = ?")
|
||||
args.append(confidence)
|
||||
sql.append("ORDER BY bm25(chunk_fts) LIMIT ?")
|
||||
args.append(int(limit))
|
||||
rows = self.db.execute(" ".join(sql), args)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
# -- meta -------------------------------------------------------------
|
||||
|
||||
def set_meta(self, key: str, value) -> None:
|
||||
self.db.execute(
|
||||
"INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)",
|
||||
(key, json.dumps(value, sort_keys=True)),
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def get_meta(self, key: str, default=None):
|
||||
row = self.db.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
|
||||
if row is None:
|
||||
return default
|
||||
return json.loads(row["value"])
|
||||
|
||||
def meta(self) -> dict:
|
||||
return {row["key"]: json.loads(row["value"]) for row in self.db.execute("SELECT key, value FROM meta")}
|
||||
|
||||
|
||||
def match_expression(query: str) -> str:
|
||||
"""Free text -> a safe FTS5 expression: bare words, each a prefix, all required.
|
||||
|
||||
Mirrors `MatchExpression` in internal/search/search.go so the site's search
|
||||
box and the retrieval path treat a visitor's words identically. FTS5
|
||||
operators in the input are never honoured.
|
||||
"""
|
||||
terms = []
|
||||
for raw in re.split(r"[^0-9a-zA-Z\-]+", unicodedata.normalize("NFKC", query or "").lower()):
|
||||
term = raw.strip("-")
|
||||
if term:
|
||||
terms.append('"' + term.replace('"', "") + '"*')
|
||||
return " AND ".join(terms)
|
||||
0
corpus/tests/__init__.py
Normal file
0
corpus/tests/__init__.py
Normal file
112
corpus/tests/fake_qdrant.py
Normal file
112
corpus/tests/fake_qdrant.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""An in-memory stand-in for Qdrant, used by the pipeline tests.
|
||||
|
||||
It implements the routes the pipeline actually calls and nothing else, and it is
|
||||
deliberately dumb: brute-force cosine over a dict. If the pipeline passes
|
||||
against this, the pipeline's own logic is right; the real server is exercised
|
||||
separately in `docs/phase-4-notes.md` (rebuild against a live container).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from corpus.qdrant import Qdrant, QdrantError, QdrantNotFound
|
||||
|
||||
|
||||
class FakeQdrant(Qdrant):
|
||||
def __init__(self, dim: int):
|
||||
super().__init__(url="fake://qdrant", timeout=1.0, attempts=1)
|
||||
self.dim = dim
|
||||
self.collections = {} # name -> {id: {"vector": [...], "payload": {...}}}
|
||||
self.calls = []
|
||||
|
||||
# -- transport --------------------------------------------------------
|
||||
|
||||
def _request(self, method: str, path: str, body=None) -> dict:
|
||||
self.calls.append((method, path, body))
|
||||
query = ""
|
||||
if "?" in path:
|
||||
path, query = path.split("?", 1)
|
||||
parts = [part for part in path.split("/") if part]
|
||||
|
||||
if not parts:
|
||||
return {"version": "fake-1.0"}
|
||||
|
||||
if parts[0] != "collections":
|
||||
raise QdrantNotFound(f"no route {path}")
|
||||
if len(parts) == 2:
|
||||
name = parts[1]
|
||||
if method == "PUT":
|
||||
self.collections.setdefault(name, {})
|
||||
return {"result": True}
|
||||
if method == "DELETE":
|
||||
if name not in self.collections:
|
||||
raise QdrantNotFound("collection not found")
|
||||
del self.collections[name]
|
||||
return {"result": True}
|
||||
if name not in self.collections:
|
||||
raise QdrantNotFound("collection not found")
|
||||
return {
|
||||
"result": {
|
||||
"points_count": len(self.collections[name]),
|
||||
"config": {"params": {"vectors": {"size": self.dim, "distance": "Cosine"}}},
|
||||
}
|
||||
}
|
||||
name = parts[1]
|
||||
if name not in self.collections:
|
||||
raise QdrantNotFound("collection not found")
|
||||
points = self.collections[name]
|
||||
|
||||
if parts[2:4] == ["points", "count"]:
|
||||
return {"result": {"count": len(points)}}
|
||||
if len(parts) == 3 and parts[2] == "points" and method == "PUT":
|
||||
for point in (body or {}).get("points", []):
|
||||
if len(point["vector"]) != self.dim:
|
||||
raise QdrantError("wrong vector dimension")
|
||||
points[point["id"]] = {"vector": point["vector"], "payload": point["payload"]}
|
||||
return {"result": {"status": "completed"}}
|
||||
if parts[2:4] == ["points", "delete"]:
|
||||
must = (((body or {}).get("filter") or {}).get("must")) or []
|
||||
for point_id in list(points):
|
||||
if _matches(points[point_id]["payload"], must):
|
||||
del points[point_id]
|
||||
return {"result": {"status": "completed"}}
|
||||
if parts[2:4] == ["points", "query"]:
|
||||
vector = body["query"]
|
||||
must = (((body or {}).get("filter") or {}).get("must")) or []
|
||||
scored = []
|
||||
for point_id, point in points.items():
|
||||
if not _matches(point["payload"], must):
|
||||
continue
|
||||
scored.append(
|
||||
{
|
||||
"id": point_id,
|
||||
"score": _cosine(vector, point["vector"]),
|
||||
"payload": point["payload"],
|
||||
}
|
||||
)
|
||||
scored.sort(key=lambda row: row["score"], reverse=True)
|
||||
return {"result": {"points": scored[: int(body.get("limit", 10))]}}
|
||||
if parts[2:4] == ["points", "scroll"]:
|
||||
limit = int((body or {}).get("limit", 100))
|
||||
rows = [
|
||||
{"id": point_id, "payload": {"source_path": point["payload"].get("source_path")}}
|
||||
for point_id, point in list(points.items())[:limit]
|
||||
]
|
||||
return {"result": {"points": rows, "next_page_offset": None}}
|
||||
raise QdrantNotFound(f"no route {path}")
|
||||
|
||||
|
||||
def _matches(payload: dict, conditions: list) -> bool:
|
||||
for condition in conditions:
|
||||
key = condition.get("key")
|
||||
expected = (condition.get("match") or {}).get("value")
|
||||
if payload.get(key) != expected:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _cosine(left: list, right: list) -> float:
|
||||
dot = sum(a * b for a, b in zip(left, right))
|
||||
norm = math.sqrt(sum(a * a for a in left)) * math.sqrt(sum(b * b for b in right))
|
||||
return dot / norm if norm else 0.0
|
||||
186
corpus/tests/support.py
Normal file
186
corpus/tests/support.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Shared fixtures for the corpus tests: a throwaway content tree and a fake Qdrant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
|
||||
from corpus.config import Config
|
||||
from corpus.tests.fake_qdrant import FakeQdrant
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
DOC = """---
|
||||
id: {id}
|
||||
area: {area}
|
||||
level: {level}
|
||||
title: "{title}"
|
||||
claim: "{claim}"
|
||||
confidence: {confidence}
|
||||
period: "2023–"
|
||||
tech: [Go, SQLite]
|
||||
metrics: [{{label: endpoints, value: 8}}]
|
||||
artefacts: []
|
||||
public: true
|
||||
last_verified: {last_verified}
|
||||
tags: [{tags}]
|
||||
---
|
||||
|
||||
# {title}
|
||||
|
||||
{body}
|
||||
"""
|
||||
|
||||
FENCE = """A paragraph before the code block explains the constraint.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
func main() {{
|
||||
println("chunk boundary must never land inside this fence")
|
||||
}}
|
||||
```
|
||||
|
||||
A paragraph after the code block.
|
||||
"""
|
||||
|
||||
TABLE = """Measured results, one row per endpoint:
|
||||
|
||||
| endpoint | latency | loss |
|
||||
|---|---|---|
|
||||
| alpha | 12ms | 0.0% |
|
||||
| beta | 14ms | 0.1% |
|
||||
| gamma | 9ms | 0.0% |
|
||||
|
||||
And a closing paragraph.
|
||||
"""
|
||||
|
||||
PROSE = "\n\n".join(
|
||||
f"Paragraph {index} describes a delivery constraint in corporate language, "
|
||||
"with enough words that the chunker has to think about where to break, and "
|
||||
"never inside a fenced block, a table or a front-matter block."
|
||||
for index in range(1, 40)
|
||||
)
|
||||
|
||||
DEFAULT_DOCS = {
|
||||
"ai/ai-overview.md": dict(
|
||||
id="ai-overview", area="ai", level=1, title="Agent Systems",
|
||||
claim="Harnesses, memory and routing.", confidence="running",
|
||||
last_verified="2026-10-01", tags="agents, llm", body=PROSE,
|
||||
),
|
||||
"edge/l3/edge-zigbee.md": dict(
|
||||
id="edge-zigbee", area="edge", level=3, title="Low Power Mesh Sensing",
|
||||
claim="A constrained mesh that survives power loss.", confidence="delivered",
|
||||
last_verified="2026-09-15", tags="zigbee, mesh", body=FENCE,
|
||||
),
|
||||
"platform/l3/platform-containers.md": dict(
|
||||
id="platform-containers", area="platform", level=3, title="Container Estate",
|
||||
claim="Fifty containers with measured resource limits.", confidence="in-progress",
|
||||
last_verified="2026-08-01", tags="docker", body=TABLE,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def temp_repo(docs: dict = None) -> str:
|
||||
"""Create a throwaway repository root with tools/ and content/."""
|
||||
root = tempfile.mkdtemp(prefix="corpus-test-")
|
||||
os.makedirs(os.path.join(root, "tools"), exist_ok=True)
|
||||
shutil.copy(
|
||||
os.path.join(REPO_ROOT, "tools", "validate_content.py"),
|
||||
os.path.join(root, "tools", "validate_content.py"),
|
||||
)
|
||||
write_docs(root, docs or DEFAULT_DOCS)
|
||||
return root
|
||||
|
||||
|
||||
def write_docs(root: str, docs: dict) -> None:
|
||||
for rel, fields in docs.items():
|
||||
path = os.path.join(root, "content", rel)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(DOC.format(**fields))
|
||||
|
||||
|
||||
def write_doc(root: str, rel: str, **fields) -> None:
|
||||
path = os.path.join(root, "content", rel)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(DOC.format(**fields))
|
||||
# A fresh interpreter's import cache is fine; the loader re-reads the file.
|
||||
|
||||
|
||||
def make_config(root: str, **overrides) -> Config:
|
||||
values = dict(
|
||||
provider="local-hashing",
|
||||
model="local-hashing-v1",
|
||||
dim=32,
|
||||
qdrant_url="fake://qdrant",
|
||||
collection="test_corpus",
|
||||
)
|
||||
values.update(overrides)
|
||||
return Config.from_env(root=root, **values)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def fake_qdrant(cfg, fake: FakeQdrant = None):
|
||||
"""Point every module that builds a Qdrant client at one in-memory fake."""
|
||||
fake = fake or FakeQdrant(cfg.dim)
|
||||
with mock.patch("corpus.pipeline.Qdrant", return_value=fake), mock.patch(
|
||||
"corpus.freshness.Qdrant", return_value=fake
|
||||
):
|
||||
yield fake
|
||||
|
||||
|
||||
class StubServer:
|
||||
"""A throwaway HTTP server on a loopback ephemeral port, for provider tests.
|
||||
|
||||
`responder(method, path, body)` returns `(status, payload)`; every request is
|
||||
recorded with its parsed body so a test can assert the wire shape without a
|
||||
real provider or a real Qdrant.
|
||||
"""
|
||||
|
||||
def __init__(self, responder):
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
self.requests = []
|
||||
self._responder = responder
|
||||
outer = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def _handle(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(length).decode("utf-8") if length else ""
|
||||
body = json.loads(raw) if raw.strip() else None
|
||||
outer.requests.append(
|
||||
{"method": self.command, "path": self.path, "body": body,
|
||||
"headers": dict(self.headers)}
|
||||
)
|
||||
status, payload = outer._responder(self.command, self.path, body)
|
||||
data = payload.encode("utf-8") if isinstance(payload, str) else json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
do_GET = do_POST = do_PUT = do_DELETE = _handle
|
||||
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.url = f"http://127.0.0.1:{self.server.server_address[1]}"
|
||||
self._thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
|
||||
def __enter__(self):
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
145
corpus/tests/test_chunker.py
Normal file
145
corpus/tests/test_chunker.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Chunking rules: size, overlap, determinism, and the hard one — no split inside
|
||||
a fence, a table or front-matter (docs/phase-4-corpus.md DoD item 3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from corpus import chunker, loader
|
||||
from corpus.config import Config
|
||||
from corpus.tests import support
|
||||
|
||||
REPO_ROOT = support.REPO_ROOT
|
||||
|
||||
|
||||
def content_documents():
|
||||
cfg = Config.from_env(root=REPO_ROOT, provider="local-hashing", dim=32)
|
||||
return loader.load_documents(cfg)
|
||||
|
||||
|
||||
class BlockParsingTest(unittest.TestCase):
|
||||
def test_fence_is_one_block(self):
|
||||
blocks = chunker.parse_blocks(support.FENCE)
|
||||
kinds = [block.kind for block in blocks]
|
||||
self.assertEqual(kinds, ["para", "fence", "para"])
|
||||
self.assertIn("chunk boundary must never land", blocks[1].text)
|
||||
|
||||
def test_table_is_one_block(self):
|
||||
blocks = chunker.parse_blocks(support.TABLE)
|
||||
table = [block for block in blocks if block.kind == "table"]
|
||||
self.assertEqual(len(table), 1)
|
||||
self.assertEqual(len(table[0].text.splitlines()), 5) # header, rule, 3 rows
|
||||
|
||||
def test_front_matter_in_body_is_refused(self):
|
||||
with self.assertRaises(chunker.ChunkError):
|
||||
chunker.parse_blocks("---\nid: x\n---\n\n# Heading\n")
|
||||
|
||||
def test_unterminated_fence_runs_to_end(self):
|
||||
blocks = chunker.parse_blocks("intro\n\n```go\nfunc main() {}\n")
|
||||
self.assertEqual(blocks[-1].kind, "fence")
|
||||
self.assertTrue(blocks[-1].text.rstrip().endswith("}"))
|
||||
|
||||
def test_headings_start_blocks(self):
|
||||
blocks = chunker.parse_blocks("# One\n\ntext\n\n## Two\n\nmore\n")
|
||||
self.assertEqual([block.kind for block in blocks], ["heading", "para", "heading", "para"])
|
||||
|
||||
def test_list_items_are_separate_blocks(self):
|
||||
blocks = chunker.parse_blocks("- one\n- two\n continued\n- three\n")
|
||||
self.assertEqual([block.kind for block in blocks], ["list", "list", "list"])
|
||||
self.assertIn("continued", blocks[1].text)
|
||||
|
||||
|
||||
class ChunkingTest(unittest.TestCase):
|
||||
def test_small_body_is_one_chunk(self):
|
||||
chunks = chunker.chunk_document("# Title\n\nA short body.\n")
|
||||
self.assertEqual(len(chunks), 1)
|
||||
self.assertEqual(chunks[0].index, 0)
|
||||
self.assertFalse(chunks[0].oversize)
|
||||
|
||||
def test_long_body_respects_maximum_and_overlaps(self):
|
||||
chunks = chunker.chunk_document(support.PROSE)
|
||||
self.assertGreater(len(chunks), 1)
|
||||
for chunk in chunks:
|
||||
self.assertLessEqual(chunk.tokens, chunker.TOKEN_MAX, f"chunk {chunk.index} too large")
|
||||
# Overlap: consecutive chunks share at least one block.
|
||||
for previous, current in zip(chunks, chunks[1:]):
|
||||
self.assertTrue(
|
||||
set(id(block) for block in previous.blocks) & set(id(block) for block in current.blocks),
|
||||
f"chunks {previous.index} and {current.index} do not overlap",
|
||||
)
|
||||
|
||||
def test_chunk_indices_are_sequential(self):
|
||||
chunks = chunker.chunk_document(support.PROSE)
|
||||
self.assertEqual([chunk.index for chunk in chunks], list(range(len(chunks))))
|
||||
|
||||
def test_deterministic(self):
|
||||
first = chunker.chunk_document(support.PROSE)
|
||||
second = chunker.chunk_document(support.PROSE)
|
||||
self.assertEqual([chunk.text for chunk in first], [chunk.text for chunk in second])
|
||||
|
||||
def test_code_fence_is_never_split(self):
|
||||
body = support.PROSE + "\n\n" + support.FENCE + "\n\n" + support.PROSE
|
||||
chunks = chunker.chunk_document(body)
|
||||
fences = [block for block in chunker.parse_blocks(body) if block.kind == "fence"]
|
||||
self.assertTrue(fences)
|
||||
for fence in fences:
|
||||
holders = [chunk for chunk in chunks if fence in chunk.blocks]
|
||||
self.assertTrue(holders, "the fenced block disappeared from every chunk")
|
||||
for holder in holders:
|
||||
self.assertIn(fence.text, holder.text, "a chunk holds part of a fenced block")
|
||||
|
||||
def test_oversize_single_block_is_flagged_not_split(self):
|
||||
huge = "```go\n" + "\n".join(f"line{i} := {i}" for i in range(1200)) + "\n```\n"
|
||||
chunks = chunker.chunk_document(huge)
|
||||
self.assertEqual(len(chunks), 1)
|
||||
self.assertTrue(chunks[0].oversize)
|
||||
self.assertGreater(chunks[0].tokens, chunker.TOKEN_MAX)
|
||||
|
||||
|
||||
class RealContentTest(unittest.TestCase):
|
||||
"""The same rules, over every document in content/."""
|
||||
|
||||
def test_no_boundary_splits_a_protected_region(self):
|
||||
for doc in content_documents():
|
||||
regions = chunker.protected_regions(doc.body)
|
||||
chunks = chunker.chunk_document(doc.body)
|
||||
chunker.verify_no_split(chunks, regions) # raises on failure
|
||||
|
||||
def test_every_chunk_is_whole_blocks(self):
|
||||
for doc in content_documents():
|
||||
expected = [block.text for block in chunker.parse_blocks(doc.body)]
|
||||
seen = []
|
||||
for chunk in chunker.chunk_document(doc.body):
|
||||
for block in chunk.blocks:
|
||||
self.assertIn(block.text, chunk.text)
|
||||
if not seen or seen[-1] != block.text:
|
||||
seen.append(block.text)
|
||||
# Every block appears, in order, exactly as parsed (overlap repeats a
|
||||
# block at the start of the next chunk, which is expected).
|
||||
position = 0
|
||||
for text in expected:
|
||||
while position < len(seen) and seen[position] != text:
|
||||
position += 1
|
||||
self.assertLess(position, len(seen), f"{doc.path}: block was dropped: {text[:40]!r}")
|
||||
position += 1
|
||||
|
||||
def test_chunks_are_within_the_size_band(self):
|
||||
for doc in content_documents():
|
||||
for chunk in chunker.chunk_document(doc.body):
|
||||
if chunk.oversize:
|
||||
self.assertEqual(len(chunk.blocks), 1)
|
||||
continue
|
||||
self.assertLessEqual(chunk.tokens, chunker.TOKEN_MAX, f"{doc.path} chunk {chunk.index}")
|
||||
|
||||
def test_content_tree_is_chunked_completely(self):
|
||||
total = 0
|
||||
for doc in content_documents():
|
||||
chunks = chunker.chunk_document(doc.body)
|
||||
self.assertTrue(chunks, f"{doc.path}: no chunks")
|
||||
total += len(chunks)
|
||||
self.assertGreater(total, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
125
corpus/tests/test_embeddings.py
Normal file
125
corpus/tests/test_embeddings.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Providers: determinism offline, and an OpenAI-compatible wire shape through OmniRoute.
|
||||
|
||||
There is no direct vendor SDK call anywhere (AGENTS.md §4.5), so the provider
|
||||
test asserts the HTTP request shape, not a client library's behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import unittest
|
||||
|
||||
from corpus import embeddings
|
||||
from corpus.tests import support
|
||||
|
||||
|
||||
class LocalHashingTest(unittest.TestCase):
|
||||
def test_deterministic_and_unit_length(self):
|
||||
provider = embeddings.LocalHashingProvider(dim=64)
|
||||
first = provider.embed(["zigbee mesh latency"])[0]
|
||||
second = embeddings.LocalHashingProvider(dim=64).embed(["zigbee mesh latency"])[0]
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(len(first), 64)
|
||||
self.assertAlmostEqual(math.sqrt(sum(value * value for value in first)), 1.0, places=9)
|
||||
|
||||
def test_different_text_differs(self):
|
||||
provider = embeddings.LocalHashingProvider(dim=64)
|
||||
a, b = provider.embed(["zigbee", "lora"])
|
||||
self.assertNotEqual(a, b)
|
||||
|
||||
def test_empty_text_still_returns_a_unit_vector(self):
|
||||
provider = embeddings.LocalHashingProvider(dim=16)
|
||||
vector = provider.embed([""])[0]
|
||||
self.assertEqual(len(vector), 16)
|
||||
self.assertAlmostEqual(math.sqrt(sum(value * value for value in vector)), 1.0, places=9)
|
||||
|
||||
def test_stats_count_calls_and_texts(self):
|
||||
provider = embeddings.LocalHashingProvider(dim=16)
|
||||
provider.embed(["a", "b", "c"])
|
||||
provider.embed(["d"])
|
||||
self.assertEqual(provider.stats(), {"calls": 2, "texts": 4})
|
||||
provider.reset_stats()
|
||||
self.assertEqual(provider.stats(), {"calls": 0, "texts": 0})
|
||||
|
||||
def test_rejects_a_zero_dimension(self):
|
||||
with self.assertRaises(embeddings.EmbeddingError):
|
||||
embeddings.LocalHashingProvider(dim=0)
|
||||
|
||||
|
||||
class OmniRouteTest(unittest.TestCase):
|
||||
def responder_factory(self, dim=8, status_plan=None):
|
||||
plan = list(status_plan or [])
|
||||
state = {"n": 0}
|
||||
|
||||
def responder(method, path, body):
|
||||
state["n"] += 1
|
||||
if plan:
|
||||
status = plan.pop(0)
|
||||
if status != 200:
|
||||
return status, {"error": "planned failure"}
|
||||
vectors = [
|
||||
{"index": index, "embedding": [0.1] * dim}
|
||||
for index, _text in enumerate(body.get("input") or [])
|
||||
]
|
||||
return 200, {"model": body.get("model"), "data": vectors}
|
||||
|
||||
return responder
|
||||
|
||||
def test_posts_to_v1_embeddings_with_the_configured_model(self):
|
||||
with support.StubServer(self.responder_factory()) as server:
|
||||
provider = embeddings.OmniRouteProvider(
|
||||
api_url=server.url, api_key="test-key", model="text-embedding-3-small", dim=8
|
||||
)
|
||||
vectors = provider.embed(["one", "two"])
|
||||
self.assertEqual(len(vectors), 2)
|
||||
request = server.requests[0]
|
||||
self.assertEqual(request["method"], "POST")
|
||||
self.assertEqual(request["path"], "/v1/embeddings")
|
||||
self.assertEqual(request["body"]["model"], "text-embedding-3-small")
|
||||
self.assertEqual(request["body"]["input"], ["one", "two"])
|
||||
self.assertEqual(request["headers"]["Authorization"], "Bearer test-key")
|
||||
self.assertEqual(provider.stats(), {"calls": 1, "texts": 2})
|
||||
|
||||
def test_batches_requests(self):
|
||||
with support.StubServer(self.responder_factory()) as server:
|
||||
provider = embeddings.OmniRouteProvider(
|
||||
api_url=server.url, api_key="k", model="m", dim=8, batch_size=2
|
||||
)
|
||||
provider.embed(["a", "b", "c", "d", "e"])
|
||||
self.assertEqual(len(server.requests), 3)
|
||||
self.assertEqual(provider.stats(), {"calls": 3, "texts": 5})
|
||||
|
||||
def test_dimension_mismatch_is_refused(self):
|
||||
with support.StubServer(self.responder_factory(dim=4)) as server:
|
||||
provider = embeddings.OmniRouteProvider(
|
||||
api_url=server.url, api_key="k", model="m", dim=8
|
||||
)
|
||||
with self.assertRaises(embeddings.EmbeddingError) as caught:
|
||||
provider.embed(["one"])
|
||||
self.assertIn("dimension", str(caught.exception))
|
||||
|
||||
def test_retries_then_succeeds(self):
|
||||
with support.StubServer(self.responder_factory(status_plan=[429, 200])) as server:
|
||||
provider = embeddings.OmniRouteProvider(
|
||||
api_url=server.url, api_key="k", model="m", dim=8, backoff=0.01
|
||||
)
|
||||
vectors = provider.embed(["one"])
|
||||
self.assertEqual(len(vectors), 1)
|
||||
self.assertEqual(len(server.requests), 2)
|
||||
|
||||
def test_client_error_is_not_retried(self):
|
||||
with support.StubServer(self.responder_factory(status_plan=[400])) as server:
|
||||
provider = embeddings.OmniRouteProvider(
|
||||
api_url=server.url, api_key="k", model="m", dim=8, backoff=0.01
|
||||
)
|
||||
with self.assertRaises(embeddings.EmbeddingError):
|
||||
provider.embed(["one"])
|
||||
self.assertEqual(len(server.requests), 1)
|
||||
|
||||
def test_missing_key_is_refused_before_any_call(self):
|
||||
with self.assertRaises(embeddings.EmbeddingError):
|
||||
embeddings.OmniRouteProvider(api_url="http://127.0.0.1:1", api_key="", model="m", dim=8)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
110
corpus/tests/test_freshness.py
Normal file
110
corpus/tests/test_freshness.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Freshness audit: stale documents, index drift and contract drift."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import unittest
|
||||
|
||||
from corpus import freshness, pipeline, store as store_module
|
||||
from corpus.tests import support
|
||||
|
||||
|
||||
class AuditTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = support.temp_repo()
|
||||
self.cfg = support.make_config(self.root)
|
||||
self.addCleanup(_cleanup, self.root)
|
||||
with support.fake_qdrant(self.cfg):
|
||||
pipeline.run(self.cfg, mode="rebuild")
|
||||
|
||||
def audit(self, today=None, cfg=None):
|
||||
with support.fake_qdrant(cfg or self.cfg):
|
||||
return freshness.audit(cfg or self.cfg, today=today)
|
||||
|
||||
def test_clean_soon_after_rebuild(self):
|
||||
report = self.audit(today=datetime.date(2026, 10, 5))
|
||||
self.assertTrue(report["ok"], report["findings"])
|
||||
self.assertEqual(report["corpus"]["documents"], 3)
|
||||
|
||||
def test_reports_stale_documents(self):
|
||||
report = self.audit(today=datetime.date(2027, 10, 5))
|
||||
stale = {row["source_path"] for row in report["findings"]["stale"]}
|
||||
self.assertEqual(
|
||||
stale,
|
||||
{
|
||||
"content/ai/ai-overview.md",
|
||||
"content/edge/l3/edge-zigbee.md",
|
||||
"content/platform/l3/platform-containers.md",
|
||||
},
|
||||
)
|
||||
self.assertFalse(report["ok"])
|
||||
text = freshness.render_text(report)
|
||||
self.assertIn("STALE", text)
|
||||
self.assertIn("platform-containers", text)
|
||||
|
||||
def test_warns_before_a_document_goes_stale(self):
|
||||
# platform-containers was verified 2026-08-01. Horizon 180 days, warning
|
||||
# 30, so on 2027-01-20 it is 172 days old: due soon, not yet stale.
|
||||
report = self.audit(today=datetime.date(2027, 1, 20))
|
||||
due = {row["source_path"] for row in report["findings"]["due_soon"]}
|
||||
self.assertEqual(due, {"content/platform/l3/platform-containers.md"})
|
||||
self.assertEqual(report["findings"]["stale"], [])
|
||||
self.assertTrue(report["ok"])
|
||||
|
||||
def test_reports_index_drift_when_a_source_changes(self):
|
||||
support.write_doc(
|
||||
self.root,
|
||||
"ai/ai-overview.md",
|
||||
id="ai-overview", area="ai", level=1, title="Agent Systems",
|
||||
claim="Edited but not re-indexed.", confidence="running",
|
||||
last_verified="2026-10-05", tags="agents", body="New words for the corpus.",
|
||||
)
|
||||
report = self.audit(today=datetime.date(2026, 10, 6))
|
||||
problems = [row["problem"] for row in report["findings"]["index_drift"]]
|
||||
self.assertIn("source changed since it was embedded", problems)
|
||||
self.assertFalse(report["ok"])
|
||||
|
||||
def test_reports_contract_drift_when_the_model_changes(self):
|
||||
other = support.make_config(self.root, model="different-model")
|
||||
report = self.audit(today=datetime.date(2026, 10, 5), cfg=other)
|
||||
self.assertTrue(report["findings"]["contract_drift"])
|
||||
|
||||
def test_report_is_json_serialisable(self):
|
||||
import json
|
||||
|
||||
report = self.audit(today=datetime.date(2026, 10, 5))
|
||||
json.dumps(report)
|
||||
|
||||
|
||||
class AlertTest(unittest.TestCase):
|
||||
def test_no_url_means_no_alert(self):
|
||||
report = {"ok": True, "findings": {"stale": []}, "corpus": {}, "horizon": {}, "summary": {}}
|
||||
self.assertFalse(freshness.alert(report, ""))
|
||||
|
||||
def test_posts_to_apprise(self):
|
||||
report = {
|
||||
"generated_at": "2026-10-26T00:00:00Z",
|
||||
"today": "2026-10-26",
|
||||
"horizon": {"max_age_days": 180, "warn_days": 30},
|
||||
"corpus": {
|
||||
"documents": 1, "chunks": 1, "sources": 1, "collection": "c",
|
||||
"collection_points": 1, "provider": "p", "model": "m", "dim": 8,
|
||||
"chunker_version": "v",
|
||||
},
|
||||
"findings": {"stale": [], "due_soon": [], "index_drift": [], "contract_drift": []},
|
||||
"summary": {"stale": 0},
|
||||
"ok": True,
|
||||
}
|
||||
with support.StubServer(lambda method, path, body: (200, "ok")) as server:
|
||||
self.assertTrue(freshness.alert(report, server.url))
|
||||
self.assertIn("title", server.requests[0]["body"])
|
||||
|
||||
|
||||
def _cleanup(root):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
100
corpus/tests/test_payloads.py
Normal file
100
corpus/tests/test_payloads.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Payload rules: every field present, enums enforced, missing field fails the run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from corpus import chunker, loader, payloads
|
||||
from corpus.config import Config
|
||||
from corpus.pipeline import chunk_id
|
||||
from corpus.tests import support
|
||||
|
||||
|
||||
def first_payload():
|
||||
cfg = Config.from_env(root=support.REPO_ROOT, provider="local-hashing", dim=32)
|
||||
doc = loader.load_documents(cfg)[0]
|
||||
chunk = chunker.chunk_document(doc.body)[0]
|
||||
payload = payloads.build_payload(doc, chunk, cfg)
|
||||
payload["id"] = chunk_id(doc.path, chunk.index, doc.digest)
|
||||
return cfg, doc, payload
|
||||
|
||||
|
||||
class BuildTest(unittest.TestCase):
|
||||
def test_payload_has_every_required_field(self):
|
||||
cfg, doc, payload = first_payload()
|
||||
payloads.validate_payload(payload)
|
||||
for field in payloads.REQUIRED_FIELDS:
|
||||
self.assertIn(field, payload)
|
||||
self.assertEqual(payload["area"], doc.area)
|
||||
self.assertEqual(payload["confidence"], doc.confidence)
|
||||
self.assertEqual(payload["last_verified"], doc.last_verified)
|
||||
self.assertEqual(payload["embedding_model"], cfg.model)
|
||||
self.assertEqual(payload["embedding_dim"], cfg.dim)
|
||||
|
||||
def test_every_real_document_produces_valid_payloads(self):
|
||||
cfg = Config.from_env(root=support.REPO_ROOT, provider="local-hashing", dim=32)
|
||||
for doc in loader.load_documents(cfg):
|
||||
for chunk in chunker.chunk_document(doc.body):
|
||||
payload = payloads.build_payload(doc, chunk, cfg)
|
||||
payload["id"] = chunk_id(doc.path, chunk.index, doc.digest)
|
||||
payloads.validate_payload(payload, where=doc.path)
|
||||
|
||||
def test_source_type_is_doc_by_default(self):
|
||||
_cfg, doc, payload = first_payload()
|
||||
self.assertEqual(payload["source_type"], "doc")
|
||||
|
||||
def test_source_type_from_tags(self):
|
||||
doc = loader.Document(
|
||||
path="content/ai/adr-x.md", source_id="adr-x", area="ai", level=3, title="ADR",
|
||||
claim="c", confidence="delivered", period="2024", tech=[], metrics=[], artefacts=[],
|
||||
public=True, last_verified="2026-01-01", tags=["adr"], body="", digest="0" * 64,
|
||||
url="/ai/a/adr-x/",
|
||||
)
|
||||
self.assertEqual(payloads.source_type_for(doc), "adr")
|
||||
|
||||
|
||||
class ValidationTest(unittest.TestCase):
|
||||
def test_missing_field_raises(self):
|
||||
_cfg, _doc, payload = first_payload()
|
||||
for field in ("area", "level", "source_type", "confidence", "period", "artefacts", "last_verified", "text"):
|
||||
broken = dict(payload)
|
||||
del broken[field]
|
||||
with self.assertRaises(payloads.PayloadError, msg=f"missing {field} passed validation"):
|
||||
payloads.validate_payload(broken)
|
||||
|
||||
def test_missing_provenance_field_raises(self):
|
||||
_cfg, _doc, payload = first_payload()
|
||||
for field in payloads.PROVENANCE_FIELDS:
|
||||
broken = dict(payload)
|
||||
del broken[field]
|
||||
with self.assertRaises(payloads.PayloadError, msg=f"missing {field} passed validation"):
|
||||
payloads.validate_payload(broken)
|
||||
|
||||
def test_bad_enums_raise(self):
|
||||
_cfg, _doc, payload = first_payload()
|
||||
for field, value in (
|
||||
("area", "sales"),
|
||||
("level", 4),
|
||||
("source_type", "blog"),
|
||||
("confidence", "vibes"),
|
||||
):
|
||||
broken = dict(payload)
|
||||
broken[field] = value
|
||||
with self.assertRaises(payloads.PayloadError, msg=f"{field}={value!r} passed validation"):
|
||||
payloads.validate_payload(broken)
|
||||
|
||||
def test_empty_text_raises(self):
|
||||
_cfg, _doc, payload = first_payload()
|
||||
payload["text"] = " "
|
||||
with self.assertRaises(payloads.PayloadError):
|
||||
payloads.validate_payload(payload)
|
||||
|
||||
def test_bad_date_raises(self):
|
||||
_cfg, _doc, payload = first_payload()
|
||||
payload["last_verified"] = "last october"
|
||||
with self.assertRaises(payloads.PayloadError):
|
||||
payloads.validate_payload(payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
179
corpus/tests/test_pipeline.py
Normal file
179
corpus/tests/test_pipeline.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""The pipeline: deterministic ids, no duplicate vectors on re-run, incremental mode,
|
||||
incomplete payloads never reach the store (docs/phase-4-corpus.md DoD 1-6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from corpus import chunker, loader, pipeline, store as store_module
|
||||
from corpus.tests import support
|
||||
|
||||
|
||||
class RebuildTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = support.temp_repo()
|
||||
self.cfg = support.make_config(self.root)
|
||||
self.addCleanup(_cleanup, self.root)
|
||||
# One fake for the whole test: a second run must see the first run's points.
|
||||
self.fake = support.FakeQdrant(self.cfg.dim)
|
||||
|
||||
def run_pipeline(self, mode="sync", cfg=None):
|
||||
cfg = cfg or self.cfg
|
||||
with support.fake_qdrant(cfg, self.fake):
|
||||
return pipeline.run(cfg, mode=mode)
|
||||
|
||||
def test_rebuild_from_empty_volume(self):
|
||||
stats = self.run_pipeline("rebuild")
|
||||
self.assertEqual(stats.documents, 3)
|
||||
self.assertEqual(stats.added, 3)
|
||||
self.assertGreater(stats.store_chunks, 0)
|
||||
self.assertEqual(stats.store_chunks, stats.collection_points)
|
||||
self.assertEqual(stats.collection_points, len(self.fake.collections[self.cfg.collection]))
|
||||
self.assertGreater(stats.embed_calls, 0)
|
||||
|
||||
def test_running_twice_does_not_duplicate_vectors(self):
|
||||
first = self.run_pipeline("rebuild")
|
||||
points_after_first = len(self.fake.collections[self.cfg.collection])
|
||||
second = self.run_pipeline("sync")
|
||||
points_after_second = len(self.fake.collections[self.cfg.collection])
|
||||
self.assertEqual(points_after_first, points_after_second)
|
||||
self.assertEqual(first.store_chunks, second.store_chunks)
|
||||
self.assertEqual(second.added, 0)
|
||||
self.assertEqual(second.updated, 0)
|
||||
self.assertEqual(second.chunks_written, 0)
|
||||
|
||||
def test_incremental_reembeds_only_changed_documents(self):
|
||||
self.run_pipeline("rebuild")
|
||||
|
||||
# Nothing changed: no embedding calls at all.
|
||||
unchanged = self.run_pipeline("sync")
|
||||
self.assertEqual(unchanged.embed_calls, 0)
|
||||
self.assertEqual(unchanged.texts_embedded, 0)
|
||||
|
||||
# One document changes: exactly one embedding call, for that document.
|
||||
support.write_doc(
|
||||
self.root,
|
||||
"edge/l3/edge-zigbee.md",
|
||||
id="edge-zigbee", area="edge", level=3, title="Low Power Mesh Sensing",
|
||||
claim="A constrained mesh that survives power loss.", confidence="delivered",
|
||||
last_verified="2026-09-15", tags="zigbee, mesh",
|
||||
body="The mesh now also reports battery voltage on every node.",
|
||||
)
|
||||
changed = self.run_pipeline("sync")
|
||||
self.assertEqual(changed.added + changed.updated, 1)
|
||||
self.assertEqual(changed.embed_calls, 1)
|
||||
expected_chunks = len(
|
||||
chunker.chunk_document(
|
||||
loader.load_documents(support.make_config(self.root))[1].body
|
||||
)
|
||||
)
|
||||
self.assertEqual(changed.texts_embedded, expected_chunks)
|
||||
self.assertEqual(changed.unchanged, 2)
|
||||
|
||||
def test_removed_document_is_removed_from_both_indexes(self):
|
||||
self.run_pipeline("rebuild")
|
||||
before = self.run_pipeline("sync").store_chunks
|
||||
os.remove(os.path.join(self.root, "content", "platform", "l3", "platform-containers.md"))
|
||||
after = self.run_pipeline("sync")
|
||||
self.assertEqual(after.removed, 1)
|
||||
self.assertLess(after.store_chunks, before)
|
||||
self.assertEqual(after.store_chunks, after.collection_points)
|
||||
with store_module.Store(self.cfg.db_path) as store:
|
||||
self.assertEqual(store.keyword_search("endpoint latency loss"), [])
|
||||
|
||||
def test_ids_are_deterministic(self):
|
||||
first = pipeline.chunk_id("content/ai/ai-overview.md", 0, "a" * 64)
|
||||
second = pipeline.chunk_id("content/ai/ai-overview.md", 0, "a" * 64)
|
||||
other = pipeline.chunk_id("content/ai/ai-overview.md", 1, "a" * 64)
|
||||
self.assertEqual(first, second)
|
||||
self.assertNotEqual(first, other)
|
||||
self.assertEqual(len(first), 36)
|
||||
|
||||
def test_invalid_payload_aborts_before_any_write(self):
|
||||
real_build = pipeline.payloads.build_payload
|
||||
|
||||
def broken(doc, chunk, cfg):
|
||||
payload = real_build(doc, chunk, cfg)
|
||||
payload.pop("confidence")
|
||||
return payload
|
||||
|
||||
with mock.patch("corpus.payloads.build_payload", side_effect=broken):
|
||||
with self.assertRaises(pipeline.PipelineError):
|
||||
self.run_pipeline("rebuild")
|
||||
self.assertEqual(self.fake.collections.get(self.cfg.collection, {}), {})
|
||||
|
||||
def test_provider_change_is_refused_without_rebuild(self):
|
||||
self.run_pipeline("rebuild")
|
||||
other = support.make_config(self.root, model="local-hashing-v2")
|
||||
with self.assertRaises(pipeline.PipelineError) as caught:
|
||||
self.run_pipeline("sync", cfg=other)
|
||||
self.assertIn("governed migration", str(caught.exception))
|
||||
|
||||
def test_contract_is_recorded(self):
|
||||
self.run_pipeline("rebuild")
|
||||
with store_module.Store(self.cfg.db_path) as store:
|
||||
contract = store.get_meta("collection")
|
||||
self.assertEqual(contract["model"], self.cfg.model)
|
||||
self.assertEqual(contract["dim"], self.cfg.dim)
|
||||
self.assertEqual(contract["chunker_version"], self.cfg.chunker_version)
|
||||
self.assertTrue(os.path.exists(self.cfg.meta_path))
|
||||
|
||||
|
||||
class VerifyTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = support.temp_repo()
|
||||
self.cfg = support.make_config(self.root)
|
||||
self.addCleanup(_cleanup, self.root)
|
||||
|
||||
def test_clean_after_rebuild(self):
|
||||
with support.fake_qdrant(self.cfg):
|
||||
pipeline.run(self.cfg, mode="rebuild")
|
||||
self.assertEqual(pipeline.verify(self.cfg), [])
|
||||
|
||||
def test_detects_source_changed_without_sync(self):
|
||||
with support.fake_qdrant(self.cfg):
|
||||
pipeline.run(self.cfg, mode="rebuild")
|
||||
support.write_doc(
|
||||
self.root,
|
||||
"ai/ai-overview.md",
|
||||
id="ai-overview", area="ai", level=1, title="Agent Systems",
|
||||
claim="Changed.", confidence="running", last_verified="2026-10-02",
|
||||
tags="agents", body="A different body entirely.",
|
||||
)
|
||||
problems = pipeline.verify(self.cfg)
|
||||
self.assertTrue(any("changed since it was embedded" in problem for problem in problems), problems)
|
||||
|
||||
def test_detects_missing_document(self):
|
||||
with support.fake_qdrant(self.cfg):
|
||||
pipeline.run(self.cfg, mode="rebuild")
|
||||
os.remove(os.path.join(self.root, "content", "ai", "ai-overview.md"))
|
||||
problems = pipeline.verify(self.cfg)
|
||||
self.assertTrue(any("no longer in content/" in problem for problem in problems), problems)
|
||||
|
||||
|
||||
class AreaFilterTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = support.temp_repo()
|
||||
self.cfg = support.make_config(self.root)
|
||||
self.addCleanup(_cleanup, self.root)
|
||||
|
||||
def test_filter_narrows_the_result_set(self):
|
||||
with support.fake_qdrant(self.cfg):
|
||||
pipeline.run(self.cfg, mode="rebuild")
|
||||
result = pipeline.demo_area_filter(self.cfg, "container estate limits", area="edge")
|
||||
self.assertTrue(result["unfiltered"])
|
||||
self.assertTrue(result["filtered"])
|
||||
self.assertEqual({row["area"] for row in result["filtered"]}, {"edge"})
|
||||
self.assertGreater(len({row["area"] for row in result["unfiltered"]}), 1)
|
||||
|
||||
|
||||
def _cleanup(root):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
104
corpus/tests/test_qdrant_client.py
Normal file
104
corpus/tests/test_qdrant_client.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Qdrant client: the wire shape the pipeline depends on, and the fallback route."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from corpus import qdrant as qdrant_module
|
||||
from corpus.tests import support
|
||||
|
||||
|
||||
class ClientTest(unittest.TestCase):
|
||||
def test_create_collection_sends_size_and_distance(self):
|
||||
with support.StubServer(lambda method, path, body: (200, {"result": True})) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=1)
|
||||
client.create_collection("resume_corpus", 1536)
|
||||
request = server.requests[0]
|
||||
self.assertEqual(request["method"], "PUT")
|
||||
self.assertEqual(request["path"], "/collections/resume_corpus")
|
||||
body = request["body"]
|
||||
self.assertEqual(body["vectors"]["size"], 1536)
|
||||
self.assertEqual(body["vectors"]["distance"], "Cosine")
|
||||
# A memory-bound host: payloads and the HNSW graph live on disk.
|
||||
self.assertTrue(body["on_disk_payload"])
|
||||
self.assertTrue(body["hnsw_config"]["on_disk"])
|
||||
self.assertFalse(body["vectors"]["on_disk"])
|
||||
self.assertGreater(body["optimizers_config"]["memmap_threshold"], 0)
|
||||
|
||||
def test_upsert_waits_and_batches(self):
|
||||
with support.StubServer(lambda method, path, body: (200, {"result": {}})) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=1)
|
||||
points = [{"id": f"id-{index}", "vector": [0.0], "payload": {}} for index in range(5)]
|
||||
sent = client.upsert("c", points, batch=2)
|
||||
self.assertEqual(sent, 5)
|
||||
self.assertEqual(len(server.requests), 3)
|
||||
self.assertTrue(all("wait=true" in request["path"] for request in server.requests))
|
||||
self.assertEqual(sum(len(r["body"]["points"]) for r in server.requests), 5)
|
||||
|
||||
def test_delete_by_source_sends_a_source_filter(self):
|
||||
with support.StubServer(lambda method, path, body: (200, {"result": {}})) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=1)
|
||||
client.delete_by_source("c", "content/edge/l3/edge-zigbee.md")
|
||||
request = server.requests[0]
|
||||
self.assertEqual(request["method"], "POST")
|
||||
self.assertIn("/collections/c/points/delete", request["path"])
|
||||
self.assertEqual(
|
||||
request["body"],
|
||||
{"filter": {"must": [{"key": "source_path", "match": {"value": "content/edge/l3/edge-zigbee.md"}}]}},
|
||||
)
|
||||
|
||||
def test_search_filters_before_searching(self):
|
||||
responder = lambda method, path, body: (200, {"result": {"points": []}}) # noqa: E731
|
||||
with support.StubServer(responder) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=1)
|
||||
client.search("c", [0.1, 0.2], limit=3, area="edge", confidence="delivered")
|
||||
body = server.requests[0]["body"]
|
||||
self.assertEqual(server.requests[0]["path"], "/collections/c/points/query")
|
||||
self.assertEqual(body["limit"], 3)
|
||||
self.assertEqual(
|
||||
body["filter"],
|
||||
{
|
||||
"must": [
|
||||
{"key": "area", "match": {"value": "edge"}},
|
||||
{"key": "confidence", "match": {"value": "delivered"}},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def test_search_falls_back_to_the_legacy_route(self):
|
||||
def responder(method, path, body):
|
||||
if path.endswith("/points/query"):
|
||||
return 404, {"status": "not found"}
|
||||
return 200, {"result": [{"id": "p1", "score": 0.9, "payload": {"area": "ai"}}]}
|
||||
|
||||
with support.StubServer(responder) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=1)
|
||||
hits = client.search("c", [0.1])
|
||||
self.assertEqual([hit["id"] for hit in hits], ["p1"])
|
||||
self.assertEqual([r["path"] for r in server.requests][-1], "/collections/c/points/search")
|
||||
|
||||
def test_point_count_is_exact(self):
|
||||
responder = lambda method, path, body: (200, {"result": {"count": 42}}) # noqa: E731
|
||||
with support.StubServer(responder) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=1)
|
||||
self.assertEqual(client.point_count("c"), 42)
|
||||
self.assertEqual(server.requests[0]["body"], {"exact": True})
|
||||
|
||||
def test_health_is_false_when_unreachable(self):
|
||||
client = qdrant_module.Qdrant("http://127.0.0.1:1", timeout=0.2, attempts=1)
|
||||
self.assertFalse(client.health())
|
||||
|
||||
def test_upsert_retries_a_server_error(self):
|
||||
plan = [500, 200]
|
||||
|
||||
def responder(method, path, body):
|
||||
return plan.pop(0), {"result": {}}
|
||||
|
||||
with support.StubServer(responder) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=2)
|
||||
client.upsert("c", [{"id": "x", "vector": [0.0], "payload": {}}])
|
||||
self.assertEqual(len(server.requests), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
106
corpus/tests/test_store.py
Normal file
106
corpus/tests/test_store.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""FTS5 chunk index, manifest and meta: the keyword half of hybrid retrieval."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from corpus import chunker, loader, payloads, store as store_module
|
||||
from corpus.config import Config
|
||||
from corpus.pipeline import chunk_id
|
||||
from corpus.tests import support
|
||||
|
||||
|
||||
def build_store(path, cfg, documents):
|
||||
store = store_module.Store(path)
|
||||
for doc in documents:
|
||||
payload_list = []
|
||||
for chunk in chunker.chunk_document(doc.body):
|
||||
payload = payloads.build_payload(doc, chunk, cfg)
|
||||
payload["id"] = chunk_id(doc.path, chunk.index, doc.digest)
|
||||
payload_list.append(payload)
|
||||
store.write_document(doc, payload_list)
|
||||
return store
|
||||
|
||||
|
||||
class StoreTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = support.temp_repo()
|
||||
self.cfg = support.make_config(self.root)
|
||||
self.addCleanup(_cleanup, self.root)
|
||||
self.documents = loader.load_documents(self.cfg)
|
||||
self.store = build_store(self.cfg.db_path, self.cfg, self.documents)
|
||||
self.addCleanup(self.store.close)
|
||||
|
||||
def test_counts_match_the_documents(self):
|
||||
self.assertEqual(self.store.source_count(), len(self.documents))
|
||||
expected = sum(len(chunker.chunk_document(doc.body)) for doc in self.documents)
|
||||
self.assertEqual(self.store.chunk_count(), expected)
|
||||
|
||||
def test_keyword_search_finds_exact_tokens(self):
|
||||
hits = self.store.keyword_search("zigbee")
|
||||
self.assertTrue(hits, "no hit for a token that is literally in the document")
|
||||
self.assertEqual(hits[0]["area"], "edge")
|
||||
|
||||
def test_keyword_search_respects_area_filter(self):
|
||||
all_hits = self.store.keyword_search("latency")
|
||||
edge_hits = self.store.keyword_search("latency", area="edge")
|
||||
self.assertTrue(all_hits)
|
||||
for hit in edge_hits:
|
||||
self.assertEqual(hit["area"], "edge")
|
||||
|
||||
def test_keyword_search_returns_snippets_and_scores(self):
|
||||
hits = self.store.keyword_search("fence")
|
||||
self.assertTrue(hits)
|
||||
self.assertIn("score", hits[0])
|
||||
self.assertIn("snippet", hits[0])
|
||||
|
||||
def test_operator_injection_is_not_honoured(self):
|
||||
# FTS5 operators must be treated as words, never as query syntax.
|
||||
hits = self.store.keyword_search('edge OR "NEAR"(a b)')
|
||||
self.assertIsInstance(hits, list)
|
||||
|
||||
def test_manifest_records_the_content_hash(self):
|
||||
manifest = self.store.manifest()
|
||||
self.assertEqual(set(manifest), {doc.path for doc in self.documents})
|
||||
for doc in self.documents:
|
||||
self.assertEqual(manifest[doc.path]["content_hash"], doc.digest)
|
||||
self.assertGreater(manifest[doc.path]["chunk_count"], 0)
|
||||
|
||||
def test_delete_removes_rows_from_both_tables(self):
|
||||
target = self.documents[1]
|
||||
self.store.delete_document(target.path)
|
||||
self.assertNotIn(target.path, self.store.manifest())
|
||||
for row in self.store.chunk_payloads():
|
||||
self.assertNotEqual(row["source_path"], target.path)
|
||||
|
||||
def test_meta_roundtrip(self):
|
||||
self.store.set_meta("collection", {"model": "x", "dim": 8})
|
||||
self.assertEqual(self.store.get_meta("collection"), {"model": "x", "dim": 8})
|
||||
self.assertIn("collection", self.store.meta())
|
||||
|
||||
def test_payloads_survive_the_roundtrip(self):
|
||||
rows = self.store.chunk_payloads()
|
||||
self.assertTrue(rows)
|
||||
for row in rows:
|
||||
payloads.validate_payload(row)
|
||||
|
||||
|
||||
class MatchExpressionTest(unittest.TestCase):
|
||||
def test_terms_become_prefix_queries(self):
|
||||
self.assertEqual(store_module.match_expression("Zigbee mesh"), '"zigbee"* AND "mesh"*')
|
||||
|
||||
def test_empty_query_is_empty(self):
|
||||
self.assertEqual(store_module.match_expression(" "), "")
|
||||
|
||||
def test_punctuation_is_dropped(self):
|
||||
self.assertEqual(store_module.match_expression("-edge- LoRaWAN, 2023"), '"edge"* AND "lorawan"* AND "2023"*')
|
||||
|
||||
|
||||
def _cleanup(root):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
29
deploy/Dockerfile
Normal file
29
deploy/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
# Runtime image for the Mode A site (docs/phase-2-static-site.md §2.1).
|
||||
#
|
||||
# The image contains no toolchain: `make build dist` on the build host produces
|
||||
# bin/resume-server and dist/, and the image just carries them. That keeps the
|
||||
# runtime offline and small, and it means the deployed bytes are the bytes that
|
||||
# were built and tested, not a second compile.
|
||||
#
|
||||
# Build context is the repository root:
|
||||
#
|
||||
# make build dist
|
||||
# docker build -f deploy/Dockerfile -t ai-resume:local .
|
||||
#
|
||||
# Phase 3 (§3.1) owns the release directory, the compose invocation and the
|
||||
# rollback; this file only describes the process that runs.
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
COPY bin/resume-server /usr/bin/resume-server
|
||||
COPY dist/ /srv/site
|
||||
|
||||
# 0.0.0.0 is inside the container's own network namespace only. The host
|
||||
# publishes the port on 127.0.0.1 (see deploy/compose.yml), which is what keeps
|
||||
# Caddy the only public listener.
|
||||
ENV RESUME_ADDR=0.0.0.0:8096 \
|
||||
RESUME_DIST=/srv/site
|
||||
|
||||
EXPOSE 8096
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/usr/bin/resume-server"]
|
||||
71
deploy/compose.yml
Normal file
71
deploy/compose.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
# Container shape for the Mode A site (docs/phase-2-static-site.md §2.1).
|
||||
#
|
||||
# Values are fixed by the specification and must not drift:
|
||||
# mem_limit == memswap_limit == 120m so the app cannot eat the host's swap
|
||||
# ports published on 127.0.0.1 only Caddy on .35 is the only public listener
|
||||
# logging capped one bad day cannot fill the disk
|
||||
#
|
||||
# Run it (build the artefacts first — Phase 3's deploy.sh does both):
|
||||
#
|
||||
# make build dist
|
||||
# docker compose -f deploy/compose.yml up -d
|
||||
#
|
||||
# `curl http://127.0.0.1:8096/healthz` must answer with static ready.
|
||||
|
||||
# The compose project name is deliberately not `ai-resume`: /home/sam/deployment/
|
||||
# ai-resume on .13 already runs a compose project by that name (backend + db on
|
||||
# the ai-mesh network). Sharing the name would make this site's containers show
|
||||
# up as orphans in that project and vice versa, so anything running `compose
|
||||
# down --remove-orphans` under either project could stop the other. The image
|
||||
# tag stays ai-resume:local (Phase 3 fixes it); only the project namespace is
|
||||
# distinct.
|
||||
name: ai-resume-site
|
||||
|
||||
services:
|
||||
resume:
|
||||
image: ai-resume:local
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
restart: unless-stopped
|
||||
mem_limit: 120m
|
||||
memswap_limit: 120m
|
||||
# Published on this host's LAN address, not loopback. Caddy lives on .35 and
|
||||
# proxies to 192.168.20.13:8096, exactly as it does for offsite (:8095),
|
||||
# photo-filter (:8092), garage-ui (:3909) and prefect (:4200). The port is
|
||||
# reachable inside the house only; Caddy on .35 remains the sole public
|
||||
# listener, which is the intent of AGENTS.md section 4.
|
||||
ports:
|
||||
- "192.168.20.13:8096:8096"
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD", "/usr/bin/resume-server", "--health-check"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
# The project network gets an explicit, small subnet. The host's docker daemon
|
||||
# has every default address pool allocated by other projects ("all predefined
|
||||
# address pools have been fully subnetted"), so an implicit compose network
|
||||
# cannot be created. Pinning /28 of RFC1918 space that no other bridge or route
|
||||
# on .13 uses keeps this deploy independent of the host's pool exhaustion and
|
||||
# does not touch any other container. Single-service stack: two addresses is
|
||||
# plenty.
|
||||
networks:
|
||||
default:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.89.0.0/28
|
||||
gateway: 10.89.0.1
|
||||
|
||||
# Phase 4/5 add at most three more containers (Qdrant, Prefect worker, query
|
||||
# service) inside the 4-container and 1.0 GiB budget in AGENTS.md §4.3. Mode A
|
||||
# needs none of them and must keep working when they are stopped (test A1).
|
||||
400
deploy/deploy.sh
Executable file
400
deploy/deploy.sh
Executable file
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — release, deploy and roll back the Mode A site (docs/phase-3-deploy.md §3.1).
|
||||
#
|
||||
# ./deploy/deploy.sh build, test, stage a release, flip `current`,
|
||||
# start the container, gate on /healthz
|
||||
# ./deploy/deploy.sh --rollback put the previous release back, in one command
|
||||
# ./deploy/deploy.sh --status what is live, what is previous, is it healthy
|
||||
# ./deploy/deploy.sh --release-dir D deploy an already-built release directory
|
||||
# (copied into releases/ first; used by the
|
||||
# rollback rehearsal below)
|
||||
#
|
||||
# Layout on the runtime host — the release directory, not the image tag, is the
|
||||
# unit that runs, so a rollback cannot publish bytes that were never tested:
|
||||
#
|
||||
# /home/sam/deployment/resume/
|
||||
# releases/<utc-timestamp>-<git-sha>/{bin/resume-server,dist/,release.json}
|
||||
# current -> releases/<…> what the container is running
|
||||
# previous -> releases/<…> what --rollback restores
|
||||
#
|
||||
# A deploy is: build → test → stage → flip `current` → build the image from that
|
||||
# release directory → `compose up -d` → `curl /healthz` until ready. Any failure
|
||||
# after the flip puts `current` and the container back on the previous release
|
||||
# and exits non-zero. No step is a model decision (AGENTS.md §4.6); this script
|
||||
# is the only thing that publishes.
|
||||
#
|
||||
# Rollback rehearsal (Definition of Done: "a deliberate bad build proves rollback"):
|
||||
#
|
||||
# good=$(readlink ~/deployment/resume/current)
|
||||
# bad=$(mktemp -d) && mkdir -p "$bad/bin" "$bad/dist"
|
||||
# printf '#!/bin/sh\nexit 1\n' >"$bad/bin/resume-server" # binary that cannot start
|
||||
# chmod +x "$bad/bin/resume-server"
|
||||
# cp -a ~/deployment/resume/"$good"/dist/. "$bad/dist/" # valid pages
|
||||
# ./deploy/deploy.sh --release-dir "$bad" # expect exit 1
|
||||
# readlink ~/deployment/resume/current # still $good
|
||||
#
|
||||
# Run it on the runtime host (.13) from a checkout of this repository. It never
|
||||
# reads content/ and never calls a model.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
APP=ai-resume # image tag (fixed by Phase 3 §3.2)
|
||||
PROJECT=ai-resume-site # compose project; distinct from the older
|
||||
# /home/sam/deployment/ai-resume project on .13
|
||||
PORT=8096
|
||||
HEALTH_PATH=/healthz
|
||||
|
||||
# The host address the port is published on. Caddy runs on .35 and proxies to
|
||||
# this address, so a loopback publish is unreachable from the edge (Sam's
|
||||
# decision, 2026-10-26, interaction 54aeb1a2). Never 0.0.0.0: the port must not
|
||||
# be reachable off the LAN. The health gate below follows this address — once the
|
||||
# port is published on the LAN address the host no longer listens on
|
||||
# 127.0.0.1:8096, so a loopback probe would refuse and every deploy would roll
|
||||
# back.
|
||||
PUBLISH_IP=${RESUME_PUBLISH_IP:-192.168.20.13}
|
||||
|
||||
ROOT=${RESUME_DEPLOY_ROOT:-/home/sam/deployment/resume}
|
||||
RELEASES=$ROOT/releases
|
||||
CURRENT=$ROOT/current
|
||||
PREVIOUS=$ROOT/previous
|
||||
|
||||
KEEP=${RESUME_KEEP_RELEASES:-5} # release directories kept on disk
|
||||
HEALTH_TRIES=${RESUME_HEALTH_TRIES:-40} # seconds to wait for /healthz
|
||||
|
||||
REPO=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
COMPOSE=$REPO/deploy/compose.yml
|
||||
DOCKERFILE=$REPO/deploy/Dockerfile
|
||||
|
||||
MODE=deploy
|
||||
RELEASE_DIR_OVERRIDE=
|
||||
|
||||
log() { printf '%s %s
|
||||
' "$(date -u +%H:%M:%SZ)" "$*" >&2; }
|
||||
warn() { printf '%s WARN %s
|
||||
' "$(date -u +%H:%M:%SZ)" "$*" >&2; }
|
||||
die() { printf '%s FAIL %s
|
||||
' "$(date -u +%H:%M:%SZ)" "$*" >&2; exit 1; }
|
||||
|
||||
on_err() {
|
||||
local rc=$? line=$1
|
||||
printf '%s FAIL line %s exited %s
|
||||
' "$(date -u +%H:%M:%SZ)" "$line" "$rc" >&2
|
||||
exit "$rc"
|
||||
}
|
||||
trap 'on_err $LINENO' ERR
|
||||
|
||||
usage() {
|
||||
sed -n '2,13p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit "${1:-0}"
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--rollback) MODE=rollback ;;
|
||||
--status) MODE=status ;;
|
||||
--release-dir) shift; RELEASE_DIR_OVERRIDE=${1:-} ;;
|
||||
--keep) shift; KEEP=${1:-5} ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) printf 'unknown argument: %s
|
||||
' "$1" >&2; usage 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------- helpers ---
|
||||
|
||||
compose() { docker compose -f "$COMPOSE" -p "$PROJECT" "$@"; }
|
||||
|
||||
current_target() { readlink -- "$CURRENT" 2>/dev/null || true; }
|
||||
previous_target() { readlink -- "$PREVIOUS" 2>/dev/null || true; }
|
||||
|
||||
# flip <relative-path-under-ROOT> — replace the symlink atomically. A reader sees
|
||||
# either the old release or the new one, never a missing link.
|
||||
flip() {
|
||||
ln -sfn "$1" "$CURRENT.tmp"
|
||||
mv -Tf "$CURRENT.tmp" "$CURRENT"
|
||||
}
|
||||
|
||||
release_name_of() { basename "$1"; }
|
||||
|
||||
# The tag deploy/compose.yml runs. Releases are distinguished by content, not by
|
||||
# tag, so compose.yml keeps its fixed image name.
|
||||
image_name() { printf '%s:local' "$APP"; }
|
||||
|
||||
# The port must be ours, or free. 8095 belongs to offsite.lab.
|
||||
check_port() {
|
||||
local listening
|
||||
listening=$(ss -ltnH "sport = :$PORT" 2>/dev/null || true)
|
||||
if [ -n "$listening" ]; then
|
||||
if [ -z "$(compose ps -q resume 2>/dev/null)" ]; then
|
||||
printf '%s
|
||||
' "$listening" >&2
|
||||
die "port $PORT is held by something that is not $APP; pick the next free port and record it in reference/stack.md"
|
||||
fi
|
||||
log "port $PORT is held by our own container (expected on a redeploy)"
|
||||
fi
|
||||
}
|
||||
|
||||
check_tools() {
|
||||
command -v docker >/dev/null || die "docker is not on PATH"
|
||||
docker compose version >/dev/null 2>&1 || die "docker compose v2 is not available"
|
||||
command -v curl >/dev/null || die "curl is not on PATH"
|
||||
command -v ss >/dev/null || die "ss (iproute2) is not on PATH"
|
||||
mkdir -p "$RELEASES"
|
||||
}
|
||||
|
||||
memory_report() {
|
||||
local avail
|
||||
avail=$(awk '/^MemAvailable:/ {print int($2/1024)}' /proc/meminfo)
|
||||
log "host memory available: ${avail} MiB — the container cap is 120 MiB; .13 is memory-tight by design"
|
||||
if [ "${avail:-0}" -lt 512 ]; then
|
||||
warn "under 512 MiB available; the Go build may push this host into swap"
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- build ------
|
||||
|
||||
build_and_test() {
|
||||
log "build + test in $REPO"
|
||||
(
|
||||
cd "$REPO"
|
||||
rm -rf dist
|
||||
if command -v nix >/dev/null 2>&1; then
|
||||
nix develop . -c bash -c 'make validate test dist'
|
||||
elif command -v templ >/dev/null 2>&1 && command -v go >/dev/null 2>&1; then
|
||||
make validate test dist
|
||||
else
|
||||
echo "no toolchain: neither 'nix develop' nor templ+go is available" >&2
|
||||
exit 1
|
||||
fi
|
||||
)
|
||||
[ -f "$REPO/bin/resume-server" ] || die "build produced no bin/resume-server"
|
||||
[ -f "$REPO/dist/index.html" ] || die "build produced no dist/index.html"
|
||||
log "build ok: $(jq -r '"\(.pages) pages, \(.documents) documents, \(.fragments) fragments"' "$REPO/dist/build.json" 2>/dev/null || echo 'dist/build.json unreadable')"
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------- release -----
|
||||
|
||||
write_manifest() {
|
||||
local dir=$1 sha dirty
|
||||
sha=$(git -C "$REPO" rev-parse HEAD 2>/dev/null || echo unknown)
|
||||
dirty=false
|
||||
git -C "$REPO" diff --quiet 2>/dev/null || dirty=true
|
||||
cat >"$dir/release.json" <<JSON
|
||||
{
|
||||
"release": "$(basename "$dir")",
|
||||
"image": "$(image_name)",
|
||||
"git_sha": "$sha",
|
||||
"git_dirty": $dirty,
|
||||
"built_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"host": "$(hostname)",
|
||||
"pages": $(jq -r '.pages // 0' "$REPO/dist/build.json" 2>/dev/null || echo 0),
|
||||
"documents": $(jq -r '.documents // 0' "$REPO/dist/build.json" 2>/dev/null || echo 0)
|
||||
}
|
||||
JSON
|
||||
}
|
||||
|
||||
stage_release() {
|
||||
local name dest sha
|
||||
sha=$(git -C "$REPO" rev-parse HEAD 2>/dev/null || echo unknown)
|
||||
name=$(date -u +%Y%m%dT%H%M%SZ)-${sha:0:7}
|
||||
dest=$RELEASES/$name
|
||||
|
||||
mkdir -p "$dest/bin" "$dest/dist"
|
||||
install -m 0755 "$REPO/bin/resume-server" "$dest/bin/resume-server"
|
||||
cp -a "$REPO/dist/." "$dest/dist/"
|
||||
write_manifest "$dest"
|
||||
printf '%s
|
||||
' "$dest"
|
||||
}
|
||||
|
||||
# Copy an externally built release into releases/ so that every deployable
|
||||
# directory lives under exactly one root and `current` never dangles.
|
||||
adopt_release() {
|
||||
local src dest
|
||||
src=$(cd "$1" && pwd)
|
||||
[ -f "$src/bin/resume-server" ] || die "$src has no bin/resume-server"
|
||||
[ -f "$src/dist/index.html" ] || die "$src has no dist/index.html"
|
||||
dest=$RELEASES/$(date -u +%Y%m%dT%H%M%SZ)-manual
|
||||
mkdir -p "$dest"
|
||||
cp -a "$src/." "$dest/"
|
||||
write_manifest "$dest"
|
||||
printf '%s
|
||||
' "$dest"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- image -----
|
||||
|
||||
build_image() {
|
||||
log "docker build from $1"
|
||||
docker build --file "$DOCKERFILE" --tag "$(image_name)" "$1" 1>&2
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ run -----
|
||||
|
||||
start() {
|
||||
log "docker compose up -d ($1)"
|
||||
compose up -d --no-build --force-recreate 1>&2
|
||||
}
|
||||
|
||||
wait_healthy() {
|
||||
local i body
|
||||
for i in $(seq 1 "$HEALTH_TRIES"); do
|
||||
if body=$(curl -fsS --max-time 3 "http://$PUBLISH_IP:$PORT$HEALTH_PATH" 2>/dev/null); then
|
||||
if printf '%s' "$body" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"ok"'; then
|
||||
log "health gate passed after ${i}s"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
warn "health gate failed after ${HEALTH_TRIES}s; last response:"
|
||||
curl -sS --max-time 3 "http://$PUBLISH_IP:$PORT$HEALTH_PATH" >&2 || true
|
||||
compose logs --tail 40 resume >&2 || true
|
||||
return 1
|
||||
}
|
||||
|
||||
# Definition-of-Done evidence, checked on every deploy instead of by hand:
|
||||
# mem_limit == memswap_limit == the value declared in compose.yml, and the port
|
||||
# published on $PUBLISH_IP only (the LAN address Caddy proxies to — never
|
||||
# 0.0.0.0 and no longer loopback).
|
||||
assert_shape() {
|
||||
local cid mem swap ip declared
|
||||
declared=$(awk '/^[[:space:]]*mem_limit:/ {gsub(/[^0-9]/, "", $2); print $2}' "$COMPOSE")
|
||||
cid=$(compose ps -q resume 2>/dev/null || true)
|
||||
[ -n "$cid" ] || die "no container for service 'resume'"
|
||||
mem=$(docker inspect -f '{{.HostConfig.Memory}}' "$cid")
|
||||
swap=$(docker inspect -f '{{.HostConfig.MemorySwap}}' "$cid")
|
||||
[ "$mem" = "$swap" ] || die "mem_limit ($mem) != memswap_limit ($swap)"
|
||||
[ "$mem" = "$((declared * 1024 * 1024))" ] || die "mem_limit is $mem, expected ${declared} MiB"
|
||||
ip=$(docker inspect -f '{{range $p, $b := .HostConfig.PortBindings}}{{range $b}}{{.HostIp}} {{end}}{{end}}' "$cid" | xargs)
|
||||
[ "$ip" = "$PUBLISH_IP" ] || die "published on '${ip}', expected $PUBLISH_IP only"
|
||||
log "shape ok: mem_limit == memswap_limit == ${declared} MiB, published on $PUBLISH_IP:$PORT"
|
||||
}
|
||||
|
||||
deploy_release() {
|
||||
local dir old name
|
||||
dir=$1
|
||||
name=$(release_name_of "$dir")
|
||||
[ -d "$dir" ] || die "release directory does not exist: $dir"
|
||||
[ -f "$dir/bin/resume-server" ] || die "$dir has no bin/resume-server"
|
||||
[ -f "$dir/dist/index.html" ] || die "$dir has no dist/index.html — a build that cannot serve a landing page is not a release"
|
||||
|
||||
build_image "$dir"
|
||||
old=$(current_target)
|
||||
|
||||
# The flip happens before the container change, so `current` always describes
|
||||
# what is being started. The release it replaced becomes `previous`.
|
||||
flip "releases/$name"
|
||||
if [ -n "$old" ] && [ "$old" != "releases/$name" ]; then
|
||||
ln -sfn "$old" "$PREVIOUS"
|
||||
log "previous -> $old"
|
||||
fi
|
||||
|
||||
if start "$name" && wait_healthy && assert_shape; then
|
||||
printf '%s
|
||||
' "$(date -u +%Y-%m-%dT%H:%M:%SZ) deploy releases/$name ok" >>"$ROOT/deploy.log"
|
||||
log "deployed $name"
|
||||
prune
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -z "$old" ] || [ "$old" = "releases/$name" ]; then
|
||||
warn "first deploy failed and there is no previous release; leaving the container down"
|
||||
compose down >&2 || true
|
||||
rm -f "$CURRENT"
|
||||
return 1
|
||||
fi
|
||||
|
||||
warn "deploy of $name failed; restoring $old"
|
||||
flip "$old"
|
||||
build_image "$ROOT/$old" || die "could not rebuild the previous image; container state unknown"
|
||||
if start "$old" && wait_healthy && assert_shape; then
|
||||
printf '%s
|
||||
' "$(date -u +%Y-%m-%dT%H:%M:%SZ) rolled back to $old after failed deploy of releases/$name" >>"$ROOT/deploy.log"
|
||||
die "deploy of releases/$name failed; rolled back to $old (the failed release is kept for inspection)"
|
||||
fi
|
||||
die "deploy of releases/$name failed AND rollback to $old failed — container state is unknown; run '$0 --status' and read $ROOT/deploy.log"
|
||||
}
|
||||
|
||||
do_rollback() {
|
||||
local cur prev
|
||||
cur=$(current_target)
|
||||
prev=$(previous_target)
|
||||
[ -n "$prev" ] || die "no previous release recorded; nothing to roll back to"
|
||||
[ "$prev" != "$cur" ] || die "previous and current are the same release ($cur); nothing to roll back"
|
||||
[ -d "$ROOT/$prev" ] || die "previous release directory is missing: $ROOT/$prev"
|
||||
|
||||
log "rolling back: $cur -> $prev"
|
||||
build_image "$ROOT/$prev"
|
||||
flip "$prev"
|
||||
if start "$prev" && wait_healthy && assert_shape; then
|
||||
[ -n "$cur" ] && ln -sfn "$cur" "$PREVIOUS"
|
||||
printf '%s
|
||||
' "$(date -u +%Y-%m-%dT%H:%M:%SZ) rollback $cur -> $prev" >>"$ROOT/deploy.log"
|
||||
log "rolled back to $prev; $cur is now previous"
|
||||
return 0
|
||||
fi
|
||||
|
||||
warn "rollback to $prev failed; returning to $cur"
|
||||
flip "$cur"
|
||||
build_image "$ROOT/$cur" || die "could not rebuild $cur; container state unknown"
|
||||
start "$cur" && wait_healthy || die "return to $cur failed; container state unknown"
|
||||
die "rollback to $prev failed; $cur was restored"
|
||||
}
|
||||
|
||||
# Keep the newest KEEP releases plus current and previous, nothing else.
|
||||
prune() {
|
||||
local cur prev name dir
|
||||
cur=$(current_target); prev=$(previous_target)
|
||||
for dir in $(ls -1dt "$RELEASES"/*/ 2>/dev/null | tail -n +"$((KEEP + 1))"); do
|
||||
name="releases/$(basename "$dir")"
|
||||
if [ "$name" = "$cur" ] || [ "$name" = "$prev" ]; then continue; fi
|
||||
rm -rf -- "$dir"
|
||||
log "pruned $name"
|
||||
done
|
||||
}
|
||||
|
||||
do_status() {
|
||||
local cur prev body
|
||||
cur=$(current_target); prev=$(previous_target)
|
||||
body=$(curl -fsS --max-time 3 "http://$PUBLISH_IP:$PORT$HEALTH_PATH" 2>/dev/null || true)
|
||||
printf 'root %s
|
||||
' "$ROOT"
|
||||
printf 'current %s
|
||||
' "${cur:-<none>}"
|
||||
printf 'previous %s
|
||||
' "${prev:-<none>}"
|
||||
if [ -n "$body" ]; then
|
||||
printf 'health %s
|
||||
' "$(printf '%s' "$body" | jq -c '{status, pages: .static.pages, search: .search.ready}' 2>/dev/null || printf '%s' "$body")"
|
||||
else
|
||||
printf 'health unreachable
|
||||
'
|
||||
fi
|
||||
compose ps || true
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ main ----
|
||||
|
||||
case $MODE in
|
||||
status)
|
||||
do_status
|
||||
;;
|
||||
rollback)
|
||||
check_tools
|
||||
do_rollback
|
||||
;;
|
||||
deploy)
|
||||
check_tools
|
||||
check_port
|
||||
memory_report
|
||||
if [ -n "$RELEASE_DIR_OVERRIDE" ]; then
|
||||
deploy_release "$(adopt_release "$RELEASE_DIR_OVERRIDE")"
|
||||
else
|
||||
build_and_test
|
||||
deploy_release "$(stage_release)"
|
||||
fi
|
||||
log "live: $(current_target)"
|
||||
;;
|
||||
esac
|
||||
55
deploy/qdrant.yml
Normal file
55
deploy/qdrant.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
# Qdrant for the corpus (docs/phase-4-corpus.md, reference/stack.md).
|
||||
#
|
||||
# mem_limit == memswap_limit == 400m the cap in the memory budget, and the
|
||||
# container cannot reach host swap
|
||||
# ports on 127.0.0.1 only Caddy on .35 is the only public listener
|
||||
# its own compose project stopping or recreating it cannot disturb
|
||||
# the Mode A site (test A1 still passes:
|
||||
# the site never calls Qdrant)
|
||||
#
|
||||
# Run on the runtime host (.13):
|
||||
#
|
||||
# docker compose -f deploy/qdrant.yml up -d
|
||||
# curl -s http://127.0.0.1:6333/ # must answer with a version
|
||||
#
|
||||
# Then, in the corpus environment (RESUME_QDRANT_URL is already the default):
|
||||
#
|
||||
# python3 -m corpus.cli rebuild
|
||||
#
|
||||
# Deleting the volume is always recoverable: `rebuild` re-derives every vector
|
||||
# from content/.
|
||||
|
||||
name: ai-resume-index
|
||||
|
||||
services:
|
||||
qdrant:
|
||||
image: qdrant/qdrant:v1.20.0
|
||||
container_name: ai-resume-qdrant
|
||||
restart: unless-stopped
|
||||
mem_limit: 400m
|
||||
memswap_limit: 400m
|
||||
ports:
|
||||
- "127.0.0.1:6333:6333" # REST — the pipeline and the query service use this
|
||||
- "127.0.0.1:6334:6334" # gRPC — reserved for the Phase 5 query service
|
||||
volumes:
|
||||
- qdrant_storage:/qdrant/storage
|
||||
environment:
|
||||
# The corpus is ~1k chunks today and the measurement in
|
||||
# reference/stack.md records the ceiling. Keep the HNSW defaults; do not
|
||||
# tune for speed on a memory-bound host.
|
||||
QDRANT__TELEMETRY_DISABLED: "true"
|
||||
QDRANT__LOG_LEVEL: "WARN"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "bash -c '</dev/tcp/127.0.0.1/6333' || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
qdrant_storage:
|
||||
@@ -30,7 +30,7 @@ deploy.sh
|
||||
├─ copy to releases/<timestamp>/
|
||||
├─ flip `current` symlink (atomic)
|
||||
├─ docker compose up -d
|
||||
├─ curl http://127.0.0.1:8096/healthz → must be ready
|
||||
├─ curl http://192.168.20.13:8096/healthz → must be ready
|
||||
└─ on failure: flip back to `previous`, exit non-zero
|
||||
```
|
||||
|
||||
@@ -40,7 +40,12 @@ deploy.sh
|
||||
|
||||
### 3.2 Container
|
||||
- `mem_limit` = `memswap_limit` = `120m`.
|
||||
- `ports: ["127.0.0.1:8096:8096"]` — never `0.0.0.0`.
|
||||
- `ports: ["192.168.20.13:8096:8096"]` — **never** `0.0.0.0`.
|
||||
Caddy runs on `.35`, so the port has to be reachable from the LAN; a
|
||||
`127.0.0.1` publish answers only on `.13` and the public URL returns 502.
|
||||
LAN-only publish, Caddy on `.35` still the sole public listener. Decision:
|
||||
Sam, 2026-10-26, interaction `54aeb1a2`. The health gate and `assert_shape()`
|
||||
in `deploy.sh` follow the same address (`RESUME_PUBLISH_IP`).
|
||||
- `logging` capped (`max-size: 10m`, `max-file: 3`).
|
||||
- `restart: unless-stopped`.
|
||||
|
||||
@@ -75,7 +80,7 @@ Repo stays private through this phase. Sam decides on public visibility after th
|
||||
|
||||
- [ ] `deploy.sh` and `deploy.sh --rollback` both work; a deliberate bad build proves rollback.
|
||||
- [ ] `https://resume.lab.audasmedia.com.au` serves the site.
|
||||
- [ ] Container bound to `127.0.0.1` only; confirmed with `ss -ltn`.
|
||||
- [ ] Container published on `192.168.20.13:8096` only, reachable from `.35`; confirmed with `ss -ltn`.
|
||||
- [ ] `memswap_limit == mem_limit`; confirmed with `docker inspect`.
|
||||
- [ ] Security headers present; CSP does not break the page.
|
||||
- [ ] A1 still passes over the public URL with the AI layer stopped.
|
||||
|
||||
@@ -1,81 +1,157 @@
|
||||
# Phase 4 — Corpus and retrieval
|
||||
# Phase 4 — Ingestion from sources
|
||||
|
||||
**Goal:** `content/` becomes a retrievable corpus with filters that work before the vector search, and a freshness audit that cannot silently rot.
|
||||
**Goal:** a fireable tool that reads the estate — Gitea, the notes vault, the knowledge graph — and keeps a searchable pool current. The pool is not built from `content/` alone; `content/` is the top tier of it.
|
||||
|
||||
**Depends on:** Phase 1 (content), Phase 2 (FTS5 index exists).
|
||||
**Depends on:** Phase 1 (content and schema), Phase 2 (FTS5 index).
|
||||
|
||||
**Why this changed.** An earlier version of this document said the corpus would be built from `content/` only. That was wrong. A hand-curated set cannot answer "how did the photo pipeline deduplicate" or "what did the garden controller do before the rewrite". The estate can. Curated claims answer questions about Sam; the estate answers questions about the work.
|
||||
|
||||
---
|
||||
|
||||
## Work
|
||||
## 1. Sources
|
||||
|
||||
### 4.1 Pipeline
|
||||
| Source | Reached by | What to take |
|
||||
|---|---|---|
|
||||
| **Gitea**, all repositories | API over HTTPS | `README.md`, file tree (paths only), selected source files, recent commit messages, language statistics, repo description and topics |
|
||||
| **`sam/obsidian-vault`** | Same Gitea API — the vault is a repo | Markdown body, front-matter title and tags, folder path |
|
||||
| **Graphify output** | `graph.json` committed to the vault repo | Nodes (label, source_file, community), edges (relation, confidence) |
|
||||
| **`content/`** | Local repo | The curated claims. **Tier 1** — outranks everything above on conflict |
|
||||
|
||||
```
|
||||
content/ ──► validate ──► chunk ──► enrich ──► embed ──► upsert Qdrant
|
||||
│
|
||||
SQLite FTS5 (from Phase 2) ┘
|
||||
Gitea is the only network dependency. The vault and the graph arrive through it, so there is no cross-machine copy and no SSH from `.13` to `.27`.
|
||||
|
||||
### What not to read
|
||||
|
||||
Media never becomes text. Images, video and audio are recorded as `image` node references, tagged with their path, and nothing more.
|
||||
|
||||
---
|
||||
|
||||
## 2. Platform guides
|
||||
|
||||
One file per platform in `platforms/<name>.yml`. They are configuration **and** public copy — the site shows them so a visitor can see what the assistant reads.
|
||||
|
||||
```yaml
|
||||
name: gitea
|
||||
enabled: true
|
||||
what: "Self-hosted Git. Source for every current project."
|
||||
fits: [ai, platform, edge]
|
||||
base: https://gitea.lab.audasmedia.com.au
|
||||
auth: GITEA_TOKEN
|
||||
include:
|
||||
repos: all
|
||||
exclude:
|
||||
paths: ["**/node_modules/**", "**/.venv/**", "**/.git/**", "**/*.env*",
|
||||
"**/.silverbullet.auth.json", "**/*.pem", "**/*.key"]
|
||||
repos: []
|
||||
read: [readme, tree, selected_source, commits, languages]
|
||||
limits: { max_documents: 4000, max_file_kb: 200 }
|
||||
```
|
||||
|
||||
- **Chunking:** 400–800 tokens, ~12% overlap, **code-aware** — never split inside a fenced block, table or front-matter.
|
||||
- **Deterministic IDs:** `sha256(source_path + chunk_index + content_hash)`. Re-running must update, not duplicate.
|
||||
- **Incremental:** hash the source set; only changed documents are re-chunked and re-embedded.
|
||||
- **One command** rebuilds from scratch. Deleting the Qdrant volume is always recoverable.
|
||||
```yaml
|
||||
name: obsidian
|
||||
enabled: true
|
||||
what: "Personal knowledge base. High-level notes on every system, in Sam's words."
|
||||
fits: [about, ai, platform, edge]
|
||||
base: sam/obsidian-vault
|
||||
include:
|
||||
paths: ["200 projects/**", "300 areas/**", "400 Personal Family/**",
|
||||
"100 Table of Contents/**", "010 inbox/**"]
|
||||
exclude:
|
||||
paths: [".obsidian/**", ".trash/**", ".venv/**", "templates/**",
|
||||
"**/*.excalidraw", "**/*.png", "**/*.jpg"]
|
||||
limits: { max_documents: 4000, max_file_kb: 200 }
|
||||
```
|
||||
|
||||
### 4.2 Payload schema (the retrieval filter)
|
||||
```yaml
|
||||
name: graphify
|
||||
enabled: true
|
||||
what: "Knowledge graph over the vault. Answers 'what connects X to Y' rather than 'what mentions X'."
|
||||
fits: [ai]
|
||||
base: sam/obsidian-vault
|
||||
read: [graph_json]
|
||||
```
|
||||
|
||||
`fits` drives the payload `area`, which is what lets the query filter before it searches.
|
||||
|
||||
---
|
||||
|
||||
## 3. Metadata on every chunk
|
||||
|
||||
```json
|
||||
{
|
||||
"platform": "gitea | obsidian | graphify | curated",
|
||||
"tier": 1,
|
||||
"source_type": "doc | code | commit | graph_node | claim",
|
||||
"area": "ai | platform | edge | products | history | about",
|
||||
"level": 1,
|
||||
"source_type": "doc|code|adr|metric",
|
||||
"origin_url": "https://gitea.../sam/photo-pipeline/src/x.py",
|
||||
"path": "src/x.py",
|
||||
"title": "photo-pipeline / dedup",
|
||||
"confidence": "delivered | running | in-progress | abandoned | historic",
|
||||
"period": "2023–",
|
||||
"artefacts": ["..."],
|
||||
"last_verified": "2026-10-20",
|
||||
"last_modified": "2026-10-14T05:00:00Z",
|
||||
"content_hash": "sha256:...",
|
||||
"public": true,
|
||||
"text": "..."
|
||||
}
|
||||
```
|
||||
|
||||
`area` and `confidence` are the two that matter: they let the query path filter *before* searching, and they let the answer qualify unfinished work.
|
||||
`tier: 1` is curated `content/`. Everything harvested is `tier: 2`. The query layer uses this to prefer the curated claim when the two disagree.
|
||||
|
||||
### 4.3 Embeddings
|
||||
- Batch, low volume, incremental.
|
||||
- **Record the embedding model and dimension per collection.** A provider change is a governed migration, not a silent corruption.
|
||||
- Provider decision pending Sam — see `reference/stack.md`. Build provider-agnostic behind one interface.
|
||||
|
||||
### 4.4 Scheduling
|
||||
- Prefect deployment on the existing Prefect server; **worker only**, do not deploy a second server.
|
||||
- Trigger: git push to `content/`, plus a nightly audit.
|
||||
- Nightly audit reports documents past their `last_verified` horizon and alerts via the existing Apprise service.
|
||||
`origin_url` is mandatory for harvested chunks. A chunk with no origin is not admitted — an answer that cannot be traced is worse than no answer.
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
## 4. Pipeline
|
||||
|
||||
- [ ] One command rebuilds the corpus from an empty volume.
|
||||
- [ ] Running it twice produces no duplicate vectors (count is stable).
|
||||
- [ ] No chunk boundary lands inside a code fence, table or front-matter block.
|
||||
- [ ] Every chunk carries all payload fields; a missing field fails the run rather than defaulting.
|
||||
- [ ] Payload filtering demonstrably narrows results — demonstrate the same query with and without an `area` filter.
|
||||
- [ ] Incremental mode re-embeds only changed documents; prove it with the provider call count.
|
||||
- [ ] Freshness audit runs and reports stale documents.
|
||||
- [ ] Qdrant memory measured at 500 / 5,000 / 20,000 chunks and recorded in `reference/stack.md`, within its cap.
|
||||
```
|
||||
guides → harvest (per platform) → normalise to text → chunk → enrich → embed → upsert
|
||||
│
|
||||
└─ reject: excluded path, no origin, binary, oversize
|
||||
```
|
||||
|
||||
- **Chunking** is code-aware: 400–800 tokens, ~12% overlap, never split inside a fenced block, table, front-matter block or function. Use the language grammar where available; fall back to paragraph splitting.
|
||||
- **Deterministic IDs:** `sha256(origin_url + chunk_index + content_hash)`. Re-running updates, never duplicates.
|
||||
- **Incremental:** compare `content_hash` against what the pool already holds; embed only what changed. A full rebuild must always be possible and is one command.
|
||||
- **Graph** is not chunked. Nodes and edges go into their own collection, joined to note chunks by `path`, so a graph answer can name the note it came from.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
## 5. Scheduling
|
||||
|
||||
- Answering questions. Phase 4 ends at "retrieval returns the right chunks".
|
||||
- Query graphs, prompts, guardrails — Phase 5 and 6.
|
||||
- Ingesting anything outside `content/`. Four sources maximum, and this phase uses one: the repo.
|
||||
- On demand: one command.
|
||||
- Scheduled: a Prefect deployment on the existing server. Worker only; do not deploy a second Prefect server.
|
||||
- Reports what changed each run: documents added, updated, removed, and any excluded-path rejections.
|
||||
- A source that has not changed in its expected window raises an alert through the existing Apprise service. Silent staleness is the failure mode that matters.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
## 6. Definition of Done
|
||||
|
||||
- [ ] One command rebuilds the pool from empty.
|
||||
- [ ] A second run produces no duplicate vectors.
|
||||
- [ ] A probe document placed in an excluded path never appears in the pool, and the run reports the rejection.
|
||||
- [ ] A document with no resolvable origin is refused, not defaulted.
|
||||
- [ ] Every chunk carries all fields in section 3.
|
||||
- [ ] Incremental mode re-embeds only changed documents, proven by embedding call count.
|
||||
- [ ] Graph nodes and edges are queryable and each resolves to a source note.
|
||||
- [ ] The scheduled run reports deltas and alerts on staleness.
|
||||
- [ ] Qdrant stays inside its `mem_limit`; footprint recorded at 1k, 10k and 40k chunks.
|
||||
- [ ] No credential, `.env`, key file or family-personal binary is present in the pool — verified by search, not by assertion.
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
- Answering questions. This phase ends at "retrieval returns the right chunks with their origins".
|
||||
- The query graph, prompts and guardrails — Phase 5.
|
||||
- Building the Graphify graph. It already exists and is rebuilt on `.27` with its own skill. This phase consumes its output.
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Corpus grows beyond RAM | Incremental by default; measure at three corpus sizes; cap Qdrant with `mem_limit` |
|
||||
| Duplicate vectors on re-run | Deterministic IDs are mandatory, not optional |
|
||||
| Silent staleness | Nightly audit + Apprise alert; a stale index is otherwise an invisible quality failure |
|
||||
| Provider rate limits during bulk embed | Batch with retry/backoff; ingest is low-volume and incremental |
|
||||
| Volume — 50 repos plus 280 notes exceeds the memory cap | Cap per platform, skip large files, measure at three corpus sizes |
|
||||
| Scraped text contradicts a curated claim | `tier` field; the query prefers tier 1 and says so |
|
||||
| A secret is harvested | Exclusion list plus a post-run scan of the pool for credential patterns, failing the run if found |
|
||||
| Staleness is invisible | Scheduled run plus alert |
|
||||
| The estate is unavailable when a recruiter asks | The pool is local; only ingestion needs Gitea. Serving never does |
|
||||
|
||||
304
docs/phase-4-notes.md
Normal file
304
docs/phase-4-notes.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# Phase 4 notes — corpus and retrieval
|
||||
|
||||
Operator manual for the corpus pipeline, and the evidence for the Phase 4
|
||||
Definition of Done in `docs/phase-4-corpus.md`. Every number below was measured
|
||||
on 2026-10-26 against a live Qdrant 1.20.0 container with a 400 MiB cap.
|
||||
|
||||
---
|
||||
|
||||
## 1. What exists now
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `corpus/` | The pipeline: one Python package, standard library only |
|
||||
| `corpus/cli.py` | `python3 -m corpus.cli {rebuild,sync,audit,verify,demo-filter,hybrid,measure,info}` |
|
||||
| `corpus/chunker.py` | Code-aware chunking (fenced block, table and front-matter are atomic) |
|
||||
| `corpus/embeddings.py` | The only place a vector is made; two providers behind one interface |
|
||||
| `corpus/qdrant.py` | Qdrant REST client (no `qdrant-client` dependency) |
|
||||
| `corpus/store.py` | SQLite: FTS5 over chunks, the incremental manifest, the collection contract |
|
||||
| `corpus/pipeline.py` | validate → chunk → enrich → embed → upsert, in that order |
|
||||
| `corpus/freshness.py` | Nightly audit: stale documents, index drift, contract drift |
|
||||
| `corpus/measure.py` | Memory measurement at 500 / 5,000 / 20,000 chunks |
|
||||
| `corpus/tests/` | 76 unit tests, no container, no network |
|
||||
| `flows/corpus.py` | Prefect flows: `corpus-sync`, `corpus-rebuild`, `corpus-audit` |
|
||||
| `deploy/qdrant.yml` | Qdrant container: `mem_limit` = `memswap_limit` = 400 m, loopback only |
|
||||
|
||||
Two indexes are written from one pass over `content/`, so they cannot drift:
|
||||
|
||||
* **vectors** in Qdrant (`resume_corpus`), one point per chunk;
|
||||
* **keywords** in SQLite (`data/corpus/corpus.db`), one FTS5 row per chunk.
|
||||
|
||||
Retrieval uses both (`corpus.cli hybrid`, reciprocal rank fusion). Vectors miss
|
||||
exact tokens such as repo names, `LoRaWAN`, `MQTT` and dates; FTS5 alone misses
|
||||
paraphrase. Decision D10 requires the pair.
|
||||
|
||||
---
|
||||
|
||||
## 2. Commands
|
||||
|
||||
```sh
|
||||
make corpus # rebuild from content/ (drops the collection first)
|
||||
make corpus-sync # incremental: only changed documents are re-embedded
|
||||
make corpus-audit # freshness + drift audit; exit 1 on findings
|
||||
make corpus-verify # index vs content/ vs Qdrant, consistency report
|
||||
make corpus-demo # the same query with and without an area filter
|
||||
make corpus-test # 76 unit tests
|
||||
```
|
||||
|
||||
Direct form, when you need flags:
|
||||
|
||||
```sh
|
||||
python3 -m corpus.cli rebuild --qdrant-url http://127.0.0.1:6333 --dim 1024
|
||||
python3 -m corpus.cli audit --today 2027-01-20 --json --fail-on-findings
|
||||
python3 -m corpus.cli measure --sizes 500,5000,20000 --container ai-resume-qdrant
|
||||
```
|
||||
|
||||
Configuration is environment-first. Nothing is read from a file in this repo.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `RESUME_QDRANT_URL` | `http://127.0.0.1:6333` | Qdrant REST endpoint |
|
||||
| `RESUME_COLLECTION` | `resume_corpus` | collection name |
|
||||
| `RESUME_EMBED_PROVIDER` | `local-hashing` | `local-hashing` or `omniroute` |
|
||||
| `RESUME_EMBED_MODEL` | provider default | recorded per collection |
|
||||
| `RESUME_EMBED_DIM` | provider default | recorded per collection |
|
||||
| `RESUME_CORPUS_DB` | `data/corpus/corpus.db` | FTS5 + manifest |
|
||||
| `RESUME_MAX_AGE_DAYS` | `180` | freshness horizon |
|
||||
| `RESUME_WARN_DAYS` | `30` | how early "due soon" starts |
|
||||
| `RESUME_APPRISE_URL` | unset | existing Apprise endpoint for audit alerts |
|
||||
| `OMNIROUTE_API_KEY` | unset | only for the `omniroute` provider; never in the repo |
|
||||
|
||||
---
|
||||
|
||||
## 3. Pipeline
|
||||
|
||||
```
|
||||
content/ ─► make validate ─► chunk ─► enrich ─► embed ─► upsert Qdrant
|
||||
│
|
||||
└─► SQLite FTS5 + manifest
|
||||
```
|
||||
|
||||
* **Validate first.** The pipeline runs the Phase 1 validator
|
||||
(`tools/validate_content.py`). A broken content tree stops the run before a
|
||||
single vector is written.
|
||||
* **Chunking.** Target 600 tokens, hard ceiling 800, ~12 % overlap. The body is
|
||||
parsed into atomic blocks (fenced code, table, heading, list item, quote,
|
||||
paragraph, rule) and chunks are packed from whole blocks, so a boundary
|
||||
cannot fall inside a fence, a table or front-matter. A list item or a table
|
||||
that exceeds 800 tokens becomes one oversize chunk and is flagged, never cut.
|
||||
Tokens are counted as words plus punctuation, which is a conservative
|
||||
under-estimate of BPE tokens.
|
||||
* **Payload before vectors.** Every chunk payload is built and validated for
|
||||
every changed document *before* one point is written. A missing or invalid
|
||||
field aborts the run with the document and chunk named.
|
||||
* **Deterministic ids.** `sha256(source_path + chunk_index + content_hash)`,
|
||||
rendered as a UUID because Qdrant accepts only integers or UUIDs. Re-running
|
||||
updates points; a document whose chunk count shrinks has its old points
|
||||
deleted by `source_path` filter, so orphans cannot accumulate.
|
||||
* **Incremental.** One hash comparison against the manifest per document. The
|
||||
document is re-embedded when its bytes changed, or when the embedding model,
|
||||
dimension or chunker version changed.
|
||||
* **Consistency check.** At the end of every run the point count in Qdrant must
|
||||
equal the chunk count in SQLite, or the run fails.
|
||||
|
||||
Corpus today: **40 documents → 51 chunks**, token range 100–707 (median 225),
|
||||
8 protected regions (fenced blocks and tables), 0 boundary violations.
|
||||
|
||||
---
|
||||
|
||||
## 4. Payload
|
||||
|
||||
`docs/phase-4-corpus.md` §4.2 fields, plus provenance so an answer can cite:
|
||||
|
||||
```json
|
||||
{
|
||||
"area": "edge", "level": 3, "source_type": "doc", "confidence": "running",
|
||||
"period": "2020–", "artefacts": [], "last_verified": "2026-10-26",
|
||||
"text": "# Artefact — Distributed audio …",
|
||||
"id": "014d792f-28d9-e0a0-610e-7ffc04eea3ad",
|
||||
"source_path": "content/edge/l3/edge-snapcast.md", "source_id": "edge-snapcast",
|
||||
"chunk_index": 0, "title": "…", "claim": "…", "url": "/edge/a/edge-snapcast/",
|
||||
"content_hash": "12fc4fa9…", "embedding_model": "local-hashing-v1",
|
||||
"embedding_dim": 1024, "chunker_version": "blocks-v1", "chunk_tokens": 191,
|
||||
"public": true, "tech": ["Snapcast", "Mopidy", "…"], "tags": ["edge", "audio"]
|
||||
}
|
||||
```
|
||||
|
||||
`area` and `confidence` are the two that matter. The query path filters on them
|
||||
*before* it searches, and an answer can qualify unfinished work instead of
|
||||
presenting it as delivered.
|
||||
|
||||
---
|
||||
|
||||
## 5. Freshness audit
|
||||
|
||||
```sh
|
||||
make corpus-audit # human table, exit 1 on findings
|
||||
python3 -m corpus.cli audit --json # machine-readable, for Prefect
|
||||
```
|
||||
|
||||
Findings, in order of seriousness:
|
||||
|
||||
| Finding | Meaning |
|
||||
|---|---|
|
||||
| `stale` | `last_verified` older than `RESUME_MAX_AGE_DAYS` (180). The claim may no longer be true |
|
||||
| `due_soon` | inside the warning window (30 days by default). A warning, not a failure |
|
||||
| `index_drift` | the source changed, disappeared or was never indexed since it was embedded |
|
||||
| `contract_drift` | the collection was built with a different model, dimension or chunker |
|
||||
|
||||
`ok` is false when there is a `stale`, `index_drift` or `contract_drift`
|
||||
finding. `due_soon` alone keeps the audit green.
|
||||
|
||||
`--alert` posts the report to `RESUME_APPRISE_URL` when the audit is not ok.
|
||||
The Prefect flow does this by default.
|
||||
|
||||
---
|
||||
|
||||
## 6. Scheduling (Prefect, existing server, worker only)
|
||||
|
||||
`flows/corpus.py` defines three flows. It imports Prefect when present and runs
|
||||
as plain Python when it is not, so the CLI never depends on the orchestrator.
|
||||
|
||||
On `.13`, which already runs the Prefect server — **do not start a second one**:
|
||||
|
||||
```sh
|
||||
# once, per deployment
|
||||
export RESUME_QDRANT_URL=http://127.0.0.1:6333
|
||||
export RESUME_EMBED_PROVIDER=omniroute RESUME_EMBED_MODEL=<approved model> RESUME_EMBED_DIM=<dim>
|
||||
prefect deploy flows/corpus.py:sync_corpus --name corpus-sync
|
||||
prefect deploy flows/corpus.py:freshness_audit --name corpus-audit --cron "0 4 * * *"
|
||||
```
|
||||
|
||||
Triggers required by `docs/phase-4-corpus.md` §4.4:
|
||||
|
||||
* **on push touching `content/`** — a deployment trigger on the repository, or
|
||||
`make corpus-sync` from the same post-receive hook that runs `deploy.sh`;
|
||||
* **nightly audit** — cron `0 4 * * *`, which alerts through Apprise.
|
||||
|
||||
This deployment is **not applied yet**: it needs the runtime host and the
|
||||
provider decision in §8. Both are Sam's. The flows themselves are ready.
|
||||
|
||||
---
|
||||
|
||||
## 7. Qdrant memory at three corpus sizes
|
||||
|
||||
Measured with `python3 -m corpus.cli measure`, Qdrant 1.20.0 in a container
|
||||
capped at 400 MiB (`mem_limit` = `memswap_limit`), 1024-dimensional vectors,
|
||||
real payloads from `content/`, throttled ingest in batches of 250.
|
||||
|
||||
| Chunks | Points | Settled RSS | Peak during ingest | Growth | Bytes/chunk | Ingest time |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 0 | 0 | 88.6 MiB | — | — | — | — |
|
||||
| 500 | 500 | 128.3 MiB | 148.5 MiB | +39.7 MiB | 83 KiB | 1.1 s |
|
||||
| 5,000 | 5,000 | 137.7 MiB | 305.0 MiB | +49.1 MiB | 10 KiB | 10.9 s |
|
||||
| 20,000 | 20,000 | 150.9 MiB | 292.3 MiB | +62.3 MiB | 3 KiB | 43.7 s |
|
||||
|
||||
All three sizes are **within the 400 MiB cap**. The peak is the number that
|
||||
matters: the cgroup limit is tripped during a bulk write, not after it.
|
||||
|
||||
### The finding that changed the design
|
||||
|
||||
With Qdrant's **default** settings the container was OOM-killed at exactly the
|
||||
cap between 5,000 and 20,000 chunks (`anon-rss: 404204kB`, `exitCode 137`,
|
||||
`constraint=CONSTRAINT_MEMCG`). The default keeps the HNSW graph and the payload
|
||||
in RAM and holds several segments live during ingest.
|
||||
|
||||
The collection is now created memory-frugal, and the pipeline is the only code
|
||||
that creates it (`corpus/qdrant.py`):
|
||||
|
||||
```json
|
||||
{
|
||||
"vectors": {"size": 1024, "distance": "Cosine", "on_disk": false},
|
||||
"hnsw_config": {"m": 8, "ef_construct": 64, "on_disk": true},
|
||||
"on_disk_payload": true,
|
||||
"optimizers_config": {"default_segment_number": 2, "memmap_threshold": 5000, "indexing_threshold": 20000}
|
||||
}
|
||||
```
|
||||
|
||||
Vectors stay in RAM because search reads them on every query. The HNSW graph and
|
||||
the payloads go to disk, where the kernel can reclaim those pages instead of
|
||||
killing the process. `memmap_threshold` 5,000 makes segments that size mmap;
|
||||
`indexing_threshold` 20,000 stops a bulk ingest from building the graph while it
|
||||
is still writing. The cost is build time and a little search latency, both
|
||||
irrelevant for a corpus of this size and a public site with no query burst.
|
||||
|
||||
Practical ceiling: at 1024 dimensions the cap holds 20,000 chunks. If the corpus
|
||||
grows past that, set `RESUME_QDRANT_VECTORS_ON_DISK=1`, or reduce the dimension,
|
||||
or raise the cap — in that order of preference. Vector memory scales linearly
|
||||
with dimension: 1,536-dimension vectors need 50 % more than recorded here.
|
||||
|
||||
---
|
||||
|
||||
## 8. Embedding provider — still Sam's decision
|
||||
|
||||
`reference/stack.md` lists the open item: Gemini free tier, OpenRouter, or
|
||||
local. Phase 4 does not wait for it:
|
||||
|
||||
* **`omniroute`** is implemented and tested. OpenAI-compatible
|
||||
`/v1/embeddings`, batched, retry with backoff on 429 and 5xx, key from the
|
||||
environment only, no provider SDK, no direct vendor call (AGENTS.md §4.5).
|
||||
* **`local-hashing`** is the default for tests, for offline rebuilds and for the
|
||||
memory measurement. It is deterministic feature hashing, **not a semantic
|
||||
model**: it is a placeholder that lets the whole pipeline run without a
|
||||
provider decision, and it must not be used for answers.
|
||||
|
||||
The provider, model, dimension and chunker version are stored in the collection
|
||||
contract (`data/corpus/collection.json` and the `meta` table). A run whose
|
||||
configuration differs is refused:
|
||||
|
||||
```
|
||||
the collection contract does not match this configuration — refusing to mix
|
||||
vectors in one collection (docs/phase-4-corpus.md §4.3).
|
||||
```
|
||||
|
||||
### Changing the provider (governed migration)
|
||||
|
||||
1. Get Sam's decision and record it in `reference/decisions.md`.
|
||||
2. Set `RESUME_EMBED_PROVIDER`, `RESUME_EMBED_MODEL`, `RESUME_EMBED_DIM`, and
|
||||
`OMNIROUTE_API_KEY` in `~/.config/environment.d/`.
|
||||
3. `make corpus` — a rebuild, because a provider change invalidates every
|
||||
vector. It is one command and takes seconds at this corpus size.
|
||||
4. `make corpus-verify` and `make corpus-audit`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recovery
|
||||
|
||||
Deleting the Qdrant volume or `data/corpus/` is **always recoverable**:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/qdrant.yml down -v
|
||||
docker compose -f deploy/qdrant.yml up -d
|
||||
make corpus # re-derives every vector and FTS row from content/
|
||||
make corpus-verify
|
||||
```
|
||||
|
||||
There is no state that exists only in the index. `content/` is the source of
|
||||
truth; the corpus is a derived artefact and lives in `data/`, which is
|
||||
gitignored.
|
||||
|
||||
---
|
||||
|
||||
## 10. Definition of Done — evidence
|
||||
|
||||
| DoD item | Evidence |
|
||||
|---|---|
|
||||
| One command rebuilds from an empty volume | `make corpus` on an empty Qdrant volume: 40 documents → 51 chunks, 51 points, 1.0 s |
|
||||
| Running it twice produces no duplicate vectors | rebuild ×2 → 51 points both times; `sync` with no changes → 0 embedding calls, 0 points sent |
|
||||
| No chunk boundary inside a fence, table or front-matter | `corpus/tests/test_chunker.py`; 51/51 stored chunks reproduce from `content/`, 8 protected regions, 0 violations |
|
||||
| Every chunk carries all payload fields; missing field fails the run | `corpus/tests/test_payloads.py` (each field removed in turn); 51/51 stored payloads validate; a broken payload aborts before any write (`test_invalid_payload_aborts_before_any_write`) |
|
||||
| Payload filtering demonstrably narrows results | `make corpus-demo` — query "distributed audio endpoints": unfiltered returns edge *and* ai documents; `--area edge` returns edge only, `--area ai` returns ai only |
|
||||
| Incremental re-embeds only changed documents | edit one of 40 documents → `updated=1 unchanged=39 embedding calls=1 texts=1`; re-run → `calls=0` |
|
||||
| Freshness audit runs and reports stale documents | `python3 -m corpus.cli audit` today: 0 findings. `--today 2027-10-05`: 40 stale documents, one row per document with age in days. `--today 2026-11-01`: 0 findings |
|
||||
| Qdrant memory at 500 / 5,000 / 20,000 chunks, within cap | §7 table; 128 / 138 / 151 MiB settled, 149 / 305 / 292 MiB peak, cap 400 MiB |
|
||||
|
||||
---
|
||||
|
||||
## 11. Not done here
|
||||
|
||||
* Qdrant is **not deployed to `.13`** and the Prefect deployment is not applied.
|
||||
`deploy/qdrant.yml` is ready; the runtime host is Phase 3's (`AUD-143`) and
|
||||
Sam's to apply.
|
||||
* No provider is chosen (§8).
|
||||
* Answering questions, the query graph, the streaming console and guardrails are
|
||||
Phase 5 and Phase 6. Phase 4 ends at "retrieval returns the right chunks".
|
||||
@@ -80,3 +80,43 @@ One hand-written TypeScript island, ES modules, no framework, no bundler.
|
||||
| Panel refactor needed | Phase 2 §2.4 contract; if it fails, fix the panel contract, do not fork the UI |
|
||||
| Streaming stalls behind Caddy | Explicit flush settings; p90 measurement, not a spot check |
|
||||
| Answers drift from the site | Citation verification is a node, not a hope |
|
||||
|
||||
---
|
||||
|
||||
## 5.6 Citations point at the original
|
||||
|
||||
An answer cites where it came from, not a curated page standing in for it.
|
||||
|
||||
| Source | Cited as |
|
||||
|---|---|
|
||||
| Gitea repo file | full URL to the file in Gitea |
|
||||
| Vault note | full URL to the note in `sam/obsidian-vault` |
|
||||
| Graph answer | the note title the node came from, plus its URL |
|
||||
| Curated claim | the site page |
|
||||
|
||||
A citation whose URL cannot be opened is dropped, not rendered. A claim with no
|
||||
traceable origin is not returned at all.
|
||||
|
||||
## 5.7 Curated outranks harvested
|
||||
|
||||
`content/` is tier 1; everything harvested is tier 2. When the two disagree, the
|
||||
tier 1 claim wins and the answer says so plainly: the curated description is
|
||||
current; the older text is background.
|
||||
|
||||
This is the rule that stops a stale README from quietly overwriting Sam's own
|
||||
description of his work.
|
||||
|
||||
## 5.8 The live source readout
|
||||
|
||||
While the answer streams, a panel shows what is being read, named as the visitor
|
||||
would recognise it:
|
||||
|
||||
```
|
||||
reading · gitea/photo-pipeline/README.md
|
||||
· obsidian/210 AI Resume/Graphify.md
|
||||
· graph · community 42 · vault infrastructure
|
||||
```
|
||||
|
||||
This is a feature, not a debug view. It answers the question every recruiter has
|
||||
and does not ask: is this making things up? Show the working — it is also the most
|
||||
convincing demonstration of the system on the page.
|
||||
|
||||
71
docs/phase-7-notes.md
Normal file
71
docs/phase-7-notes.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Phase 7 — notes
|
||||
|
||||
Running record for the visual layer (AUD-149). Each batch states what changed,
|
||||
what it measures, and what it still needs.
|
||||
|
||||
---
|
||||
|
||||
## Batch 1 — marks, area icons, client wall, two diagrams
|
||||
|
||||
### What is in the tree
|
||||
|
||||
| Piece | Where | Note |
|
||||
|---|---|---|
|
||||
| Technology marks | `internal/brand/` | 50 Simple Icons paths (CC0), inlined as 16 px `currentColor` SVG. No request, no font, no bundler. |
|
||||
| Regenerator | `tools/gen_brand_icons.sh` | Development-time only. The generated Go file is committed, so `make build` never needs the network. |
|
||||
| Area icons | `internal/web/templates/icons.templ` | Six marks drawn for this site, plus one for the evidence index. 24×24, 1.6 px stroke, `aria-hidden`. |
|
||||
| Client wall | `content/history/l3/history-client-wall.md` + `.client-wall` in `site.css` | 45 names, one typeface, one size, one grid of equal tiles. No client mark is used, because none has been approved. |
|
||||
| Diagrams | `content/assets/diagrams/` | Homelab topology and RAG pipeline. Both referenced from the page they explain. |
|
||||
| Image sizing | `internal/content/markdown.go` | Every content image gets intrinsic `width`/`height`, `loading="lazy"`, `decoding="async"`. |
|
||||
| Guard | `internal/site/site_test.go` | `TestImagesReserveTheirSpace` fails if an image ships without size or alt text. |
|
||||
| Topbar | `site.css` | Two rows at desktop. One row overlapped the search form once the marks were added; Lighthouse `target-size` caught it. |
|
||||
| Favicon | `internal/web/static/favicon.svg` | Stops the browser asking for a missing `/favicon.ico` (it was the only console error). |
|
||||
|
||||
### Adding a technology mark
|
||||
|
||||
1. Add the label → slug pair to `slugs` in `internal/brand/brand.go`.
|
||||
2. Add the slug to `slugs()` in `tools/gen_brand_icons.sh` and run it.
|
||||
3. `make dist`, then check the chip on the page.
|
||||
|
||||
A technology with no published mark renders as text alone, on purpose: a chip
|
||||
with a missing image would read as a broken page.
|
||||
|
||||
### Adding an image
|
||||
|
||||
- File goes under `content/assets/` (published at `/assets/...`).
|
||||
- SVG needs explicit `width` and `height` on the root element; PNG and JPEG are
|
||||
measured with `image.DecodeConfig`. Both give the page its reserved space.
|
||||
- Alt text describes the thing, not the file name.
|
||||
- The diagrams carry their own print style, so they print as ink on white.
|
||||
|
||||
### Measurements (2026-10-28)
|
||||
|
||||
Lighthouse 12.8.2, headless Chrome, against the local server on `127.0.0.1`.
|
||||
|
||||
| Page | Mode | Performance | Accessibility | Best practices | SEO | CLS |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Landing | mobile | 100 | 100 | 100 | 100 | 0 |
|
||||
| Landing | desktop | 100 | 100 | 100 | 100 | 0 |
|
||||
| Platform network (diagram) | mobile | 100 | 100 | 100 | 100 | 0 |
|
||||
| AI retrieval (diagram) | desktop | 100 | 100 | 100 | 100 | 0 |
|
||||
| Client wall | mobile | 100 | 100 | 100 | 100 | — |
|
||||
| Edge audio artefact | mobile | 100 | 100 | 100 | 100 | — |
|
||||
|
||||
Total page weight: landing 153 KiB, diagram page 105 KiB. Diagram files are 5.7
|
||||
KiB and 6.6 KiB; the stylesheet is 24 KiB; htmx is 51 KiB and is the largest
|
||||
single asset.
|
||||
|
||||
Two defects were found by measurement rather than by eye and fixed in this
|
||||
batch: the desktop topbar overlap, and the missing favicon.
|
||||
|
||||
### Still open — needs Sam
|
||||
|
||||
| DoD line | State | What is needed |
|
||||
|---|---|---|
|
||||
| At least one real photograph in use | Not started | Which photographs may be used (Immich on `.35` is the likely source). Anything used is checked for people, address and screens in shot. |
|
||||
| Two screenshots in use | Not started | Approval to capture Home Assistant, Gitea, TUIOS with agent panes, or Graphify's `graph.html`. Screens must contain nothing personal. |
|
||||
| One recording, 30–60 s | Not started | Either Sam records it, or Sam approves a recording of the agent console. |
|
||||
| Client wall marks | Deliberately absent | Official marks only, with Sam's approval. Nothing invented. |
|
||||
|
||||
Nothing above is blocking the site: it ships, measures 100 across the board, and
|
||||
every page works with the AI services stopped.
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1792773339,
|
||||
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
27
flake.nix
Normal file
27
flake.nix
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
description = "Ai Resume — build environment (Go + templ + SQLite). Node, Vite and bundlers are deliberately absent.";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAll = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
in {
|
||||
devShells = forAll (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.go
|
||||
pkgs.templ
|
||||
pkgs.sqlite # the sqlite3 CLI, for inspecting the FTS5 index
|
||||
pkgs.gnumake
|
||||
pkgs.curl
|
||||
];
|
||||
shellHook = ''
|
||||
echo "ai-resume dev shell — go $(go version | cut -d' ' -f3), templ $(templ version)"
|
||||
echo "targets: make generate build test dev"
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
89
flows/corpus.py
Normal file
89
flows/corpus.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Prefect entry points for the corpus pipeline.
|
||||
|
||||
`docs/phase-4-corpus.md` §4.4: *"Prefect deployment on the existing Prefect
|
||||
server; worker only, do not deploy a second server. Trigger: git push to
|
||||
content/, plus a nightly audit."*
|
||||
|
||||
Prefect is optional at runtime. If it is not installed, these functions are
|
||||
plain callables and the same code runs straight from the CLI
|
||||
(`python3 -m corpus.cli sync`), which is what `make corpus` does. Nothing about
|
||||
the pipeline depends on the orchestrator, and the orchestrator depends on
|
||||
nothing but the pipeline.
|
||||
|
||||
The deployment commands (run on `.13`, which already has the Prefect server) are
|
||||
in `docs/phase-4-notes.md` §6. This file is only the work definition.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
try: # pragma: no cover - exercised only where Prefect is installed
|
||||
from prefect import flow as _flow
|
||||
from prefect import get_run_logger as _get_run_logger
|
||||
|
||||
PREFECT_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover
|
||||
PREFECT_AVAILABLE = False
|
||||
|
||||
def _flow(*_args, **_kwargs):
|
||||
def decorator(func):
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def _get_run_logger():
|
||||
class _Logger:
|
||||
def info(self, message, *args, **kwargs):
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
warning = error = info
|
||||
|
||||
return _Logger()
|
||||
|
||||
|
||||
from corpus import freshness, pipeline # noqa: E402 (after the Prefect shim)
|
||||
from corpus.config import Config # noqa: E402
|
||||
|
||||
|
||||
@_flow(name="corpus-sync", log_prints=True)
|
||||
def sync_corpus() -> dict:
|
||||
"""Incremental refresh. Runs on every push that touches content/."""
|
||||
logger = _get_run_logger()
|
||||
cfg = Config.from_env()
|
||||
stats = pipeline.run(cfg, mode="sync", log=logger.info)
|
||||
return stats.to_dict()
|
||||
|
||||
|
||||
@_flow(name="corpus-rebuild", log_prints=True)
|
||||
def rebuild_corpus() -> dict:
|
||||
"""From-scratch rebuild. The recovery path: deleting the volume is always safe."""
|
||||
logger = _get_run_logger()
|
||||
cfg = Config.from_env()
|
||||
stats = pipeline.run(cfg, mode="rebuild", log=logger.info)
|
||||
return stats.to_dict()
|
||||
|
||||
|
||||
@_flow(name="corpus-audit", log_prints=True)
|
||||
def freshness_audit(alert: bool = True) -> dict:
|
||||
"""Nightly audit. Reports stale documents and index drift, then alerts."""
|
||||
logger = _get_run_logger()
|
||||
cfg = Config.from_env()
|
||||
report = freshness.audit(cfg, log=logger.info)
|
||||
logger.info(freshness.render_text(report))
|
||||
if alert and not report["ok"]:
|
||||
try:
|
||||
freshness.alert(report, cfg.apprise_url)
|
||||
except Exception as exc: # noqa: BLE001 - the alert must not fail the audit
|
||||
logger.warning(f"Apprise alert failed: {exc}")
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - convenience for a manual run
|
||||
command = sys.argv[1] if len(sys.argv) > 1 else "sync"
|
||||
if command == "audit":
|
||||
print(freshness.render_text(freshness_audit(alert=False)))
|
||||
elif command == "rebuild":
|
||||
print(rebuild_corpus())
|
||||
else:
|
||||
print(sync_corpus())
|
||||
25
go.mod
Normal file
25
go.mod
Normal file
@@ -0,0 +1,25 @@
|
||||
module gitea.lab.audasmedia.com.au/sam/ai-resume
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.1020
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/yuin/goldmark v1.8.6
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/sqlite v1.34.5 // indirect
|
||||
)
|
||||
46
go.sum
Normal file
46
go.sum
Normal file
@@ -0,0 +1,46 @@
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
90
internal/brand/brand.go
Normal file
90
internal/brand/brand.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Package brand holds the small monochrome technology marks the site inlines
|
||||
// beside a technology name.
|
||||
//
|
||||
// A mark is path data for a 24x24 viewBox, drawn with fill="currentColor" so it
|
||||
// inherits the chip's colour and adds no request, no font and no layout shift.
|
||||
// Marks are opt-in: a technology with no published mark renders as text alone,
|
||||
// which is the honest default and keeps the chips from looking broken.
|
||||
//
|
||||
// The path data is vendored (see icons_gen.go and tools/gen_brand_icons.sh),
|
||||
// not fetched at build or request time. The marks are trademarks of their
|
||||
// owners and are used only to identify the technology named beside them.
|
||||
package brand
|
||||
|
||||
import "strings"
|
||||
|
||||
// slugs maps a technology label, lowercased, to its Simple Icons slug. Labels
|
||||
// are the ones the content uses; aliases point at the same mark.
|
||||
var slugs = map[string]string{
|
||||
"javascript": "javascript",
|
||||
"php": "php",
|
||||
"python": "python",
|
||||
"home assistant": "homeassistant",
|
||||
"raspberry pi": "raspberrypi",
|
||||
"pi": "raspberrypi",
|
||||
"nixos": "nixos",
|
||||
"nix": "nixos",
|
||||
"qdrant": "qdrant",
|
||||
"prefect": "prefect",
|
||||
"n8n": "n8n",
|
||||
"mqtt": "mqtt",
|
||||
"langgraph": "langgraph",
|
||||
"langchain": "langchain",
|
||||
"ffmpeg": "ffmpeg",
|
||||
"zigbee": "zigbee",
|
||||
"three.js": "threedotjs",
|
||||
"react": "react",
|
||||
"proxmox": "proxmox",
|
||||
"mysql": "mysql",
|
||||
"postgres": "postgresql",
|
||||
"postgresql": "postgresql",
|
||||
"caddy": "caddy",
|
||||
"tailscale": "tailscale",
|
||||
"neovim": "neovim",
|
||||
"htmx": "htmx",
|
||||
"go": "go",
|
||||
"docker": "docker",
|
||||
"compose": "docker",
|
||||
"borg": "borgbackup",
|
||||
"sqlite": "sqlite",
|
||||
"spotify": "spotify",
|
||||
"rabbitmq": "rabbitmq",
|
||||
"openrouter": "openrouter",
|
||||
"obsidian": "obsidian",
|
||||
"niri": "niri",
|
||||
"gitea": "gitea",
|
||||
"ghostty": "ghostty",
|
||||
"gemini": "googlegemini",
|
||||
"deepseek": "deepseek",
|
||||
"c++": "cplusplus",
|
||||
"typescript": "typescript",
|
||||
"zsh": "zsh",
|
||||
"minimax": "minimax",
|
||||
"esp32": "espressif",
|
||||
"esp32-cam": "espressif",
|
||||
"grafana": "grafana",
|
||||
"wireguard": "wireguard",
|
||||
"nginx": "nginx",
|
||||
"linux": "linux",
|
||||
"zig": "zig",
|
||||
"huggingface": "huggingface",
|
||||
"ollama": "ollama",
|
||||
}
|
||||
|
||||
// Mark returns the path data for a technology label and whether one exists.
|
||||
// The lookup is case-insensitive and tolerates the surrounding whitespace that
|
||||
// hand-written front-matter tends to carry.
|
||||
func Mark(label string) ([]string, bool) {
|
||||
slug, ok := slugs[strings.ToLower(strings.TrimSpace(label))]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
paths, ok := marks[slug]
|
||||
return paths, ok
|
||||
}
|
||||
|
||||
// Has reports whether a technology label has a published mark.
|
||||
func Has(label string) bool {
|
||||
_, ok := Mark(label)
|
||||
return ok
|
||||
}
|
||||
160
internal/brand/icons_gen.go
Normal file
160
internal/brand/icons_gen.go
Normal file
File diff suppressed because one or more lines are too long
455
internal/content/content.go
Normal file
455
internal/content/content.go
Normal file
@@ -0,0 +1,455 @@
|
||||
// Package content loads the Markdown source of truth in content/ into documents
|
||||
// that both renderers (Mode A static pages, Mode B corpus) read.
|
||||
//
|
||||
// The schema is frozen; see docs/content-schema.md. This package does not
|
||||
// re-implement the validator (tools/validate_content.py is the gate). It does
|
||||
// enforce the invariants the renderer needs, and it fails loudly rather than
|
||||
// rendering a broken page.
|
||||
package content
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Confidence values. These are displayed on the page and are never promoted.
|
||||
const (
|
||||
ConfidenceDelivered = "delivered"
|
||||
ConfidenceRunning = "running"
|
||||
ConfidenceInProgress = "in-progress"
|
||||
ConfidenceAbandoned = "abandoned"
|
||||
ConfidenceHistoric = "historic"
|
||||
)
|
||||
|
||||
// Area is one of the six top-level navigation areas. Structure lives in code
|
||||
// (it comes from reference/content-map.md, not from the content tree); the
|
||||
// documents that fill it live in content/.
|
||||
type Area struct {
|
||||
Key string
|
||||
Title string
|
||||
Role string
|
||||
Blurb string
|
||||
}
|
||||
|
||||
// Areas is the fixed navigation order from reference/content-map.md.
|
||||
var Areas = []Area{
|
||||
{Key: "ai", Title: "AI & Agentic Systems", Role: "AI / LLM Engineer",
|
||||
Blurb: "Harnesses, subagents and skills, memory, model routing and cost, retrieval, voice, guardrails, agent observability."},
|
||||
{Key: "platform", Title: "Platform & Infrastructure", Role: "Platform / DevOps Engineer",
|
||||
Blurb: "Declarative hosts, packaging, a fifty-container Docker estate, networking, storage and restore-tested backups, observability."},
|
||||
{Key: "edge", Title: "Edge & Devices", Role: "Edge / IoT Engineer",
|
||||
Blurb: "Twenty-node fleet on constrained devices, custom radio, solar power, deep sleep, sensors and actuation — and the failures."},
|
||||
{Key: "products", Title: "Products", Role: "",
|
||||
Blurb: "Three active businesses, built and run rather than advised on."},
|
||||
{Key: "history", Title: "History", Role: "",
|
||||
Blurb: "2000–2014: agencies, clients, engagements, and the code that survives from them."},
|
||||
{Key: "about", Title: "About", Role: "",
|
||||
Blurb: "The narrative, the non-IT work that came first, education, and how to get in touch."},
|
||||
}
|
||||
|
||||
// Positioning is the site's single line of introduction.
|
||||
const (
|
||||
Positioning = "Twenty-five years shipping software. Now building agent systems, the platforms under them, and the hardware at the edge."
|
||||
SupportLine = "…and it all runs unattended."
|
||||
Location = "Melbourne"
|
||||
)
|
||||
|
||||
// AreaByKey returns the area with the given key.
|
||||
func AreaByKey(key string) (Area, bool) {
|
||||
for _, a := range Areas {
|
||||
if a.Key == key {
|
||||
return a, true
|
||||
}
|
||||
}
|
||||
return Area{}, false
|
||||
}
|
||||
|
||||
// Metric is one approved chip on a page, e.g. {label: "edge nodes", value: 20}.
|
||||
type Metric struct {
|
||||
Label string `yaml:"label"`
|
||||
Value any `yaml:"value"`
|
||||
}
|
||||
|
||||
// String renders the chip value with its label: "20 edge nodes".
|
||||
func (m Metric) String() string {
|
||||
return fmt.Sprintf("%v %s", m.Value, m.Label)
|
||||
}
|
||||
|
||||
// Artefact is a drill-down link. Every URL must resolve to a file in this
|
||||
// repository; nothing external is allowed.
|
||||
type Artefact struct {
|
||||
Type string `yaml:"type"` // repo | image | video | doc
|
||||
Label string `yaml:"label"`
|
||||
URL string `yaml:"url"`
|
||||
}
|
||||
|
||||
// Document is one Markdown file plus its front-matter.
|
||||
type Document struct {
|
||||
ID string `yaml:"id"`
|
||||
Area string `yaml:"area"`
|
||||
Level int `yaml:"level"`
|
||||
Title string `yaml:"title"`
|
||||
Claim string `yaml:"claim"`
|
||||
Confidence string `yaml:"confidence"`
|
||||
Period string `yaml:"period"`
|
||||
Tech []string `yaml:"tech"`
|
||||
Metrics []Metric `yaml:"metrics"`
|
||||
Artefacts []Artefact `yaml:"artefacts"`
|
||||
Public bool `yaml:"public"`
|
||||
LastVerified string `yaml:"last_verified"`
|
||||
Tags []string `yaml:"tags"`
|
||||
|
||||
// Derived by Load.
|
||||
Path string `yaml:"-"` // path relative to the content root, e.g. edge/edge-fleet.md
|
||||
URL string `yaml:"-"` // canonical page URL, e.g. /edge/edge-fleet/
|
||||
HTML string `yaml:"-"` // rendered body
|
||||
Plain string `yaml:"-"` // body reduced to text, for the search index
|
||||
|
||||
bodyRaw string // Markdown body, rendered by the markdown renderer
|
||||
|
||||
// Backlinks are the documents that reference this one as an artefact.
|
||||
Backlinks []*Document `yaml:"-"`
|
||||
}
|
||||
|
||||
// ResolvedArtefact is an Artefact with its target resolved.
|
||||
type ResolvedArtefact struct {
|
||||
Artefact
|
||||
Target *Document // non-nil when the artefact is an L3/L2 document
|
||||
File string // absolute path on disk
|
||||
Rel string // path relative to the content root, e.g. assets/x.svg
|
||||
Href string // site URL: a page URL for documents, /assets/... for files
|
||||
Kind string // Fragment kind: text | code | diagram | image | video | links
|
||||
}
|
||||
|
||||
// Set is every loaded document, indexed.
|
||||
type Set struct {
|
||||
Root string
|
||||
Docs []*Document
|
||||
byID map[string]*Document
|
||||
byRel map[string]*Document
|
||||
}
|
||||
|
||||
// ByID returns the document with the given id.
|
||||
func (s *Set) ByID(id string) (*Document, bool) {
|
||||
d, ok := s.byID[id]
|
||||
return d, ok
|
||||
}
|
||||
|
||||
// ByRel returns the document at the given content-relative path.
|
||||
func (s *Set) ByRel(rel string) (*Document, bool) {
|
||||
d, ok := s.byRel[rel]
|
||||
return d, ok
|
||||
}
|
||||
|
||||
// InArea returns the public documents for an area, ordered L1, L2, L3.
|
||||
func (s *Set) InArea(key string) []*Document {
|
||||
var out []*Document
|
||||
for _, d := range s.Docs {
|
||||
if d.Area == key {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Level != out[j].Level {
|
||||
return out[i].Level < out[j].Level
|
||||
}
|
||||
return out[i].Title < out[j].Title
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// AreaLanding returns the L1 document for an area.
|
||||
func (s *Set) AreaLanding(key string) (*Document, bool) {
|
||||
for _, d := range s.InArea(key) {
|
||||
if d.Level == 1 {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Load reads every Markdown file under root. Documents with public: false are
|
||||
// loaded (so links to them still resolve) but are not published.
|
||||
func Load(root string) (*Set, error) {
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("content root %q: %w", root, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("content root %q is not a directory", root)
|
||||
}
|
||||
|
||||
s := &Set{Root: root, byID: map[string]*Document{}, byRel: map[string]*Document{}}
|
||||
|
||||
err = filepath.WalkDir(root, func(p string, e os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
if p != root && (strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_")) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(name, ".md") || strings.HasPrefix(name, ".") {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, p)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
|
||||
raw, rerr := os.ReadFile(p)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
doc, derr := parse(rel, p, raw)
|
||||
if derr != nil {
|
||||
return derr
|
||||
}
|
||||
if prev, dup := s.byID[doc.ID]; dup {
|
||||
return fmt.Errorf("%s: duplicate id %q (already used by %s)", rel, doc.ID, prev.Path)
|
||||
}
|
||||
doc.URL = PageURL(doc)
|
||||
s.Docs = append(s.Docs, doc)
|
||||
s.byID[doc.ID] = doc
|
||||
s.byRel[rel] = doc
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(s.Docs) == 0 {
|
||||
return nil, fmt.Errorf("content root %q contains no documents", root)
|
||||
}
|
||||
sort.SliceStable(s.Docs, func(i, j int) bool {
|
||||
if s.Docs[i].Area != s.Docs[j].Area {
|
||||
return areaOrder(s.Docs[i].Area) < areaOrder(s.Docs[j].Area)
|
||||
}
|
||||
if s.Docs[i].Level != s.Docs[j].Level {
|
||||
return s.Docs[i].Level < s.Docs[j].Level
|
||||
}
|
||||
return s.Docs[i].ID < s.Docs[j].ID
|
||||
})
|
||||
|
||||
for _, d := range s.Docs {
|
||||
for _, a := range d.Artefacts {
|
||||
ra, rerr := s.Resolve(d, a)
|
||||
if rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
if ra.Target != nil && ra.Target.ID != d.ID {
|
||||
ra.Target.Backlinks = append(ra.Target.Backlinks, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func areaOrder(key string) int {
|
||||
for i, a := range Areas {
|
||||
if a.Key == key {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(Areas)
|
||||
}
|
||||
|
||||
func parse(rel, abs string, raw []byte) (*Document, error) {
|
||||
fm, body, err := splitFrontMatter(string(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", rel, err)
|
||||
}
|
||||
var d Document
|
||||
dec := yaml.NewDecoder(strings.NewReader(fm))
|
||||
dec.KnownFields(true) // unknown front-matter fields are a schema violation
|
||||
if err := dec.Decode(&d); err != nil {
|
||||
return nil, fmt.Errorf("%s: front-matter: %w", rel, err)
|
||||
}
|
||||
d.Path = rel
|
||||
d.bodyRaw = body
|
||||
|
||||
if err := d.check(rel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// BodyMarkdown returns the raw Markdown body (everything after the front-matter).
|
||||
func (d *Document) BodyMarkdown() string { return d.bodyRaw }
|
||||
|
||||
func (d *Document) check(rel string) error {
|
||||
switch {
|
||||
case d.ID == "":
|
||||
return fmt.Errorf("%s: id is required", rel)
|
||||
case d.Title == "":
|
||||
return fmt.Errorf("%s: title is required", rel)
|
||||
case d.Claim == "":
|
||||
return fmt.Errorf("%s: claim is required", rel)
|
||||
}
|
||||
if _, ok := AreaByKey(d.Area); !ok {
|
||||
return fmt.Errorf("%s: unknown area %q", rel, d.Area)
|
||||
}
|
||||
if d.Level < 1 || d.Level > 3 {
|
||||
return fmt.Errorf("%s: level must be 1, 2 or 3, got %d", rel, d.Level)
|
||||
}
|
||||
switch d.Confidence {
|
||||
case ConfidenceDelivered, ConfidenceRunning, ConfidenceInProgress, ConfidenceAbandoned, ConfidenceHistoric:
|
||||
default:
|
||||
return fmt.Errorf("%s: unknown confidence %q", rel, d.Confidence)
|
||||
}
|
||||
if d.LastVerified == "" {
|
||||
return fmt.Errorf("%s: last_verified is required", rel)
|
||||
}
|
||||
stem := strings.TrimSuffix(path.Base(rel), ".md")
|
||||
if stem != d.ID {
|
||||
return fmt.Errorf("%s: id %q must equal the file name %q", rel, d.ID, stem)
|
||||
}
|
||||
for _, a := range d.Artefacts {
|
||||
if a.URL == "" || a.Label == "" {
|
||||
return fmt.Errorf("%s: artefact %q needs both label and url", rel, a.Label)
|
||||
}
|
||||
if strings.Contains(a.URL, "://") {
|
||||
return fmt.Errorf("%s: artefact %q uses an external URL; create an in-repo stub instead", rel, a.URL)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitFrontMatter(src string) (fm, body string, err error) {
|
||||
src = strings.ReplaceAll(src, "\r\n", "\n")
|
||||
if !strings.HasPrefix(src, "---\n") {
|
||||
return "", "", fmt.Errorf("missing front-matter block on line 1")
|
||||
}
|
||||
rest := src[len("---\n"):]
|
||||
for _, delim := range []string{"\n---\n", "\n...\n"} {
|
||||
if i := strings.Index(rest, delim); i >= 0 {
|
||||
return rest[:i], rest[i+len(delim):], nil
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("unterminated front-matter block")
|
||||
}
|
||||
|
||||
// PageURL is the canonical URL for a document. Depth maps onto the URL path so
|
||||
// the drill-down is legible: /edge/ · /edge/edge-fleet/ · /edge/a/edge-zigbee/.
|
||||
func PageURL(d *Document) string {
|
||||
switch d.Level {
|
||||
case 1:
|
||||
return "/" + d.Area + "/"
|
||||
case 2:
|
||||
return "/" + d.Area + "/" + d.ID + "/"
|
||||
default:
|
||||
return "/" + d.Area + "/a/" + d.ID + "/"
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve maps an artefact reference onto a file, a site URL and a stage
|
||||
// fragment kind. Paths may be repository-relative ("content/edge/l3/x.md") or
|
||||
// document-relative ("./l3/x.md", "l3/x.md").
|
||||
func (s *Set) Resolve(from *Document, a Artefact) (ResolvedArtefact, error) {
|
||||
rel := a.URL
|
||||
var target string
|
||||
switch {
|
||||
case strings.HasPrefix(rel, "content/"):
|
||||
target = strings.TrimPrefix(rel, "content/")
|
||||
case strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, "../"):
|
||||
return ResolvedArtefact{}, fmt.Errorf("%s: artefact %q must not be root-absolute or escape the repo", from.Path, rel)
|
||||
default:
|
||||
target = path.Join(path.Dir(from.Path), rel)
|
||||
}
|
||||
target = path.Clean(target)
|
||||
|
||||
out := ResolvedArtefact{Artefact: a, Rel: target}
|
||||
abs := filepath.Join(s.Root, filepath.FromSlash(target))
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("%s: artefact %q does not resolve to a file (%s)", from.Path, a.URL, target)
|
||||
}
|
||||
if st.IsDir() {
|
||||
return out, fmt.Errorf("%s: artefact %q resolves to a directory", from.Path, a.URL)
|
||||
}
|
||||
out.File = abs
|
||||
if strings.HasSuffix(target, ".md") {
|
||||
doc, ok := s.byRel[target]
|
||||
if !ok {
|
||||
return out, fmt.Errorf("%s: artefact %q is not a loaded document", from.Path, a.URL)
|
||||
}
|
||||
out.Target = doc
|
||||
out.Href = doc.URL
|
||||
out.Kind = stage.KindText
|
||||
return out, nil
|
||||
}
|
||||
out.Href = "/assets/" + strings.TrimPrefix(target, "assets/")
|
||||
out.Kind = KindForFile(target)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var codeExtensions = map[string]string{
|
||||
".go": "go", ".js": "javascript", ".mjs": "javascript", ".ts": "typescript",
|
||||
".nix": "nix", ".php": "php", ".as": "actionscript", ".hpp": "cpp", ".h": "cpp",
|
||||
".c": "c", ".cpp": "cpp", ".py": "python", ".sh": "bash", ".yaml": "yaml",
|
||||
".yml": "yaml", ".json": "json", ".css": "css", ".sql": "sql", ".templ": "templ",
|
||||
}
|
||||
|
||||
// KindForFile maps a file extension onto a stage fragment kind.
|
||||
func KindForFile(name string) string {
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
if lang, ok := codeExtensions[ext]; ok && lang != "" {
|
||||
return stage.KindCode
|
||||
}
|
||||
switch ext {
|
||||
case ".svg":
|
||||
return stage.KindDiagram
|
||||
case ".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif":
|
||||
return stage.KindImage
|
||||
case ".mp4", ".webm", ".mov":
|
||||
return stage.KindVideo
|
||||
case ".md":
|
||||
return stage.KindText
|
||||
}
|
||||
return stage.KindLinks
|
||||
}
|
||||
|
||||
// LanguageFor returns the chroma language for a file, if it is code.
|
||||
func LanguageFor(name string) string {
|
||||
return codeExtensions[strings.ToLower(path.Ext(name))]
|
||||
}
|
||||
|
||||
// Truncate shortens s to at most n runes, on a word boundary where possible.
|
||||
func Truncate(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len([]rune(s)) <= n {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)[:n]
|
||||
if i := strings.LastIndex(string(r), " "); i > n/2 {
|
||||
return strings.TrimSpace(string(r)[:i]) + "…"
|
||||
}
|
||||
return strings.TrimSpace(string(r)) + "…"
|
||||
}
|
||||
|
||||
// MetricValue renders a metric value for display.
|
||||
func MetricValue(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
default:
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
251
internal/content/markdown.go
Normal file
251
internal/content/markdown.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
stdhtml "html"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||
"github.com/alecthomas/chroma/v2/lexers"
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
"github.com/yuin/goldmark"
|
||||
highlighting "github.com/yuin/goldmark-highlighting/v2"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
||||
)
|
||||
|
||||
// RendererStyle is the chroma style. Dark by default, matching the rest of
|
||||
// Sam's surfaces.
|
||||
const RendererStyle = "github-dark"
|
||||
|
||||
// Renderer turns Markdown into server-side HTML. Nothing here runs in a
|
||||
// browser: goldmark for structure and tables, chroma for code, at build time.
|
||||
type Renderer struct {
|
||||
md goldmark.Markdown
|
||||
set *Set
|
||||
}
|
||||
|
||||
// NewRenderer builds a renderer bound to a document set (needed to rewrite
|
||||
// relative .md links into site URLs).
|
||||
func NewRenderer(s *Set) *Renderer {
|
||||
r := &Renderer{set: s}
|
||||
r.md = goldmark.New(
|
||||
goldmark.WithExtensions(
|
||||
extension.GFM,
|
||||
extension.Footnote,
|
||||
extension.Typographer,
|
||||
highlighting.NewHighlighting(
|
||||
highlighting.WithStyle(RendererStyle),
|
||||
highlighting.WithFormatOptions(chromahtml.WithClasses(true)),
|
||||
),
|
||||
),
|
||||
goldmark.WithParserOptions(
|
||||
parser.WithAutoHeadingID(),
|
||||
parser.WithASTTransformers(util.Prioritized(r, 100)),
|
||||
),
|
||||
goldmark.WithRendererOptions(html.WithUnsafe()), // trusted, in-repo Markdown
|
||||
)
|
||||
return r
|
||||
}
|
||||
|
||||
// Transform rewrites document-relative links into site URLs so the
|
||||
// pre-rendered pages work as static files. The current document is carried in
|
||||
// the parser context by Render and Highlight-free callers.
|
||||
func (r *Renderer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
|
||||
current, ok := pc.Get(docKey).(*Document)
|
||||
if !ok || current == nil {
|
||||
return
|
||||
}
|
||||
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
switch node := n.(type) {
|
||||
case *ast.Link:
|
||||
node.Destination = r.rewrite(current, string(node.Destination), false)
|
||||
case *ast.Image:
|
||||
node.Destination = r.rewrite(current, string(node.Destination), true)
|
||||
r.sizeImage(current, node)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Renderer) rewrite(from *Document, dest string, image bool) []byte {
|
||||
if dest == "" || strings.Contains(dest, "://") || strings.HasPrefix(dest, "/") ||
|
||||
strings.HasPrefix(dest, "#") || strings.HasPrefix(dest, "mailto:") {
|
||||
return []byte(dest)
|
||||
}
|
||||
target := dest
|
||||
suffix := ""
|
||||
if i := strings.IndexAny(target, "#?"); i >= 0 {
|
||||
suffix = target[i:]
|
||||
target = target[:i]
|
||||
}
|
||||
rel := target
|
||||
if strings.HasPrefix(rel, "content/") {
|
||||
rel = strings.TrimPrefix(rel, "content/")
|
||||
} else {
|
||||
rel = path.Join(path.Dir(from.Path), rel)
|
||||
}
|
||||
rel = path.Clean(rel)
|
||||
if d, ok := r.set.byRel[rel]; ok {
|
||||
return []byte(d.URL + suffix)
|
||||
}
|
||||
if image || KindForFile(rel) != stage.KindLinks {
|
||||
return []byte(AssetHref(rel) + suffix)
|
||||
}
|
||||
return []byte(dest)
|
||||
}
|
||||
|
||||
// sizeImage records an image's intrinsic size and loading hints on the node, so
|
||||
// the rendered page reserves the space before the file arrives. Without this the
|
||||
// text jumps as each image lands, which is the most visible performance defect a
|
||||
// page of diagrams can have.
|
||||
//
|
||||
// Only the four attributes in goldmark's image filter are used, and only when a
|
||||
// real size is known: an unknown asset keeps the browser's own behaviour.
|
||||
func (r *Renderer) sizeImage(from *Document, node *ast.Image) {
|
||||
if r.set == nil || from == nil {
|
||||
return
|
||||
}
|
||||
dest := string(node.Destination)
|
||||
if strings.Contains(dest, "://") || strings.HasPrefix(dest, "data:") {
|
||||
return
|
||||
}
|
||||
rel := dest
|
||||
if strings.HasPrefix(rel, "/assets/") {
|
||||
rel = "assets/" + strings.TrimPrefix(rel, "/assets/")
|
||||
} else if i := strings.IndexAny(rel, "#?"); i >= 0 {
|
||||
rel = rel[:i]
|
||||
}
|
||||
w, h, ok := imageSize(filepath.Join(r.set.Root, filepath.FromSlash(path.Clean(rel))))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
node.SetAttributeString("width", []byte(strconv.Itoa(w)))
|
||||
node.SetAttributeString("height", []byte(strconv.Itoa(h)))
|
||||
node.SetAttributeString("loading", []byte("lazy"))
|
||||
node.SetAttributeString("decoding", []byte("async"))
|
||||
}
|
||||
|
||||
// svgSize reads width and height from the root element of an SVG. The diagrams
|
||||
// in content/assets are authored in this repository with explicit dimensions,
|
||||
// so a viewBox alone is not expected to occur.
|
||||
var svgSizeRe = regexp.MustCompile(`<svg[^>]*\swidth="(\d+(?:\.\d+)?)(?:px)?"[^>]*\sheight="(\d+(?:\.\d+)?)(?:px)?"`)
|
||||
|
||||
// imageSize returns the intrinsic pixel size of a content asset.
|
||||
func imageSize(abs string) (int, int, bool) {
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
defer f.Close()
|
||||
if strings.HasSuffix(strings.ToLower(abs), ".svg") {
|
||||
head := make([]byte, 4096)
|
||||
n, _ := f.Read(head)
|
||||
m := svgSizeRe.FindSubmatch(head[:n])
|
||||
if m == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
w, err1 := strconv.ParseFloat(string(m[1]), 64)
|
||||
h, err2 := strconv.ParseFloat(string(m[2]), 64)
|
||||
if err1 != nil || err2 != nil || w <= 0 || h <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return int(w + 0.5), int(h + 0.5), true
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(f)
|
||||
if err != nil || cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return cfg.Width, cfg.Height, true
|
||||
}
|
||||
|
||||
// docKey carries the document being rendered through the parser context so
|
||||
// the link rewriter knows what "relative" means.
|
||||
var docKey = parser.NewContextKey()
|
||||
|
||||
// Render fills HTML and Plain on every document in the set.
|
||||
func (r *Renderer) Render() error {
|
||||
for _, d := range r.set.Docs {
|
||||
var buf bytes.Buffer
|
||||
ctx := parser.NewContext()
|
||||
ctx.Set(docKey, d)
|
||||
if err := r.md.Convert([]byte(d.BodyMarkdown()), &buf, parser.WithContext(ctx)); err != nil {
|
||||
return fmt.Errorf("%s: render Markdown: %w", d.Path, err)
|
||||
}
|
||||
d.HTML = buf.String()
|
||||
d.Plain = PlainText(d.HTML)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Highlight renders a standalone code file with chroma. Used by L3 code
|
||||
// artefacts and by the make-target that keeps the code exhibits in sync.
|
||||
func (r *Renderer) Highlight(w io.Writer, lang string, src []byte) error {
|
||||
lexer := lexers.Get(lang)
|
||||
if lexer == nil {
|
||||
lexer = lexers.Analyse(string(src))
|
||||
}
|
||||
if lexer == nil {
|
||||
_, err := fmt.Fprintf(w, "<pre class=\"chroma\"><code>%s</code></pre>", stdhtml.EscapeString(string(src)))
|
||||
return err
|
||||
}
|
||||
lexer = chroma.Coalesce(lexer)
|
||||
it, err := lexer.Tokenise(nil, string(src))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
style := styles.Get(RendererStyle)
|
||||
if style == nil {
|
||||
return fmt.Errorf("unknown chroma style %q", RendererStyle)
|
||||
}
|
||||
formatter := chromahtml.New(chromahtml.WithClasses(true), chromahtml.WithLineNumbers(false))
|
||||
return formatter.Format(w, style, it)
|
||||
}
|
||||
|
||||
// ChromaCSS returns the stylesheet for the highlighted classes, so the built
|
||||
// site makes no colour decisions inline.
|
||||
func (r *Renderer) ChromaCSS() (string, error) {
|
||||
style := styles.Get(RendererStyle)
|
||||
if style == nil {
|
||||
return "", fmt.Errorf("unknown chroma style %q", RendererStyle)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := chromahtml.New(chromahtml.WithClasses(true)).WriteCSS(&buf, style); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// AssetHref maps a content-relative asset path onto its published URL.
|
||||
func AssetHref(rel string) string {
|
||||
return "/assets/" + strings.TrimPrefix(path.Clean(rel), "assets/")
|
||||
}
|
||||
|
||||
var tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
|
||||
// PlainText reduces rendered HTML to indexable text.
|
||||
func PlainText(h string) string {
|
||||
h = tagRe.ReplaceAllString(h, " ")
|
||||
h = stdhtml.UnescapeString(h)
|
||||
return strings.Join(strings.Fields(h), " ")
|
||||
}
|
||||
187
internal/search/search.go
Normal file
187
internal/search/search.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// Package search builds the SQLite FTS5 index over the static pages.
|
||||
//
|
||||
// The index is written by the build step and opened read-only by the server, so
|
||||
// a search never touches the content tree and works with every AI service
|
||||
// stopped. The same index is the keyword half of Phase 4's hybrid retrieval:
|
||||
// its schema is documented in docs/phase-2-notes.md and must not change shape
|
||||
// without both phases agreeing.
|
||||
package search
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Doc is one indexable page.
|
||||
type Doc struct {
|
||||
ID string
|
||||
URL string
|
||||
Title string
|
||||
Area string
|
||||
Level int
|
||||
Confidence string
|
||||
Body string
|
||||
}
|
||||
|
||||
// Result is one hit.
|
||||
type Result struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Area string `json:"area"`
|
||||
Level int `json:"level"`
|
||||
Confidence string `json:"confidence"`
|
||||
Snippet string `json:"snippet"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// Schema is the FTS5 table. Columns marked UNINDEXED are still selectable.
|
||||
const schema = `
|
||||
CREATE VIRTUAL TABLE pages USING fts5(
|
||||
id UNINDEXED,
|
||||
url UNINDEXED,
|
||||
title,
|
||||
area UNINDEXED,
|
||||
level UNINDEXED,
|
||||
confidence UNINDEXED,
|
||||
body,
|
||||
tokenize = 'porter unicode61 remove_diacritics 2'
|
||||
);`
|
||||
|
||||
// Build writes a fresh index at path. Any existing file is replaced, so the
|
||||
// index always describes exactly one build.
|
||||
func Build(path string, docs []Doc) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open index %s: %w", path, err)
|
||||
}
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if _, err := db.Exec(`PRAGMA journal_mode = OFF; PRAGMA synchronous = OFF;`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return fmt.Errorf("create fts5 table: %w", err)
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO pages (id, url, title, area, level, confidence, body) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, d := range docs {
|
||||
if _, err := stmt.Exec(d.ID, d.URL, d.Title, d.Area, d.Level, d.Confidence, d.Body); err != nil {
|
||||
return fmt.Errorf("index %s: %w", d.ID, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO pages (pages) VALUES ('optimize')`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Index is a read-only handle on a built index.
|
||||
type Index struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
// Open opens a built index read-only.
|
||||
func Open(path string) (*Index, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, fmt.Errorf("search index %s: %w", path, err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Index{db: db, path: path}, nil
|
||||
}
|
||||
|
||||
// Close releases the handle.
|
||||
func (i *Index) Close() error { return i.db.Close() }
|
||||
|
||||
// Count returns the number of indexed pages.
|
||||
func (i *Index) Count() (int, error) {
|
||||
var n int
|
||||
err := i.db.QueryRow(`SELECT count(*) FROM pages`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Query runs a keyword search. Every term is matched as a prefix so "zigbee"
|
||||
// finds "Zigbee" and "routing" finds "routers".
|
||||
func (i *Index) Query(q string, limit int) ([]Result, error) {
|
||||
match := MatchExpression(q)
|
||||
if match == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := i.db.Query(`
|
||||
SELECT url, title, area, CAST(level AS INTEGER), confidence,
|
||||
snippet(pages, 6, '<mark>', '</mark>', '…', 18),
|
||||
bm25(pages)
|
||||
FROM pages
|
||||
WHERE pages MATCH ?
|
||||
ORDER BY bm25(pages)
|
||||
LIMIT ?`, match, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search %q: %w", q, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Result
|
||||
for rows.Next() {
|
||||
var r Result
|
||||
if err := rows.Scan(&r.URL, &r.Title, &r.Area, &r.Level, &r.Confidence, &r.Snippet, &r.Score); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MatchExpression turns free text into a safe FTS5 query: bare words only, each
|
||||
// as a prefix, all required. FTS5 operators in the input are never honoured, so
|
||||
// a visitor cannot shape the query beyond words.
|
||||
func MatchExpression(q string) string {
|
||||
terms := strings.FieldsFunc(strings.ToLower(q), func(r rune) bool {
|
||||
return !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-')
|
||||
})
|
||||
if len(terms) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := make([]string, 0, len(terms))
|
||||
for _, t := range terms {
|
||||
t = strings.Trim(t, "-")
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, `"`+t+`"*`)
|
||||
}
|
||||
return strings.Join(out, " AND ")
|
||||
}
|
||||
89
internal/search/search_test.go
Normal file
89
internal/search/search_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMatchExpressionIsWordsOnly is the security-relevant half of search: FTS5
|
||||
// syntax in visitor input is never honoured, so a query cannot shape the index
|
||||
// beyond words. It also means "zigbee)" is not an error page.
|
||||
func TestMatchExpressionIsWordsOnly(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"zigbee", `"zigbee"*`},
|
||||
{"Zigbee Routers", `"zigbee"* AND "routers"*`},
|
||||
{`zigbee" OR body:`, `"zigbee"* AND "or"* AND "body"*`},
|
||||
{`NEAR(a b)`, `"near"* AND "a"* AND "b"*`},
|
||||
{` `, ``},
|
||||
{`!!!`, ``},
|
||||
{"snapcast-audio", `"snapcast-audio"*`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := MatchExpression(tc.in); got != tc.want {
|
||||
t.Errorf("MatchExpression(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAndQuery is the §2.5 DoD check: the FTS5 index returns sensible
|
||||
// results for the three terms the phase document names.
|
||||
func TestBuildAndQuery(t *testing.T) {
|
||||
indexFile := filepath.Join(t.TempDir(), "search.db")
|
||||
docs := []Doc{
|
||||
{ID: "edge", URL: "/edge/", Title: "Edge & Devices", Area: "edge", Level: 1, Confidence: "running",
|
||||
Body: "Twenty nodes on constrained hardware. Zigbee routers and a LoRa link."},
|
||||
{ID: "platform", URL: "/platform/", Title: "Platform & Infrastructure", Area: "platform", Level: 1, Confidence: "running",
|
||||
Body: "Declarative hosts, networking and storage."},
|
||||
{ID: "products", URL: "/products/", Title: "Products", Area: "products", Level: 1, Confidence: "delivered",
|
||||
Body: "Commercial signage on commodity hardware."},
|
||||
{ID: "ai", URL: "/ai/", Title: "AI & Agentic Systems", Area: "ai", Level: 1, Confidence: "running",
|
||||
Body: "Model routing and cost control through a single gateway."},
|
||||
}
|
||||
if err := Build(indexFile, docs); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
idx, err := Open(indexFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer idx.Close()
|
||||
|
||||
if n, err := idx.Count(); err != nil || n != len(docs) {
|
||||
t.Fatalf("Count = %d, %v; want %d", n, err, len(docs))
|
||||
}
|
||||
|
||||
cases := []struct{ q, wantURL string }{
|
||||
{"zigbee", "/edge/"},
|
||||
{"signage", "/products/"},
|
||||
{"routing", "/ai/"},
|
||||
{"ZIGBEE", "/edge/"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
results, err := idx.Query(tc.q, 10)
|
||||
if err != nil {
|
||||
t.Errorf("Query(%q): %v", tc.q, err)
|
||||
continue
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Errorf("Query(%q) found nothing", tc.q)
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, r := range results {
|
||||
if r.URL == tc.wantURL {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Query(%q) did not return %s; got %+v", tc.q, tc.wantURL, results)
|
||||
}
|
||||
if !strings.Contains(results[0].Snippet, "<mark>") {
|
||||
t.Errorf("Query(%q) snippet has no highlight: %q", tc.q, results[0].Snippet)
|
||||
}
|
||||
}
|
||||
|
||||
if results, err := idx.Query("!!!", 10); err != nil || len(results) != 0 {
|
||||
t.Errorf("Query(no words) = %v, %v; want no results and no error", results, err)
|
||||
}
|
||||
}
|
||||
605
internal/server/server.go
Normal file
605
internal/server/server.go
Normal file
@@ -0,0 +1,605 @@
|
||||
// Package server serves the pre-rendered Mode A site out of a build directory.
|
||||
//
|
||||
// Everything a visitor reads is a file the build wrote: the server renders a
|
||||
// page only for a keyword search that carries a query, and for the streamed
|
||||
// panel demonstration. Nothing here calls a model, opens a socket to another
|
||||
// service or reads the content tree, so stopping Qdrant, OmniRoute or any query
|
||||
// service cannot change what a visitor sees (acceptance test A1).
|
||||
//
|
||||
// The two pieces that are not files are the two things the static build cannot
|
||||
// pre-render:
|
||||
//
|
||||
// GET /search?q=… keyword search over the FTS5 index
|
||||
// GET /api/stage/stream the stage panel bound to an event stream
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
|
||||
)
|
||||
|
||||
// Default configuration. The address is loopback on purpose: Caddy is the only
|
||||
// public listener (AGENTS.md §5).
|
||||
const (
|
||||
DefaultAddr = "127.0.0.1:8096"
|
||||
DefaultDist = "dist"
|
||||
DefaultStreamDelay = 350 * time.Millisecond
|
||||
)
|
||||
|
||||
// Cache policies. Pages are rebuilt on deploy and must not be stale across a
|
||||
// release, so HTML is revalidated. Unhashed assets get an hour at the origin;
|
||||
// Phase 3's Caddy block sets the long immutable cache for the public edge.
|
||||
const (
|
||||
pageCache = "no-cache"
|
||||
assetCache = "public, max-age=3600"
|
||||
fragCache = "public, max-age=300"
|
||||
)
|
||||
|
||||
// Config is what the server needs to start. Every field has a usable default so
|
||||
// that the same binary runs under systemd, compose and `make dev`.
|
||||
type Config struct {
|
||||
// Addr is the listen address. Default 127.0.0.1:8096.
|
||||
Addr string
|
||||
// DistDir is a directory written by cmd/build. Default "dist".
|
||||
DistDir string
|
||||
// StreamDelay paces the streamed panel demonstration. Default 350ms.
|
||||
StreamDelay time.Duration
|
||||
// Logger receives one line per request. Default: JSON to stdout.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
func (c *Config) defaults() {
|
||||
if c.Addr == "" {
|
||||
c.Addr = DefaultAddr
|
||||
}
|
||||
if c.DistDir == "" {
|
||||
c.DistDir = DefaultDist
|
||||
}
|
||||
if c.StreamDelay <= 0 {
|
||||
c.StreamDelay = DefaultStreamDelay
|
||||
}
|
||||
if c.Logger == nil {
|
||||
c.Logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
}
|
||||
}
|
||||
|
||||
// Server serves one build directory.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
dist string
|
||||
shell site.Shell
|
||||
log *slog.Logger
|
||||
|
||||
// built is nil when the directory has no readable build.json; the site is
|
||||
// still served, but /healthz reports static as not ready.
|
||||
built *site.BuildInfo
|
||||
builtErr error
|
||||
|
||||
// manifest is nil when the build has no stage fragments. Only the streamed
|
||||
// demonstration needs it.
|
||||
manifest *stage.Manifest
|
||||
manifestErr error
|
||||
|
||||
// index is nil when the FTS5 index could not be opened. Search then says so
|
||||
// and the rest of the site is unaffected.
|
||||
index *search.Index
|
||||
indexErr error
|
||||
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
// New opens a build directory. It does not fail when the directory is
|
||||
// incomplete: it starts, serves whatever is there and lets /healthz report why
|
||||
// it is not ready, which is what a deploy script needs to see.
|
||||
func New(cfg Config) (*Server, error) {
|
||||
cfg.defaults()
|
||||
abs, err := filepath.Abs(cfg.DistDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.DistDir = abs
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build directory %s: %w", abs, err)
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return nil, fmt.Errorf("build directory %s is not a directory", abs)
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
dist: abs,
|
||||
shell: site.NewShell(),
|
||||
log: cfg.Logger,
|
||||
}
|
||||
|
||||
s.built, s.builtErr = site.LoadBuildInfo(abs)
|
||||
if s.built == nil && s.builtErr == nil {
|
||||
s.builtErr = errors.New("build.json is missing")
|
||||
}
|
||||
if s.builtErr != nil {
|
||||
s.log.Warn("build metadata unavailable", "dist", abs, "error", s.builtErr)
|
||||
}
|
||||
|
||||
if raw, err := os.ReadFile(filepath.Join(abs, filepath.FromSlash(site.ManifestFile))); err != nil {
|
||||
s.manifestErr = err
|
||||
s.log.Warn("stage manifest unavailable", "dist", abs, "error", err)
|
||||
} else if m, err := stage.LoadManifest(raw); err != nil {
|
||||
s.manifestErr = err
|
||||
s.log.Warn("stage manifest is not readable", "dist", abs, "error", err)
|
||||
} else {
|
||||
s.manifest = m
|
||||
}
|
||||
|
||||
s.index, s.indexErr = search.Open(filepath.Join(abs, site.IndexFile))
|
||||
if s.indexErr != nil {
|
||||
s.log.Warn("search index unavailable", "dist", abs, "error", s.indexErr)
|
||||
}
|
||||
|
||||
s.handler = s.routes()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Handler is the whole site behind one http.Handler.
|
||||
func (s *Server) Handler() http.Handler { return s.handler }
|
||||
|
||||
// Close releases the search index handle.
|
||||
func (s *Server) Close() error {
|
||||
if s.index != nil {
|
||||
return s.index.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Liveness and readiness. The AI field is separate and deliberately false:
|
||||
// Mode A never needs a model (docs/phase-2-static-site.md §2.1).
|
||||
mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||
|
||||
// The two things that are not pre-rendered files.
|
||||
mux.HandleFunc("GET /search", s.handleSearch)
|
||||
mux.HandleFunc("GET /api/stage/stream", s.handleStream)
|
||||
|
||||
// Route driver: one pre-rendered fragment per click.
|
||||
mux.HandleFunc("GET /stage/{id}", s.handleFragment)
|
||||
|
||||
// Assets the build copied out of the repository.
|
||||
mux.Handle("GET /static/", s.assetHandler("static"))
|
||||
mux.Handle("GET /assets/", s.assetHandler("assets"))
|
||||
|
||||
// Every other path is a page. "GET /" is the fallback pattern.
|
||||
mux.HandleFunc("GET /", s.handlePage)
|
||||
|
||||
return s.middleware(s.rejectDotSegments(mux))
|
||||
}
|
||||
|
||||
// rejectDotSegments refuses a request whose path has a "." or ".." segment
|
||||
// before http.ServeMux cleans it and answers with a redirect. Without this,
|
||||
// "/static/../build.json" is redirected to "/build.json" and served from outside
|
||||
// the asset directory. The check runs on URL.Path, which is already percent-
|
||||
// decoded, so an encoded "%2e%2e" is caught as well.
|
||||
func (s *Server) rejectDotSegments(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if hasDotSegment(r.URL.Path) {
|
||||
s.notFound(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// hasDotSegment reports whether any segment of p is "." or "..".
|
||||
func hasDotSegment(p string) bool {
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
if seg == "." || seg == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// middleware adds the security headers, one log line per request, and a panic
|
||||
// guard that keeps one bad request from taking the process down.
|
||||
func (s *Server) middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
// The policy mirrors the Caddy block in docs/phase-3-deploy.md, so the
|
||||
// browser sees the same rules whether it reaches the origin or the edge.
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
||||
|
||||
rec := &statusWriter{ResponseWriter: w}
|
||||
started := time.Now()
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
s.log.Error("request panic", "method", r.Method, "path", r.URL.Path, "panic", fmt.Sprint(p))
|
||||
if rec.status == 0 {
|
||||
http.Error(rec, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
s.log.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.statusOf(),
|
||||
"bytes", rec.bytes,
|
||||
"duration_ms", time.Since(started).Milliseconds(),
|
||||
)
|
||||
}()
|
||||
next.ServeHTTP(rec, r)
|
||||
})
|
||||
}
|
||||
|
||||
// handleHealth reports static and AI readiness separately. Static readiness is
|
||||
// the only thing that decides the status code, because static readiness is what
|
||||
// a deploy needs; the AI field says plainly that Mode A does not use one.
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
indexReady := s.index != nil
|
||||
documents := 0
|
||||
if indexReady {
|
||||
if n, err := s.index.Count(); err == nil {
|
||||
documents = n
|
||||
}
|
||||
}
|
||||
if documents == 0 && s.built != nil {
|
||||
documents = s.built.Indexed
|
||||
}
|
||||
|
||||
static := struct {
|
||||
Ready bool `json:"ready"`
|
||||
Pages int `json:"pages,omitempty"`
|
||||
Built string `json:"built,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}{Ready: s.staticReady()}
|
||||
if s.built != nil {
|
||||
static.Pages = s.built.Pages
|
||||
static.Built = s.built.Built.Format(time.RFC3339)
|
||||
}
|
||||
if !static.Ready && s.builtErr != nil {
|
||||
static.Error = s.builtErr.Error()
|
||||
} else if !static.Ready {
|
||||
static.Error = "index.html is missing from the build directory"
|
||||
}
|
||||
|
||||
body := struct {
|
||||
Status string `json:"status"`
|
||||
Static any `json:"static"`
|
||||
AI struct {
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"ai"`
|
||||
Search struct {
|
||||
Ready bool `json:"ready"`
|
||||
Documents int `json:"documents,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
} `json:"search"`
|
||||
}{Static: static}
|
||||
body.AI.Ready = false
|
||||
body.AI.Reason = "Mode A serves pre-rendered pages and calls no model; Mode B reports its own readiness in Phase 5."
|
||||
body.Search.Ready = indexReady
|
||||
body.Search.Documents = documents
|
||||
if s.indexErr != nil {
|
||||
body.Search.Error = s.indexErr.Error()
|
||||
}
|
||||
|
||||
code := http.StatusOK
|
||||
body.Status = "ok"
|
||||
if !static.Ready {
|
||||
code = http.StatusServiceUnavailable
|
||||
body.Status = "static-unavailable"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(code)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(body)
|
||||
}
|
||||
|
||||
// staticReady is true when the build actually produced a landing page.
|
||||
func (s *Server) staticReady() bool {
|
||||
if s.built == nil {
|
||||
return false
|
||||
}
|
||||
st, err := os.Stat(filepath.Join(s.dist, "index.html"))
|
||||
return err == nil && !st.IsDir()
|
||||
}
|
||||
|
||||
// handlePage serves a pre-rendered page by URL. Directory URLs map to
|
||||
// index.html so the same tree works under any plain file server.
|
||||
func (s *Server) handlePage(w http.ResponseWriter, r *http.Request) {
|
||||
rel := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
file, ok := s.resolvePage(rel)
|
||||
if !ok {
|
||||
s.notFound(w, r)
|
||||
return
|
||||
}
|
||||
if !s.serveFile(w, r, file, "text/html; charset=utf-8", pageCache) {
|
||||
s.notFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePage maps a cleaned URL path onto a file inside the build directory.
|
||||
// A file that is not there is not an error: the caller serves the 404 page.
|
||||
func (s *Server) resolvePage(rel string) (string, bool) {
|
||||
candidates := make([]string, 0, 3)
|
||||
switch {
|
||||
case rel == "" || strings.HasSuffix(rel, "/"):
|
||||
candidates = append(candidates, rel+"index.html")
|
||||
case path.Ext(rel) == "":
|
||||
candidates = append(candidates, rel, rel+"/index.html", rel+".html")
|
||||
default:
|
||||
candidates = append(candidates, rel)
|
||||
}
|
||||
for _, c := range candidates {
|
||||
full := filepath.Join(s.dist, filepath.FromSlash(c))
|
||||
if !within(s.dist, full) {
|
||||
continue
|
||||
}
|
||||
if st, err := os.Stat(full); err == nil && !st.IsDir() {
|
||||
return full, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// handleFragment serves the route driver's payload: one pre-rendered stage
|
||||
// fragment, selected by the evidence table's hx-get. It is the same bytes the
|
||||
// stream driver sends as an event.
|
||||
func (s *Server) handleFragment(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if !validFragmentID(id) {
|
||||
s.fragmentNotFound(w, "That fragment is not part of this build.")
|
||||
return
|
||||
}
|
||||
file := filepath.Join(s.dist, "stage", id+".html")
|
||||
if !within(s.dist, file) {
|
||||
s.fragmentNotFound(w, "That fragment is not part of this build.")
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(file); err != nil {
|
||||
s.fragmentNotFound(w, "That fragment is not part of this build.")
|
||||
return
|
||||
}
|
||||
if !s.serveFile(w, r, file, "text/html; charset=utf-8", fragCache) {
|
||||
s.fragmentNotFound(w, "That fragment could not be read.")
|
||||
}
|
||||
}
|
||||
|
||||
// validFragmentID keeps ids to the alphabet the build produces, so a request
|
||||
// can never build a path of its own.
|
||||
func validFragmentID(id string) bool {
|
||||
if id == "" || len(id) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// fragmentNotFound returns a small HTML fragment, because the panel swaps the
|
||||
// response into the page and a full document would nest inside it.
|
||||
func (s *Server) fragmentNotFound(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = io.WriteString(w, `<div class="stage-empty"><p class="stage-empty-title">`+msg+`</p><p class="stage-hint">The <a href="/evidence/">evidence index</a> lists every fragment in this build.</p></div>`)
|
||||
}
|
||||
|
||||
// handleSearch runs a keyword search. With HTMX it returns just the contents of
|
||||
// the results block, which the search page's own form swaps into #search-results;
|
||||
// without JavaScript the same handler returns the whole page, so the form works
|
||||
// in both cases.
|
||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
failed := false
|
||||
indexed := 0
|
||||
if s.built != nil {
|
||||
indexed = s.built.Indexed
|
||||
}
|
||||
var results []search.Result
|
||||
if s.index != nil {
|
||||
if n, err := s.index.Count(); err == nil {
|
||||
indexed = n
|
||||
}
|
||||
}
|
||||
if q != "" {
|
||||
if s.index == nil {
|
||||
failed = true
|
||||
} else {
|
||||
var err error
|
||||
results, err = s.index.Query(q, 20)
|
||||
if err != nil {
|
||||
failed = true
|
||||
s.log.Warn("search failed", "query_length", len(q), "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view := s.shell.SearchView(indexed, q, results, failed)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Vary", "HX-Request")
|
||||
|
||||
var buf bytes.Buffer
|
||||
var c interface {
|
||||
Render(context.Context, io.Writer) error
|
||||
} = templates.SearchPage(view)
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
// The element that holds #search-results stays where it is, so the
|
||||
// fragment carries the contents and not the wrapper.
|
||||
c = templates.SearchResultsBody(view)
|
||||
}
|
||||
if err := c.Render(r.Context(), &buf); err != nil {
|
||||
s.log.Error("render search", "error", err)
|
||||
http.Error(w, "search is unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// notFound serves the pre-rendered 404 page with a 404 status.
|
||||
func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
|
||||
file := filepath.Join(s.dist, "404.html")
|
||||
if raw, err := os.ReadFile(file); err == nil && within(s.dist, file) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write(raw)
|
||||
}
|
||||
return
|
||||
}
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
// assetHandler serves a directory of build output (/static/, /assets/).
|
||||
func (s *Server) assetHandler(dir string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
prefix := "/" + dir + "/"
|
||||
rel := strings.TrimPrefix(path.Clean("/"+strings.TrimPrefix(r.URL.Path, prefix)), "/")
|
||||
if rel == "" || strings.HasPrefix(rel, "..") {
|
||||
s.notFound(w, r)
|
||||
return
|
||||
}
|
||||
file := filepath.Join(s.dist, dir, filepath.FromSlash(rel))
|
||||
if !within(filepath.Join(s.dist, dir), file) {
|
||||
s.notFound(w, r)
|
||||
return
|
||||
}
|
||||
ctype := contentTypeFor(file)
|
||||
if !s.serveFile(w, r, file, ctype, assetCache) {
|
||||
s.notFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// serveFile writes one file, with Range and HEAD support. It returns false when
|
||||
// the file is missing or unreadable, so the caller can choose the error page.
|
||||
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, file, ctype, cache string) bool {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
st, err := f.Stat()
|
||||
if err != nil || st.IsDir() {
|
||||
return false
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", ctype)
|
||||
h.Set("Cache-Control", cache)
|
||||
h.Set("Content-Length", strconv.FormatInt(st.Size(), 10))
|
||||
http.ServeContent(w, r, st.Name(), st.ModTime(), f)
|
||||
return true
|
||||
}
|
||||
|
||||
// within reports whether target is inside root, after cleaning both. Every path
|
||||
// built from a request goes through here before it reaches the filesystem.
|
||||
func within(root, target string) bool {
|
||||
rel, err := filepath.Rel(root, target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
|
||||
}
|
||||
|
||||
// contentTypeFor is the small set of types this site publishes.
|
||||
func contentTypeFor(file string) string {
|
||||
switch strings.ToLower(filepath.Ext(file)) {
|
||||
case ".css":
|
||||
return "text/css; charset=utf-8"
|
||||
case ".js":
|
||||
return "text/javascript; charset=utf-8"
|
||||
case ".svg":
|
||||
return "image/svg+xml"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".woff2":
|
||||
return "font/woff2"
|
||||
case ".json":
|
||||
return "application/json; charset=utf-8"
|
||||
case ".html":
|
||||
return "text/html; charset=utf-8"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// statusWriter records the status and size for the request log, and forwards
|
||||
// Flush so an event stream is not buffered by the middleware.
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
if w.status == 0 {
|
||||
w.status = code
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
n, err := w.ResponseWriter.Write(b)
|
||||
w.bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *statusWriter) Flush() {
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
|
||||
func (w *statusWriter) statusOf() int {
|
||||
if w.status == 0 {
|
||||
return http.StatusOK
|
||||
}
|
||||
return w.status
|
||||
}
|
||||
565
internal/server/server_test.go
Normal file
565
internal/server/server_test.go
Normal file
@@ -0,0 +1,565 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site"
|
||||
)
|
||||
|
||||
// The tests run against a real build of the repository's content, because the
|
||||
// thing under test is "does the server serve what the build wrote". One build is
|
||||
// shared by every test.
|
||||
var (
|
||||
distDir string
|
||||
buildErr error
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "ai-resume-server-test-")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
s, err := site.New(site.Config{Root: root, OutDir: dir})
|
||||
if err == nil {
|
||||
err = s.Build()
|
||||
}
|
||||
buildErr, distDir = err, dir
|
||||
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// newServer starts the site under test. Frames are sent as fast as possible so
|
||||
// the stream test does not sleep.
|
||||
func newServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
if buildErr != nil {
|
||||
t.Fatalf("build fixture: %v", buildErr)
|
||||
}
|
||||
s, err := New(Config{DistDir: distDir, StreamDelay: time.Millisecond})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
ts := httptest.NewServer(s.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
func get(t *testing.T, ts *httptest.Server, path string, headers map[string]string) (int, http.Header, string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("request %s: %v", path, err)
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
res, err := ts.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", path, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return res.StatusCode, res.Header, string(body)
|
||||
}
|
||||
|
||||
// TestHealthzSeparatesStaticAndAI is §2.1: readiness for static is its own
|
||||
// field and does not depend on any AI service.
|
||||
func TestHealthzSeparatesStaticAndAI(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, header, body := get(t, ts, "/healthz", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("healthz status = %d, want 200; body: %s", code, body)
|
||||
}
|
||||
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("healthz content-type = %q, want JSON", ct)
|
||||
}
|
||||
var got struct {
|
||||
Status string `json:"status"`
|
||||
Static struct {
|
||||
Ready bool `json:"ready"`
|
||||
Pages int `json:"pages"`
|
||||
} `json:"static"`
|
||||
AI struct {
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"ai"`
|
||||
Search struct {
|
||||
Ready bool `json:"ready"`
|
||||
Documents int `json:"documents"`
|
||||
} `json:"search"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &got); err != nil {
|
||||
t.Fatalf("healthz is not JSON: %v\n%s", err, body)
|
||||
}
|
||||
if !got.Static.Ready {
|
||||
t.Error("static.ready = false, want true")
|
||||
}
|
||||
if got.Static.Pages == 0 {
|
||||
t.Error("static.pages = 0, want the built page count")
|
||||
}
|
||||
if got.AI.Ready {
|
||||
t.Error("ai.ready = true, want false in Mode A")
|
||||
}
|
||||
if got.AI.Reason == "" {
|
||||
t.Error("ai.reason is empty; the field must say why AI is not ready")
|
||||
}
|
||||
if !got.Search.Ready || got.Search.Documents == 0 {
|
||||
t.Errorf("search = %+v, want ready with documents", got.Search)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPagesArePreRendered checks that the two page kinds render from disk with
|
||||
// the chrome, the nav and the panel.
|
||||
func TestPagesArePreRendered(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
cases := []struct {
|
||||
path string
|
||||
wants []string
|
||||
}{
|
||||
{"/", []string{"Sam Rolfe", `data-stage-driver="route"`, `id="stage-content"`, "Presentation panel", "Skip to content"}},
|
||||
{"/edge/", []string{"Edge", `data-stage-driver="route"`, "Breadcrumb"}},
|
||||
{"/evidence/", []string{"Evidence index", `class="evidence"`}},
|
||||
{"/stream/", []string{`data-stage-driver="stream"`, `sse-connect="/api/stage/stream"`, `sse-swap="stage"`, "/static/sse.js"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
code, header, body := get(t, ts, tc.path, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("%s: status %d, want 200", tc.path, code)
|
||||
continue
|
||||
}
|
||||
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("%s: content-type %q", tc.path, ct)
|
||||
}
|
||||
for _, want := range tc.wants {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%s: body does not contain %q", tc.path, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModeADoesNotShipTheStreamExtension keeps the two modes honestly separate:
|
||||
// a page only loads htmx's SSE extension when its panel is stream-driven.
|
||||
func TestModeADoesNotShipTheStreamExtension(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, path := range []string{"/", "/edge/", "/evidence/"} {
|
||||
if _, _, body := get(t, ts, path, nil); strings.Contains(body, "sse.js") {
|
||||
t.Errorf("%s loads sse.js, but it is not a stream page", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouteDriverFragment is §2.4's first half: a click loads one pre-rendered
|
||||
// fragment, and it is a fragment, not a page.
|
||||
func TestRouteDriverFragment(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, header, body := get(t, ts, "/stage/doc-edge-overview", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("fragment status = %d, want 200", code)
|
||||
}
|
||||
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("fragment content-type = %q", ct)
|
||||
}
|
||||
if !strings.Contains(body, `class="frag frag-`) {
|
||||
t.Errorf("fragment body does not look like a fragment: %.120s", body)
|
||||
}
|
||||
if strings.Contains(body, "<!DOCTYPE html>") || strings.Contains(body, "<html") {
|
||||
t.Error("fragment response is a whole document; the panel would nest a page")
|
||||
}
|
||||
|
||||
for _, bad := range []string{"/stage/../../etc/passwd", "/stage/", "/stage/..%2f..%2fetc%2fpasswd", "/stage/does-not-exist"} {
|
||||
if code, _, _ := get(t, ts, bad, nil); code == http.StatusOK {
|
||||
t.Errorf("%s: status 200, want a 404 fragment", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamDriverSendsFragments is §2.4's second half: the same fragments,
|
||||
// pushed over SSE, with no layout change.
|
||||
func TestStreamDriverSendsFragments(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, header, body := get(t, ts, "/api/stage/stream?k=doc-edge-overview", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("stream status = %d, want 200", code)
|
||||
}
|
||||
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
||||
t.Fatalf("stream content-type = %q, want text/event-stream", ct)
|
||||
}
|
||||
if got := header.Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
||||
t.Errorf("stream cache-control = %q, want no-store", got)
|
||||
}
|
||||
if !strings.Contains(body, "event: "+StreamEvent) {
|
||||
t.Errorf("stream has no %q event: %.200s", StreamEvent, body)
|
||||
}
|
||||
if got := strings.Count(body, "event: "+StreamEvent); got != 1 {
|
||||
t.Errorf("stream sent %d events for one key, want 1", got)
|
||||
}
|
||||
// Exactly one article per frame: the build rendered it, the server must not
|
||||
// wrap it again.
|
||||
if got := strings.Count(body, `<article class="frag`); got != 1 {
|
||||
t.Errorf("frame contains %d fragment articles, want 1", got)
|
||||
}
|
||||
if !strings.Contains(body, "data: ") {
|
||||
t.Error("stream frame has no data line")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamDefaultIsSixAreaFragments keeps the demonstration's default stream
|
||||
// honest: the six area landing fragments, in navigation order, and no more.
|
||||
func TestStreamDefaultIsSixAreaFragments(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, _, body := get(t, ts, "/api/stage/stream", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("stream status = %d, want 200", code)
|
||||
}
|
||||
if got := strings.Count(body, "event: "+StreamEvent); got != 6 {
|
||||
t.Errorf("default stream has %d frames, want the six areas", got)
|
||||
}
|
||||
// Titles are HTML-escaped in the frame, so match the distinctive word.
|
||||
for _, area := range []string{"Agentic", "Platform", "Edge", "Products", "History", "About"} {
|
||||
if !strings.Contains(body, area) {
|
||||
t.Errorf("default stream is missing the %s area", area)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchWorksWithAndWithoutHTMX covers §2.5 and §2.6: the same handler
|
||||
// returns a page for a form post and a fragment for HTMX.
|
||||
func TestSearchWorksWithAndWithoutHTMX(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, tc := range []struct {
|
||||
q string
|
||||
want string
|
||||
}{
|
||||
{"zigbee", "/edge/"},
|
||||
{"signage", "/products/"},
|
||||
{"routing", "/ai/"},
|
||||
} {
|
||||
code, _, body := get(t, ts, "/search?q="+tc.q, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("search %q: status %d", tc.q, code)
|
||||
continue
|
||||
}
|
||||
// templ writes a lowercase HTML5 doctype, so compare case-insensitively.
|
||||
if !strings.Contains(strings.ToLower(body), "<!doctype html>") {
|
||||
t.Errorf("search %q without HTMX did not return a page", tc.q)
|
||||
}
|
||||
if !strings.Contains(body, tc.want) {
|
||||
t.Errorf("search %q has no result in %s", tc.q, tc.want)
|
||||
}
|
||||
|
||||
code, _, frag := get(t, ts, "/search?q="+tc.q, map[string]string{"HX-Request": "true"})
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("htmx search %q: status %d", tc.q, code)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(frag, "<html") {
|
||||
t.Errorf("htmx search %q returned a whole page, want the results block", tc.q)
|
||||
}
|
||||
// The fragment is swapped into #search-results with innerHTML, so it
|
||||
// must not carry the wrapper again: a second element with the same id
|
||||
// inside the first is invalid and makes the live region announce twice.
|
||||
if strings.Contains(frag, `id="search-results"`) {
|
||||
t.Errorf("htmx search %q re-declares #search-results inside itself", tc.q)
|
||||
}
|
||||
if !strings.Contains(frag, "results for") {
|
||||
t.Errorf("htmx search %q returned no results", tc.q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchRejectsOperators checks that FTS5 syntax in a visitor's input is
|
||||
// never honoured, so a query can only be words.
|
||||
func TestSearchRejectsOperators(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, q := range []string{`" OR "`, `zigbee" OR body:`, `NEAR(zigbee routing)`, `*`} {
|
||||
code, _, body := get(t, ts, "/search?q="+strings.ReplaceAll(q, " ", "+"), nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("query %q: status %d, want 200 with an honest empty/refused result", q, code)
|
||||
}
|
||||
if strings.Contains(body, "could not be run") && q == "*" {
|
||||
t.Logf("query %q reported as unparseable, which is acceptable", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownPathIsTheBuilt404Page makes sure a typo lands on the pre-rendered
|
||||
// page rather than a bare error, and that the status stays 404.
|
||||
func TestUnknownPathIsTheBuilt404Page(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, _, body := get(t, ts, "/does-not-exist", nil)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", code)
|
||||
}
|
||||
if !strings.Contains(body, "That page is not here") {
|
||||
t.Error("404 body is not the built 404 page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticAssetsAreServedWithTheirType keeps the stylesheet, htmx and the SSE
|
||||
// extension reachable, and confirms content sniffing is refused.
|
||||
func TestStaticAssetsAreServedWithTheirType(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, tc := range []struct{ path, ctype string }{
|
||||
{"/static/site.css", "text/css"},
|
||||
{"/static/chroma.css", "text/css"},
|
||||
{"/static/htmx.min.js", "text/javascript"},
|
||||
{"/static/sse.js", "text/javascript"},
|
||||
} {
|
||||
code, header, _ := get(t, ts, tc.path, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("%s: status %d, want 200", tc.path, code)
|
||||
continue
|
||||
}
|
||||
if ct := header.Get("Content-Type"); !strings.HasPrefix(ct, tc.ctype) {
|
||||
t.Errorf("%s: content-type %q, want %q", tc.path, ct, tc.ctype)
|
||||
}
|
||||
if header.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Errorf("%s: missing nosniff", tc.path)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"/static/../build.json", "/static/%2e%2e/build.json", "/assets/../dist"} {
|
||||
if code, _, _ := get(t, ts, bad, nil); code == http.StatusOK {
|
||||
t.Errorf("%s: status 200, want a rejection", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityHeadersAreAlwaysPresent mirrors the phase 3 edge rules at the
|
||||
// origin, so a direct request sees the same policy as one through Caddy.
|
||||
func TestSecurityHeadersAreAlwaysPresent(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, path := range []string{"/", "/search?q=zigbee", "/healthz", "/does-not-exist"} {
|
||||
_, header, _ := get(t, ts, path, nil)
|
||||
if header.Get("Content-Security-Policy") == "" {
|
||||
t.Errorf("%s: no CSP", path)
|
||||
}
|
||||
if header.Get("Referrer-Policy") == "" {
|
||||
t.Errorf("%s: no referrer policy", path)
|
||||
}
|
||||
if header.Get("X-Frame-Options") == "" {
|
||||
t.Errorf("%s: no frame options", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirstByteBudget is a coarse A2 check: on loopback, well inside the 100 ms
|
||||
// budget the specification sets for a page served over the LAN.
|
||||
func TestFirstByteBudget(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
started := time.Now()
|
||||
code, _, _ := get(t, ts, "/", nil)
|
||||
elapsed := time.Since(started)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d", code)
|
||||
}
|
||||
if elapsed > 100*time.Millisecond {
|
||||
t.Errorf("first byte in %s, budget is 100ms", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// navSearchForm isolates the navigation search form's opening tag and markup.
|
||||
// The layout has one such form on every page; there is nothing to search for if
|
||||
// it is missing.
|
||||
func navSearchForm(t *testing.T, path, body string) string {
|
||||
t.Helper()
|
||||
marker := strings.Index(body, `class="search nav-search"`)
|
||||
if marker < 0 {
|
||||
t.Fatalf("%s: no navigation search form", path)
|
||||
}
|
||||
start := strings.LastIndex(body[:marker], "<form")
|
||||
end := strings.Index(body[marker:], "</form>")
|
||||
if start < 0 || end < 0 {
|
||||
t.Fatalf("%s: could not isolate the navigation search form", path)
|
||||
}
|
||||
return body[start : marker+end]
|
||||
}
|
||||
|
||||
// TestNavigationSearchNavigatesFromEveryPage is the AUD-151 regression: the nav
|
||||
// form used to carry hx-target="#search-results", which only exists on /search,
|
||||
// so htmx silently discarded the response everywhere else. The nav form must be
|
||||
// a plain GET, and no page may target a fragment it does not carry.
|
||||
func TestNavigationSearchNavigatesFromEveryPage(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
pages := []string{
|
||||
"/",
|
||||
"/ai/", "/platform/", "/edge/", "/products/", "/history/", "/about/",
|
||||
"/evidence/", "/edge/edge-fleet/", "/404.html",
|
||||
}
|
||||
for _, path := range pages {
|
||||
code, _, body := get(t, ts, path, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("%s: status %d", path, code)
|
||||
continue
|
||||
}
|
||||
form := navSearchForm(t, path, body)
|
||||
if strings.Contains(form, "hx-") {
|
||||
t.Errorf("%s: navigation search form carries an htmx attribute:\n%s", path, form)
|
||||
}
|
||||
if !strings.Contains(form, `action="/search"`) {
|
||||
t.Errorf("%s: navigation search form does not post to /search:\n%s", path, form)
|
||||
}
|
||||
if !strings.Contains(form, `method="get"`) {
|
||||
t.Errorf("%s: navigation search form is not a GET:\n%s", path, form)
|
||||
}
|
||||
if strings.Contains(body, `hx-target="#search-results"`) {
|
||||
t.Errorf("%s: page targets #search-results, which only the search page carries", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchFromAnyPageLandsOnResults follows the nav form the way a browser with
|
||||
// JavaScript disabled would: GET the form action with q set, expect a page.
|
||||
func TestSearchFromAnyPageLandsOnResults(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
for _, path := range []string{"/", "/edge/", "/platform/"} {
|
||||
code, _, body := get(t, ts, path, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("%s: status %d", path, code)
|
||||
continue
|
||||
}
|
||||
form := navSearchForm(t, path, body)
|
||||
if !strings.Contains(form, `action="/search"`) {
|
||||
t.Errorf("%s: nav form has no /search action to follow", path)
|
||||
continue
|
||||
}
|
||||
// No HX-Request header: this is the no-JavaScript path.
|
||||
code, _, landed := get(t, ts, "/search?q=zigbee", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("search from %s: status %d", path, code)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(landed), "<!doctype html>") {
|
||||
t.Errorf("search from %s did not land on a page", path)
|
||||
}
|
||||
if !strings.Contains(landed, "results for") || !strings.Contains(landed, "/edge/") {
|
||||
t.Errorf("search from %s landed on a page with no results", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoHTMXTargetIsMissingFromItsPage walks the whole built site: every
|
||||
// hx-target selector must name an element in the same document. This is the
|
||||
// general form of the AUD-151 bug, so the next one fails a test instead of
|
||||
// failing silently in a browser.
|
||||
func TestNoHTMXTargetIsMissingFromItsPage(t *testing.T) {
|
||||
target := regexp.MustCompile(`hx-target="([^"]+)"`)
|
||||
files := 0
|
||||
err := filepath.WalkDir(distDir, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(p, ".html") {
|
||||
return nil
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files++
|
||||
body := string(raw)
|
||||
for _, m := range target.FindAllStringSubmatch(body, -1) {
|
||||
selector := m[1]
|
||||
if !strings.HasPrefix(selector, "#") {
|
||||
continue // closest/find targets are resolved from the trigger
|
||||
}
|
||||
id := strings.TrimPrefix(selector, "#")
|
||||
if !strings.Contains(body, `id="`+id+`"`) {
|
||||
t.Errorf("%s: hx-target=%q has no matching element", p, selector)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", distDir, err)
|
||||
}
|
||||
if files == 0 {
|
||||
t.Fatal("no HTML files scanned; the build fixture is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchEmptyQueryShowsAPrompt covers "an empty query shows a prompt, not an
|
||||
// empty list".
|
||||
func TestSearchEmptyQueryShowsAPrompt(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, _, body := get(t, ts, "/search", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d", code)
|
||||
}
|
||||
if !strings.Contains(body, "Type one or two words above") {
|
||||
t.Error("empty search shows no prompt")
|
||||
}
|
||||
if strings.Contains(body, `class="results"`) {
|
||||
t.Error("empty search renders a results list")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchWithNoMatchesSuggestsTheAreas covers "a query with no matches shows a
|
||||
// clear nothing-found message and suggests browsing the areas".
|
||||
func TestSearchWithNoMatchesSuggestsTheAreas(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, _, body := get(t, ts, "/search?q=appaloosafoundation", nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d", code)
|
||||
}
|
||||
if !strings.Contains(body, "Nothing found for") {
|
||||
t.Error("no nothing-found message")
|
||||
}
|
||||
for _, href := range []string{`href="/ai/"`, `href="/platform/"`, `href="/edge/"`, `href="/evidence/"`} {
|
||||
if !strings.Contains(body, href) {
|
||||
t.Errorf("nothing-found message does not offer %s", href)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchCountIsAnnounced checks the count is inside the polite live region
|
||||
// and marked as a status, so a screen reader reads "4 results for …" rather than
|
||||
// an undifferentiated block of titles.
|
||||
func TestSearchCountIsAnnounced(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
_, _, body := get(t, ts, "/search?q=zigbee", nil)
|
||||
if !strings.Contains(body, `id="search-results" aria-live="polite"`) {
|
||||
t.Error("results container is not a live region")
|
||||
}
|
||||
if !regexp.MustCompile(`<p class="search-count" role="status">\d+ results for`).MatchString(body) {
|
||||
t.Error("result count is not announced as a status inside the live region")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTMXSearchStillReturnsTheFragment keeps the search page's own upgrade: with
|
||||
// the header set, the handler returns the block and not a whole page.
|
||||
func TestHTMXSearchStillReturnsTheFragment(t *testing.T) {
|
||||
ts := newServer(t)
|
||||
code, _, frag := get(t, ts, "/search?q=zigbee", map[string]string{"HX-Request": "true"})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d", code)
|
||||
}
|
||||
if strings.Contains(frag, "<html") {
|
||||
t.Error("htmx search returned a whole page")
|
||||
}
|
||||
if strings.Contains(frag, `id="search-results"`) {
|
||||
t.Error("htmx search re-declares #search-results inside itself")
|
||||
}
|
||||
if !strings.Contains(frag, `role="status"`) || !strings.Contains(frag, "results for") {
|
||||
t.Error("htmx search did not return the results block")
|
||||
}
|
||||
}
|
||||
188
internal/server/stream.go
Normal file
188
internal/server/stream.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
|
||||
)
|
||||
|
||||
// StreamEvent is the SSE event the panel listens for. The binding rendered by
|
||||
// stage.SSEDriver uses the same name, so a client needs no configuration.
|
||||
const StreamEvent = "stage"
|
||||
|
||||
// maxStreamKeys caps how many fragments one connection may ask for, so a
|
||||
// crafted query cannot turn the demonstration into a long-lived loop.
|
||||
const maxStreamKeys = 24
|
||||
|
||||
// handleStream points the stage panel at an event stream.
|
||||
//
|
||||
// This is the Phase 2 proof of the §2.4 contract: the panel markup does not
|
||||
// change, only the binding does. The frames come from the fragments the build
|
||||
// already rendered, so nothing calls a model and the site still works with
|
||||
// every AI service stopped. Phase 5 replaces the source behind the same
|
||||
// interface (stage.Source) and this handler does not change.
|
||||
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming is not supported by this server", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if s.manifest == nil || len(s.manifest.Entries) == 0 {
|
||||
msg := "the stage manifest is not part of this build"
|
||||
if s.manifestErr != nil {
|
||||
s.log.Warn("stream unavailable", "error", s.manifestErr)
|
||||
}
|
||||
http.Error(w, msg, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
keys := s.streamKeys(r.URL.Query())
|
||||
if len(keys) == 0 {
|
||||
keys = s.defaultStreamKeys()
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
http.Error(w, "no fragments to stream", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-store")
|
||||
h.Set("Connection", "keep-alive")
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// A client that reconnects gets a short pause rather than an immediate retry.
|
||||
if _, err := io.WriteString(w, "retry: 2000\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
|
||||
ctx := r.Context()
|
||||
source := s.newStreamSource(ctx)
|
||||
err := source.Stream(ctx, keys, func(f stage.Fragment) error {
|
||||
var buf bytes.Buffer
|
||||
if f.Rendered {
|
||||
// The build already rendered the whole article; send those bytes.
|
||||
if err := f.Body.Render(ctx, &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// A structured source (Phase 5) hands over parts, so the panel
|
||||
// renders them here with the same component the route driver uses.
|
||||
if err := templates.FragmentView(f).Render(ctx, &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeSSE(w, StreamEvent, buf.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
flusher.Flush()
|
||||
return nil
|
||||
})
|
||||
if err != nil && ctx.Err() == nil {
|
||||
// Headers are already sent, so there is no status to change. Log it and
|
||||
// let the panel keep the last fragment it received.
|
||||
s.log.Warn("stage stream ended early", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// streamKeys reads the fragment ids a caller asked for. Both `?k=a&k=b` and
|
||||
// `?k=a,b` work, because both are natural to write by hand.
|
||||
func (s *Server) streamKeys(q url.Values) []string {
|
||||
var keys []string
|
||||
for _, raw := range q["k"] {
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
id := strings.TrimSpace(part)
|
||||
if id != "" && validFragmentID(id) {
|
||||
keys = append(keys, id)
|
||||
}
|
||||
if len(keys) >= maxStreamKeys {
|
||||
return keys
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// defaultStreamKeys is the demonstration stream: the six area landing
|
||||
// fragments, in navigation order. The build writes those first, so the manifest
|
||||
// alone is enough — the server never reads the content tree.
|
||||
func (s *Server) defaultStreamKeys() []string {
|
||||
if s.manifest == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, 6)
|
||||
for _, e := range s.manifest.Entries {
|
||||
if !strings.HasPrefix(e.ID, "doc-") {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, e.ID)
|
||||
if len(keys) == 6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
return keys
|
||||
}
|
||||
for _, e := range s.manifest.Entries {
|
||||
keys = append(keys, e.ID)
|
||||
if len(keys) == 6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// newStreamSource wires the manifest to the files on disk and paces the frames.
|
||||
func (s *Server) newStreamSource(ctx context.Context) *stage.ManifestSource {
|
||||
read := func(e stage.Entry) (string, error) {
|
||||
file := filepath.Join(s.dist, filepath.FromSlash(e.File))
|
||||
if !within(s.dist, file) {
|
||||
return "", fmt.Errorf("fragment %s is outside the build directory", e.ID)
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fragment %s: %w", e.ID, err)
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
delay := s.cfg.StreamDelay
|
||||
frame := func(i int) {
|
||||
if i == 0 || delay <= 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
return stage.NewManifestSource(s.manifest, read, frame)
|
||||
}
|
||||
|
||||
// writeSSE writes one event. A payload with newlines is split across data
|
||||
// lines, which is what the server-sent events format expects.
|
||||
func writeSSE(w io.Writer, event, data string) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("event: ")
|
||||
b.WriteString(event)
|
||||
b.WriteByte('\n')
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
b.WriteString("data: ")
|
||||
b.WriteString(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
_, err := io.WriteString(w, b.String())
|
||||
return err
|
||||
}
|
||||
325
internal/site/build.go
Normal file
325
internal/site/build.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
|
||||
)
|
||||
|
||||
// Published file names. The server and the build agree on these, and nothing
|
||||
// else does.
|
||||
const (
|
||||
// IndexFile is the SQLite FTS5 index, read-only at request time.
|
||||
IndexFile = "search.db"
|
||||
// ManifestFile is the stage fragment index.
|
||||
ManifestFile = "stage/manifest.json"
|
||||
// BuildInfoFile records what the last build produced.
|
||||
BuildInfoFile = "build.json"
|
||||
// markerFile marks a directory as a previous build, so Build can clean it
|
||||
// without ever deleting a directory that was not ours.
|
||||
markerFile = ".build"
|
||||
)
|
||||
|
||||
// streamEndpoint is where the streaming panel connects.
|
||||
const streamEndpoint = "/api/stage/stream"
|
||||
|
||||
// BuildInfo is what the build wrote, for /healthz and for the operator.
|
||||
type BuildInfo struct {
|
||||
Built time.Time `json:"built"`
|
||||
Documents int `json:"documents"`
|
||||
Pages int `json:"pages"`
|
||||
Fragments int `json:"fragments"`
|
||||
Indexed int `json:"indexed"`
|
||||
Artefacts int `json:"artefacts"`
|
||||
}
|
||||
|
||||
// LoadBuildInfo reads build.json from a published directory.
|
||||
func LoadBuildInfo(dir string) (*BuildInfo, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, BuildInfoFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var info BuildInfo
|
||||
if err := json.Unmarshal(raw, &info); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", BuildInfoFile, err)
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// Build pre-renders the whole site into OutDir and makes no network call.
|
||||
//
|
||||
// It writes: the landing page, every published document at its own URL, the
|
||||
// evidence index, the search page shell, the streaming panel page, the 404,
|
||||
// one file per stage fragment plus the manifest, the FTS5 index, the content
|
||||
// assets and the static files. Nothing is rendered at request time except a
|
||||
// search with a query in it.
|
||||
func (s *Site) Build() error {
|
||||
started := time.Now()
|
||||
if err := s.prepareOut(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pages := 0
|
||||
writePage := func(url string, c templ.Component) error {
|
||||
pages++
|
||||
return renderTo(s.filePathForURL(url), c)
|
||||
}
|
||||
|
||||
if err := writePage("/", templates.HomePage(s.Home())); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range s.PublicDocs() {
|
||||
if err := writePage(d.URL, templates.DocPage(s.Doc(d))); err != nil {
|
||||
return fmt.Errorf("%s: %w", d.Path, err)
|
||||
}
|
||||
}
|
||||
if err := writePage("/evidence/", templates.EvidencePage(s.Evidence())); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePage("/search", templates.SearchPage(s.SearchView(len(s.PublicDocs()), "", nil, false))); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePage("/stream/", templates.StreamPage(s.Stream(streamEndpoint))); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePage("/404.html", templates.NotFoundPage(s.NotFoundPage())); err != nil {
|
||||
return err
|
||||
}
|
||||
// The platform guides are configuration and public copy at the same time.
|
||||
// Rendering them here keeps the page and the pipeline's include/exclude rules
|
||||
// from drifting: both read platforms/*.yml.
|
||||
if _, err := RenderPlatforms(s.Config.Root, s.Config.OutDir); err != nil {
|
||||
return fmt.Errorf("platform guides: %w", err)
|
||||
}
|
||||
pages++
|
||||
|
||||
for _, f := range s.Fragments {
|
||||
if err := writeFile(filepath.Join(s.Config.OutDir, filepath.FromSlash(f.File)), []byte(f.HTML)); err != nil {
|
||||
return fmt.Errorf("fragment %s: %w", f.ID, err)
|
||||
}
|
||||
}
|
||||
manifest, err := s.Manifest.Marshal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFile(filepath.Join(s.Config.OutDir, ManifestFile), manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexed, err := s.buildIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.copyAssets(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.copyStatic(); err != nil {
|
||||
return err
|
||||
}
|
||||
info := BuildInfo{
|
||||
Built: started.UTC(),
|
||||
Documents: len(s.PublicDocs()),
|
||||
Pages: pages,
|
||||
Fragments: len(s.Fragments),
|
||||
Indexed: indexed,
|
||||
Artefacts: s.artefactCount(),
|
||||
}
|
||||
raw, err := json.MarshalIndent(info, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFile(filepath.Join(s.Config.OutDir, BuildInfoFile), append(raw, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile(filepath.Join(s.Config.OutDir, markerFile), []byte("ai-resume build output\n"))
|
||||
}
|
||||
|
||||
// filePathForURL maps a site URL onto its file. Directory URLs get index.html
|
||||
// so the same tree works on any plain file server as well as this binary.
|
||||
func (s *Site) filePathForURL(url string) string {
|
||||
rel := strings.TrimPrefix(url, "/")
|
||||
if rel == "" || strings.HasSuffix(rel, "/") {
|
||||
rel += "index.html"
|
||||
} else if path.Ext(rel) == "" {
|
||||
rel += "/index.html"
|
||||
}
|
||||
return filepath.Join(s.Config.OutDir, filepath.FromSlash(rel))
|
||||
}
|
||||
|
||||
// prepareOut empties the output directory, but only if it is a directory this
|
||||
// build wrote before. There is no path by which a mistyped OutDir can delete a
|
||||
// directory that belongs to something else.
|
||||
func (s *Site) prepareOut() error {
|
||||
abs, err := filepath.Abs(s.Config.OutDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if abs == string(filepath.Separator) || abs == filepath.Dir(abs) {
|
||||
return fmt.Errorf("refusing to build into %q", abs)
|
||||
}
|
||||
root, err := filepath.Abs(s.Config.Root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if abs == root {
|
||||
return fmt.Errorf("refusing to build over the repository root %q", abs)
|
||||
}
|
||||
if st, err := os.Stat(abs); err == nil && st.IsDir() {
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
if _, err := os.Stat(filepath.Join(abs, markerFile)); err != nil {
|
||||
return fmt.Errorf("refusing to clean %s: it is not a previous build (no %s)", abs, markerFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(abs); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(abs, 0o755)
|
||||
}
|
||||
|
||||
// buildIndex writes the FTS5 index and returns how many pages it covers.
|
||||
func (s *Site) buildIndex() (int, error) {
|
||||
docs := make([]search.Doc, 0, len(s.Content.Docs))
|
||||
for _, d := range s.PublicDocs() {
|
||||
docs = append(docs, search.Doc{
|
||||
ID: d.ID,
|
||||
URL: d.URL,
|
||||
Title: d.Title,
|
||||
Area: d.Area,
|
||||
Level: d.Level,
|
||||
Confidence: d.Confidence,
|
||||
Body: indexBody(d),
|
||||
})
|
||||
}
|
||||
file := filepath.Join(s.Config.OutDir, IndexFile)
|
||||
if err := search.Build(file, docs); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(docs), nil
|
||||
}
|
||||
|
||||
// indexBody is what FTS5 searches: the claim, the rendered body reduced to
|
||||
// text, and the technology and tag vocabularies so "zigbee" finds a page that
|
||||
// names it only in front-matter.
|
||||
func indexBody(d *content.Document) string {
|
||||
parts := []string{d.Claim, d.Plain}
|
||||
parts = append(parts, d.Tech...)
|
||||
parts = append(parts, d.Tags...)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// copyAssets publishes content/assets at /assets/.
|
||||
func (s *Site) copyAssets() error {
|
||||
src := filepath.Join(s.Config.ContentDir, "assets")
|
||||
dst := filepath.Join(s.Config.OutDir, "assets")
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return filepath.WalkDir(src, func(p string, e fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
if e.IsDir() && p != src {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if e.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile(target, raw)
|
||||
})
|
||||
}
|
||||
|
||||
// copyStatic publishes the vendored htmx, the SSE extension and the site's own
|
||||
// stylesheet, plus the chroma stylesheet generated from the renderer's style so
|
||||
// the highlighted code and the page agree on colour.
|
||||
func (s *Site) copyStatic() error {
|
||||
dst := filepath.Join(s.Config.OutDir, "static")
|
||||
err := fs.WalkDir(web.Static, "static", func(p string, e fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.IsDir() {
|
||||
return nil
|
||||
}
|
||||
raw, err := web.Static.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile(filepath.Join(dst, filepath.FromSlash(strings.TrimPrefix(p, "static/"))), raw)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chroma, err := s.Renderer.ChromaCSS()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile(filepath.Join(dst, "chroma.css"), []byte(chroma))
|
||||
}
|
||||
|
||||
// renderTo renders a templ component to a file, creating its directory.
|
||||
func renderTo(file string, c templ.Component) error {
|
||||
var buf bytes.Buffer
|
||||
if err := c.Render(context.Background(), &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile(file, buf.Bytes())
|
||||
}
|
||||
|
||||
// writeFile writes atomically, so a failed build never leaves half a page.
|
||||
func writeFile(file string, raw []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := file + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, file)
|
||||
}
|
||||
|
||||
// PublishedPages lists the URLs this site publishes, for tests and for the
|
||||
// operator running the build. Order is stable.
|
||||
func (s *Site) PublishedPages() []string {
|
||||
urls := []string{"/", "/evidence/", "/search", "/stream/", "/404.html"}
|
||||
for _, d := range s.PublicDocs() {
|
||||
urls = append(urls, d.URL)
|
||||
}
|
||||
sort.Strings(urls)
|
||||
return urls
|
||||
}
|
||||
200
internal/site/fragments.go
Normal file
200
internal/site/fragments.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
|
||||
)
|
||||
|
||||
// buildFragments renders every stage fragment once, in manifest order:
|
||||
//
|
||||
// 1. every document's own fragment, L1 first in area order
|
||||
// 2. every artefact fragment, in the same document order
|
||||
//
|
||||
// The order matters: the first len(content.Areas) entries are the six area
|
||||
// landing fragments, which is the default stream, and the server reads that
|
||||
// order out of the manifest without loading the content tree. The panel is a
|
||||
// content sink, so this is the only place that decides what a fragment is.
|
||||
// Neither driver renders anything itself.
|
||||
func (s *Site) buildFragments() error {
|
||||
order := make([]*content.Document, 0, len(s.Content.Docs))
|
||||
for _, a := range content.Areas {
|
||||
for _, d := range s.PublicInArea(a.Key) {
|
||||
if d.Level == 1 {
|
||||
order = append(order, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
rest := make([]*content.Document, 0, len(s.Content.Docs))
|
||||
for _, a := range content.Areas {
|
||||
for _, d := range s.PublicInArea(a.Key) {
|
||||
if d.Level != 1 {
|
||||
rest = append(rest, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
order = append(order, rest...)
|
||||
|
||||
for _, d := range order {
|
||||
if err := s.addFragment(s.docFragment(d)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, d := range order {
|
||||
for i, a := range d.Artefacts {
|
||||
f, err := s.artefactFragment(d, a, stage.ArtefactFragmentID(d.ID, i))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.addFragment(f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Site) addFragment(f *Fragment) error {
|
||||
if _, dup := s.fragByID[f.ID]; dup {
|
||||
return fmt.Errorf("stage fragment id %q is not unique", f.ID)
|
||||
}
|
||||
html, err := renderFragment(f.component())
|
||||
if err != nil {
|
||||
return fmt.Errorf("render stage fragment %s: %w", f.ID, err)
|
||||
}
|
||||
f.HTML = html
|
||||
s.Fragments = append(s.Fragments, f)
|
||||
s.fragByID[f.ID] = f
|
||||
s.FragmentsByDoc[f.DocID] = append(s.FragmentsByDoc[f.DocID], f.ID)
|
||||
s.Manifest.Entries = append(s.Manifest.Entries, stage.Entry{
|
||||
ID: f.ID,
|
||||
DocID: f.DocID,
|
||||
Title: f.Title,
|
||||
Kind: f.Kind,
|
||||
File: f.File,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// fragment builds the stage value this fragment renders from.
|
||||
func (f *Fragment) component() stage.Fragment {
|
||||
out := stage.Fragment{
|
||||
ID: f.ID,
|
||||
Kind: f.Kind,
|
||||
Title: f.Title,
|
||||
}
|
||||
out.Subtitle = f.subtitle
|
||||
out.Body = f.body
|
||||
out.Code = f.code
|
||||
out.Src = f.src
|
||||
out.Alt = f.alt
|
||||
out.Links = f.links
|
||||
out.Confidence = f.confidence
|
||||
out.DocURL = f.docURL
|
||||
out.DocTitle = f.docTitle
|
||||
return out
|
||||
}
|
||||
|
||||
func renderFragment(f stage.Fragment) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := templates.FragmentView(f).Render(context.Background(), &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// docFragment is the document's own body, shown in the panel when a reader
|
||||
// asks for the page itself rather than one of its artefacts.
|
||||
func (s *Site) docFragment(d *content.Document) *Fragment {
|
||||
area, _ := content.AreaByKey(d.Area)
|
||||
return &Fragment{
|
||||
ID: stage.DocFragmentID(d.ID),
|
||||
DocID: d.ID,
|
||||
Kind: stage.KindText,
|
||||
Title: d.Title,
|
||||
subtitle: area.Title,
|
||||
body: templ.Raw(d.HTML),
|
||||
confidence: d.Confidence,
|
||||
docURL: d.URL,
|
||||
docTitle: "Read the page",
|
||||
File: "stage/" + stage.DocFragmentID(d.ID) + ".html",
|
||||
}
|
||||
}
|
||||
|
||||
// artefactFragment renders one artefact into the kind the panel understands.
|
||||
func (s *Site) artefactFragment(d *content.Document, a content.Artefact, id string) (*Fragment, error) {
|
||||
ra, err := s.Content.Resolve(d, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := &Fragment{
|
||||
ID: id,
|
||||
DocID: d.ID,
|
||||
Kind: ra.Kind,
|
||||
Title: a.Label,
|
||||
ArtefactURL: a.URL,
|
||||
confidence: d.Confidence,
|
||||
File: "stage/" + id + ".html",
|
||||
}
|
||||
if ra.Target != nil {
|
||||
f.subtitle = ra.Target.Title
|
||||
f.body = templ.Raw(ra.Target.HTML)
|
||||
f.confidence = ra.Target.Confidence
|
||||
f.docURL = ra.Target.URL
|
||||
f.docTitle = "Open " + ra.Target.Title
|
||||
return f, nil
|
||||
}
|
||||
f.subtitle = ra.Rel
|
||||
switch ra.Kind {
|
||||
case stage.KindCode:
|
||||
src, err := os.ReadFile(ra.File)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: read code artefact: %w", d.Path, err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
lang := content.LanguageFor(ra.File)
|
||||
if err := s.Renderer.Highlight(&buf, lang, src); err != nil {
|
||||
return nil, fmt.Errorf("%s: highlight artefact: %w", d.Path, err)
|
||||
}
|
||||
f.code = &stage.Code{Language: lang, Label: path.Base(ra.Rel), HTML: templ.Raw(buf.String())}
|
||||
case stage.KindImage, stage.KindDiagram, stage.KindVideo:
|
||||
f.src = ra.Href
|
||||
f.alt = a.Label
|
||||
default:
|
||||
f.links = []stage.Link{{Label: a.Label, Href: ra.Href, Note: ra.Rel}}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// fragForEntry looks up a build-time fragment by manifest entry.
|
||||
func (s *Site) fragForEntry(e stage.Entry) (*Fragment, bool) {
|
||||
f, ok := s.fragByID[e.ID]
|
||||
return f, ok
|
||||
}
|
||||
|
||||
// FragmentHTML returns the pre-rendered markup for a fragment id. Both drivers
|
||||
// serve these bytes: the route driver as a file, the stream driver as an event.
|
||||
func (s *Site) FragmentHTML(id string) (string, bool) {
|
||||
f, ok := s.fragByID[id]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return f.HTML, true
|
||||
}
|
||||
|
||||
// FragmentsByID is a copy of the fragment index, for tests and the manifest.
|
||||
func (s *Site) FragmentsByID() map[string]*Fragment {
|
||||
out := make(map[string]*Fragment, len(s.fragByID))
|
||||
for k, v := range s.fragByID {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
128
internal/site/platforms.go
Normal file
128
internal/site/platforms.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// PlatformGuide is one `platforms/<name>.yml` file: configuration and public
|
||||
// copy at the same time. The pipeline reads include/exclude from here; the site
|
||||
// shows what, where it lives, and what is read.
|
||||
type PlatformGuide struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
What string `yaml:"what" json:"what"`
|
||||
Fits []string `yaml:"fits" json:"fits"`
|
||||
Base string `yaml:"base" json:"base"`
|
||||
Read []string `yaml:"read" json:"read"`
|
||||
Public bool `yaml:"public" json:"public"`
|
||||
Include struct {
|
||||
Repos any `yaml:"repos" json:"repos"`
|
||||
Paths []string `yaml:"paths" json:"paths"`
|
||||
} `yaml:"include" json:"include"`
|
||||
Exclude struct {
|
||||
Repos []string `yaml:"repos" json:"repos"`
|
||||
Paths []string `yaml:"paths" json:"paths"`
|
||||
} `yaml:"exclude" json:"exclude"`
|
||||
}
|
||||
|
||||
// LoadPlatforms reads every guide. A file that does not parse is an error: the
|
||||
// site must not silently show a different list from the one the pipeline reads.
|
||||
func LoadPlatforms(root string) ([]PlatformGuide, error) {
|
||||
dir := filepath.Join(root, "platforms")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []PlatformGuide
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !(strings.HasSuffix(entry.Name(), ".yml") || strings.HasSuffix(entry.Name(), ".yaml")) {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var guide PlatformGuide
|
||||
if err := yaml.Unmarshal(raw, &guide); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", entry.Name(), err)
|
||||
}
|
||||
if guide.Name == "" || guide.What == "" {
|
||||
return nil, fmt.Errorf("%s: name and what are required", entry.Name())
|
||||
}
|
||||
out = append(out, guide)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RenderPlatforms writes /platforms/index.html and /platforms/index.json.
|
||||
//
|
||||
// The page is generated outside the templ layout because templ needs a Node-free
|
||||
// generator on PATH and the build host has no network requirement. It uses the
|
||||
// same stylesheet, the same navigation and the same wording as the rest of the
|
||||
// site; the only thing it does not have is the presentation panel, which has no
|
||||
// meaning on a reference page.
|
||||
func RenderPlatforms(root, outDir string) (int, error) {
|
||||
guides, err := LoadPlatforms(root)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
target := filepath.Join(outDir, "platforms")
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var body strings.Builder
|
||||
body.WriteString(`<p class="lede">The assistant reads four sources. Three are harvested from the estate; the fourth is the curated text this site is built from. Everything below is the same configuration the pipeline runs.</p>`)
|
||||
for _, guide := range guides {
|
||||
state := "enabled"
|
||||
if !guide.Enabled {
|
||||
state = "disabled"
|
||||
}
|
||||
body.WriteString(`<section class="platform">`)
|
||||
fmt.Fprintf(&body, `<h2>%s <span class="conf">%s</span></h2>`, template.HTMLEscapeString(guide.Name), state)
|
||||
fmt.Fprintf(&body, `<p>%s</p>`, template.HTMLEscapeString(strings.TrimSpace(guide.What)))
|
||||
fmt.Fprintf(&body, `<dl><dt>Where it lives</dt><dd>%s</dd>`, template.HTMLEscapeString(guide.Base))
|
||||
fmt.Fprintf(&body, `<dt>Feeds</dt><dd>%s</dd>`, template.HTMLEscapeString(strings.Join(guide.Fits, ", ")))
|
||||
fmt.Fprintf(&body, `<dt>Reads</dt><dd>%s</dd>`, template.HTMLEscapeString(strings.Join(guide.Read, ", ")))
|
||||
if len(guide.Include.Paths) > 0 {
|
||||
fmt.Fprintf(&body, `<dt>Includes</dt><dd>%s</dd>`, template.HTMLEscapeString(strings.Join(guide.Include.Paths, ", ")))
|
||||
}
|
||||
if len(guide.Exclude.Paths) > 0 {
|
||||
fmt.Fprintf(&body, `<dt>Excludes</dt><dd>%s</dd>`, template.HTMLEscapeString(strings.Join(guide.Exclude.Paths, ", ")))
|
||||
}
|
||||
body.WriteString(`</dl></section>`)
|
||||
}
|
||||
|
||||
page, err := template.New("platforms").Parse(platformsPage)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
file, err := os.Create(filepath.Join(target, "index.html"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
if err := page.Execute(file, map[string]any{"Body": template.HTML(body.String())}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
raw, err := json.MarshalIndent(map[string]any{"version": 1, "platforms": guides}, "", " ")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(target, "index.json"), append(raw, '\n'), 0o644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(guides), nil
|
||||
}
|
||||
|
||||
const platformsPage = `<!doctype html><html lang="en-AU"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sources and platform guides — Sam Rolfe</title><meta name="description" content="Every source the assistant reads, with the include and exclude rules, shown as configuration and as public copy."><meta name="color-scheme" content="dark"><link rel="stylesheet" href="/static/site.css"></head><body><a class="skip-link" href="#main">Skip to content</a><header class="topbar"><a class="brand" href="/"><span class="brand-mark">SR</span> <span class="brand-text">Sam Rolfe <span class="brand-sub">AI · Platform · Edge</span></span></a><nav class="nav nav-rail" aria-label="Sections"><a href="/ai/" class="nav-link"><span class="nav-gutter" aria-hidden="true">AI</span> <span class="nav-title">AI & Agentic Systems</span></a><a href="/platform/" class="nav-link"><span class="nav-gutter" aria-hidden="true">PL</span> <span class="nav-title">Platform & Infrastructure</span></a><a href="/edge/" class="nav-link"><span class="nav-gutter" aria-hidden="true">ED</span> <span class="nav-title">Edge & Devices</span></a><a href="/products/" class="nav-link"><span class="nav-gutter" aria-hidden="true">PR</span> <span class="nav-title">Products</span></a><a href="/history/" class="nav-link"><span class="nav-gutter" aria-hidden="true">HI</span> <span class="nav-title">History</span></a><a href="/about/" class="nav-link"><span class="nav-gutter" aria-hidden="true">AB</span> <span class="nav-title">About</span></a><a href="/evidence/" class="nav-link"><span class="nav-gutter" aria-hidden="true">EV</span> <span class="nav-title">Evidence index</span></a></nav></header><div class="shell"><main id="main" class="main"><nav class="crumbs" aria-label="Breadcrumb"><ol><li><a href="/">Home</a></li><li><span aria-current="page">Sources</span></li></ol></nav><article class="doc"><header class="doc-head"><p class="doc-kicker">AI overlay</p><h1>What the assistant reads</h1><p class="doc-claim">Four sources, one pool. Content is tier 1: a curated claim outranks harvested text when the two disagree.</p></header>{{.Body}}<p class="doc-foot"><a href="/evidence/">Every artefact</a> · <a href="/search">Search the site</a></p></article></main></div><footer class="footer"><p class="footer-line">Twenty-five years shipping software · Melbourne · remote · hybrid · office · travel</p><p class="footer-links"><a href="/evidence/">Evidence index</a> <a href="/search">Search</a></p></footer></body></html>`
|
||||
42
internal/site/platforms_test.go
Normal file
42
internal/site/platforms_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderPlatformsShowsTheGuides(t *testing.T) {
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := t.TempDir()
|
||||
count, err := RenderPlatforms(root, out)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderPlatforms: %v", err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("got %d guides, want 3", count)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(out, "platforms", "index.html"))
|
||||
if err != nil {
|
||||
t.Fatalf("read page: %v", err)
|
||||
}
|
||||
page := strings.ToLower(string(raw))
|
||||
for _, want := range []string{
|
||||
"what the assistant reads",
|
||||
"self-hosted git",
|
||||
"personal knowledge base",
|
||||
"knowledge graph over the notes",
|
||||
"node_modules",
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("page does not contain %q", want)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(out, "platforms", "index.json")); err != nil {
|
||||
t.Errorf("index.json missing: %v", err)
|
||||
}
|
||||
}
|
||||
517
internal/site/site.go
Normal file
517
internal/site/site.go
Normal file
@@ -0,0 +1,517 @@
|
||||
// Package site assembles the Mode A static site out of content/ and knows how
|
||||
// to write it to disk.
|
||||
//
|
||||
// One assembler, two callers: cmd/build writes the whole site to a directory,
|
||||
// and cmd/server uses the same view models for the two things that cannot be
|
||||
// pre-rendered — a keyword search with a query in it, and the streamed stage
|
||||
// demonstration. Everything else in the running server is a file on disk, so a
|
||||
// page is never rendered per request and no page needs an AI service.
|
||||
package site
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
|
||||
)
|
||||
|
||||
// Config is where the site is read from and written to.
|
||||
type Config struct {
|
||||
// Root is the repository root. Static assets are read from it.
|
||||
Root string
|
||||
// ContentDir is the Markdown source of truth. Defaults to Root/content.
|
||||
ContentDir string
|
||||
// OutDir is where Build writes the pre-rendered site. Defaults to Root/dist.
|
||||
OutDir string
|
||||
}
|
||||
|
||||
func (c *Config) defaults() {
|
||||
if c.Root == "" {
|
||||
c.Root = "."
|
||||
}
|
||||
if c.ContentDir == "" {
|
||||
c.ContentDir = filepath.Join(c.Root, "content")
|
||||
}
|
||||
if c.OutDir == "" {
|
||||
c.OutDir = filepath.Join(c.Root, "dist")
|
||||
}
|
||||
}
|
||||
|
||||
// Shell is the chrome every page shares. It needs no content tree, so the
|
||||
// running server can render a search page with only dist/ on disk.
|
||||
type Shell struct {
|
||||
// Year is stamped in the footer.
|
||||
Year int
|
||||
}
|
||||
|
||||
// NewShell returns the chrome for the current year.
|
||||
func NewShell() Shell { return Shell{Year: year()} }
|
||||
|
||||
// Nav builds the six-area navigation, marking area as current.
|
||||
func (Shell) Nav(area string) []templates.NavItem {
|
||||
items := make([]templates.NavItem, 0, len(content.Areas))
|
||||
for _, a := range content.Areas {
|
||||
items = append(items, templates.NavItem{
|
||||
Title: a.Title,
|
||||
Href: "/" + a.Key + "/",
|
||||
Area: a.Key,
|
||||
Current: a.Key == area,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// AreaLinks is the six areas as plain links. The search page offers them when a
|
||||
// query matches nothing, so an empty result is still a way into the site.
|
||||
func (Shell) AreaLinks() []templates.LinkView {
|
||||
out := make([]templates.LinkView, 0, len(content.Areas))
|
||||
for _, a := range content.Areas {
|
||||
out = append(out, templates.LinkView{Label: a.Title, Href: "/" + a.Key + "/"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RouteBinding is the Mode A driver: one pre-rendered fragment per click.
|
||||
func (Shell) RouteBinding() stage.Binding {
|
||||
return stage.RouteDriver{BaseURL: "/stage"}.Bind()
|
||||
}
|
||||
|
||||
// StreamBinding is the Mode B driver: fragments pushed over SSE.
|
||||
func (Shell) StreamBinding(endpoint string) stage.Binding {
|
||||
return stage.SSEDriver{Endpoint: endpoint}.Bind()
|
||||
}
|
||||
|
||||
// LayoutOpts are the per-page chrome values.
|
||||
type LayoutOpts struct {
|
||||
Title string
|
||||
Description string
|
||||
Area string
|
||||
Crumbs []templates.Crumb
|
||||
Binding stage.Binding
|
||||
}
|
||||
|
||||
// Layout builds the chrome for one page. It never looks at the stage driver
|
||||
// beyond handing the binding to the template.
|
||||
func (sh Shell) Layout(o LayoutOpts) templates.LayoutView {
|
||||
return templates.LayoutView{
|
||||
Title: o.Title,
|
||||
Description: o.Description,
|
||||
Area: o.Area,
|
||||
Nav: sh.Nav(o.Area),
|
||||
Crumbs: o.Crumbs,
|
||||
Stage: o.Binding,
|
||||
Year: sh.Year,
|
||||
}
|
||||
}
|
||||
|
||||
// SearchView builds the search page. Search is the one Mode A page that reads a
|
||||
// query, so the server renders it; with JavaScript off the form posts and the
|
||||
// same page comes back with results.
|
||||
func (sh Shell) SearchView(indexed int, q string, results []search.Result, failed bool) templates.SearchView {
|
||||
return templates.SearchView{
|
||||
Layout: sh.Layout(LayoutOpts{
|
||||
Title: "Search — Sam Rolfe",
|
||||
Description: "Keyword search across every page of this site. No model, no network call.",
|
||||
Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Search"}},
|
||||
Binding: sh.RouteBinding(),
|
||||
}),
|
||||
Query: q,
|
||||
Results: results,
|
||||
Indexed: indexed,
|
||||
Failed: failed,
|
||||
Areas: sh.AreaLinks(),
|
||||
}
|
||||
}
|
||||
|
||||
// NotFoundPage is the 404, pre-rendered at build time and also served live.
|
||||
func (sh Shell) NotFoundPage() templates.NotFoundView {
|
||||
return templates.NotFoundView{
|
||||
Layout: sh.Layout(LayoutOpts{
|
||||
Title: "Page not found — Sam Rolfe",
|
||||
Description: "That page is not here. The six areas and the evidence index are the reliable ways in.",
|
||||
Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Not found"}},
|
||||
Binding: sh.RouteBinding(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Site is the content tree plus everything derived from it.
|
||||
type Site struct {
|
||||
Shell
|
||||
Config Config
|
||||
Content *content.Set
|
||||
Renderer *content.Renderer
|
||||
|
||||
// Fragments are the pre-rendered stage fragments, in manifest order. The
|
||||
// first len(content.Areas) are the six area landing pages, which is the
|
||||
// default stream.
|
||||
Fragments []*Fragment
|
||||
// Manifest indexes the same fragments the way the server sees them.
|
||||
Manifest *stage.Manifest
|
||||
// FragmentsByDoc lists fragment ids per document, in reading order.
|
||||
FragmentsByDoc map[string][]string
|
||||
|
||||
fragByID map[string]*Fragment
|
||||
}
|
||||
|
||||
// New loads content/ and renders every page and stage fragment in memory. It
|
||||
// touches no network and starts no service.
|
||||
func New(cfg Config) (*Site, error) {
|
||||
cfg.defaults()
|
||||
set, err := content.Load(cfg.ContentDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := content.NewRenderer(set)
|
||||
if err := r.Render(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Site{
|
||||
Shell: NewShell(),
|
||||
Config: cfg,
|
||||
Content: set,
|
||||
Renderer: r,
|
||||
Manifest: &stage.Manifest{},
|
||||
FragmentsByDoc: map[string][]string{},
|
||||
fragByID: map[string]*Fragment{},
|
||||
}
|
||||
if err := s.buildFragments(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// PublicDocs returns the published documents: public: true, in navigation order.
|
||||
func (s *Site) PublicDocs() []*content.Document {
|
||||
out := make([]*content.Document, 0, len(s.Content.Docs))
|
||||
for _, d := range s.Content.Docs {
|
||||
if d.Public {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PublicInArea returns the published documents of one area, L1 first.
|
||||
func (s *Site) PublicInArea(area string) []*content.Document {
|
||||
var out []*content.Document
|
||||
for _, d := range s.PublicDocs() {
|
||||
if d.Area == area {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pageCount counts published pages in an area.
|
||||
func (s *Site) pageCount(area string) int { return len(s.PublicInArea(area)) }
|
||||
|
||||
// artefactCount counts every resolved artefact on the site.
|
||||
func (s *Site) artefactCount() int {
|
||||
n := 0
|
||||
for _, d := range s.PublicDocs() {
|
||||
n += len(d.Artefacts)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Home is the landing page view: the ninety-second read.
|
||||
func (s *Site) Home() templates.HomeView {
|
||||
cardArea := func(a content.Area) templates.PillarCardView {
|
||||
card := templates.PillarCardView{
|
||||
Area: a,
|
||||
Title: a.Title,
|
||||
Claim: a.Blurb,
|
||||
Href: "/" + a.Key + "/",
|
||||
Level: 1,
|
||||
Pages: s.pageCount(a.Key),
|
||||
Metrics: []templates.MetricView{},
|
||||
Tech: []string{},
|
||||
}
|
||||
if d, ok := s.Content.AreaLanding(a.Key); ok {
|
||||
card.Claim = d.Claim
|
||||
card.Confidence = d.Confidence
|
||||
card.Title = d.Title
|
||||
for _, m := range d.Metrics {
|
||||
card.Metrics = append(card.Metrics, templates.MetricView{Label: m.Label, Value: content.MetricValue(m.Value)})
|
||||
}
|
||||
card.Tech = d.Tech
|
||||
}
|
||||
return card
|
||||
}
|
||||
cards := make([]templates.PillarCardView, 0, len(content.Areas))
|
||||
for _, a := range content.Areas {
|
||||
cards = append(cards, cardArea(a))
|
||||
}
|
||||
return templates.HomeView{
|
||||
Layout: s.Layout(LayoutOpts{
|
||||
Title: "Sam Rolfe — AI systems, the platforms under them, the hardware at the edge",
|
||||
Description: content.Positioning + " " + content.SupportLine,
|
||||
Crumbs: []templates.Crumb{{Label: "Home"}},
|
||||
Binding: s.RouteBinding(),
|
||||
}),
|
||||
Positioning: content.Positioning,
|
||||
Support: content.SupportLine,
|
||||
Location: content.Location,
|
||||
Cards: cards,
|
||||
Artifacts: s.artefactCount(),
|
||||
Pages: len(s.PublicDocs()),
|
||||
Areas: len(content.Areas),
|
||||
}
|
||||
}
|
||||
|
||||
// Doc builds the view for one document at any depth.
|
||||
func (s *Site) Doc(d *content.Document) templates.DocView {
|
||||
area, _ := content.AreaByKey(d.Area)
|
||||
|
||||
view := templates.DocView{
|
||||
Layout: s.Layout(LayoutOpts{
|
||||
Title: d.Title + " — Sam Rolfe",
|
||||
Description: content.Truncate(d.Claim, 160),
|
||||
Area: d.Area,
|
||||
Crumbs: s.crumbs(d),
|
||||
Binding: s.RouteBinding(),
|
||||
}),
|
||||
Doc: d,
|
||||
Role: area.Role,
|
||||
Body: templates.MarkdownBody(d.HTML),
|
||||
Metrics: make([]templates.MetricView, 0, len(d.Metrics)),
|
||||
Tech: d.Tech,
|
||||
Evidence: s.evidence(d),
|
||||
Backlinks: s.backlinks(d),
|
||||
SubPages: s.subPages(d),
|
||||
}
|
||||
for _, m := range d.Metrics {
|
||||
view.Metrics = append(view.Metrics, templates.MetricView{Label: m.Label, Value: content.MetricValue(m.Value)})
|
||||
}
|
||||
if d.Area == "history" && d.Level <= 2 {
|
||||
view.Timeline = s.timeline(d.Area)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (s *Site) crumbs(d *content.Document) []templates.Crumb {
|
||||
area, _ := content.AreaByKey(d.Area)
|
||||
out := []templates.Crumb{
|
||||
{Label: "Home", Href: "/"},
|
||||
{Label: area.Title, Href: "/" + d.Area + "/"},
|
||||
}
|
||||
if d.Level > 1 {
|
||||
out = append(out, templates.Crumb{Label: d.Title, Href: d.URL})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// evidence turns each artefact into a row that loads the artefact into the
|
||||
// stage panel and is also a working link.
|
||||
func (s *Site) evidence(d *content.Document) []templates.LinkView {
|
||||
out := make([]templates.LinkView, 0, len(d.Artefacts))
|
||||
ids := s.FragmentsByDoc[d.ID]
|
||||
for _, a := range d.Artefacts {
|
||||
ra, err := s.Content.Resolve(d, a)
|
||||
if err != nil {
|
||||
// Load already rejected dangling artefacts; keep going rather than
|
||||
// render a broken link.
|
||||
continue
|
||||
}
|
||||
conf := d.Confidence
|
||||
if ra.Target != nil {
|
||||
conf = ra.Target.Confidence
|
||||
}
|
||||
link := templates.LinkView{
|
||||
Label: a.Label,
|
||||
Href: ra.Href,
|
||||
Kind: ra.Kind,
|
||||
Confidence: conf,
|
||||
}
|
||||
for _, id := range ids {
|
||||
if f, ok := s.fragByID[id]; ok && f.ArtefactURL == a.URL {
|
||||
link.HXGet = s.RouteFragmentURL(id)
|
||||
}
|
||||
}
|
||||
out = append(out, link)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Site) backlinks(d *content.Document) []templates.LinkView {
|
||||
out := make([]templates.LinkView, 0, len(d.Backlinks))
|
||||
for _, b := range d.Backlinks {
|
||||
out = append(out, templates.LinkView{Label: b.Title, Href: b.URL})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// subPages is the drill-down list under a page. L1 lists the capability pages
|
||||
// in its area; every other level lists artefacts in the area that this page
|
||||
// does not already cite, so no page can become unreachable.
|
||||
func (s *Site) subPages(d *content.Document) []templates.LinkView {
|
||||
cited := map[string]bool{}
|
||||
for _, a := range d.Artefacts {
|
||||
if ra, err := s.Content.Resolve(d, a); err == nil && ra.Target != nil {
|
||||
cited[ra.Target.ID] = true
|
||||
}
|
||||
}
|
||||
var out []templates.LinkView
|
||||
for _, other := range s.PublicInArea(d.Area) {
|
||||
if other.ID == d.ID || cited[other.ID] {
|
||||
continue
|
||||
}
|
||||
if d.Level == 1 && other.Level != 2 {
|
||||
continue
|
||||
}
|
||||
if d.Level > 1 && other.Level < 3 {
|
||||
continue
|
||||
}
|
||||
out = append(out, templates.LinkView{
|
||||
Label: other.Title,
|
||||
Href: other.URL,
|
||||
Kind: other.Confidence,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// timeline orders an area's deeper pages by period, newest first.
|
||||
func (s *Site) timeline(area string) []templates.TimelineItem {
|
||||
var items []templates.TimelineItem
|
||||
for _, d := range s.PublicInArea(area) {
|
||||
if d.Level < 2 {
|
||||
continue
|
||||
}
|
||||
items = append(items, templates.TimelineItem{
|
||||
Period: d.Period,
|
||||
Title: d.Title,
|
||||
Href: d.URL,
|
||||
Confidence: d.Confidence,
|
||||
Claim: d.Claim,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// Evidence is the one-page index of every artefact on the site.
|
||||
func (s *Site) Evidence() templates.EvidenceView {
|
||||
var groups []templates.EvidenceGroup
|
||||
total := 0
|
||||
for _, a := range content.Areas {
|
||||
group := templates.EvidenceGroup{AreaTitle: a.Title, Href: "/" + a.Key + "/"}
|
||||
for _, d := range s.PublicInArea(a.Key) {
|
||||
for _, l := range s.evidence(d) {
|
||||
group.Rows = append(group.Rows, templates.EvidenceRow{
|
||||
Label: d.Title + " · " + l.Label,
|
||||
Href: l.Href,
|
||||
HXGet: l.HXGet,
|
||||
Kind: l.Kind,
|
||||
Confidence: l.Confidence,
|
||||
})
|
||||
total++
|
||||
}
|
||||
}
|
||||
if len(group.Rows) > 0 {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
return templates.EvidenceView{
|
||||
Layout: s.Layout(LayoutOpts{
|
||||
Title: "Evidence index — Sam Rolfe",
|
||||
Description: "Every artefact this site claims, on one page.",
|
||||
Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Evidence"}},
|
||||
Binding: s.RouteBinding(),
|
||||
}),
|
||||
Groups: groups,
|
||||
Total: total,
|
||||
}
|
||||
}
|
||||
|
||||
// StreamKeys is the fragment sequence the streaming panel demonstrates: the six
|
||||
// area landing fragments, in navigation order. No model is involved.
|
||||
func (s *Site) StreamKeys() []string {
|
||||
n := len(content.Areas)
|
||||
if n > len(s.Fragments) {
|
||||
n = len(s.Fragments)
|
||||
}
|
||||
keys := make([]string, 0, n)
|
||||
for _, f := range s.Fragments[:n] {
|
||||
keys = append(keys, f.ID)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Stream is the page that re-points the panel at an event stream. The layout is
|
||||
// the same as every other page; only the binding differs.
|
||||
func (s *Site) Stream(endpoint string) templates.StreamView {
|
||||
keys := s.StreamKeys()
|
||||
links := make([]templates.LinkView, 0, len(keys))
|
||||
for _, id := range keys {
|
||||
if f, ok := s.fragByID[id]; ok {
|
||||
links = append(links, templates.LinkView{
|
||||
Label: f.Title,
|
||||
Href: s.DocURL(f.DocID),
|
||||
HXGet: s.RouteFragmentURL(id),
|
||||
Kind: f.Kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
return templates.StreamView{
|
||||
Layout: s.Layout(LayoutOpts{
|
||||
Title: "Streaming panel — Sam Rolfe",
|
||||
Description: "The presentation panel bound to an event stream instead of pre-rendered files.",
|
||||
Crumbs: []templates.Crumb{{Label: "Home", Href: "/"}, {Label: "Streaming panel"}},
|
||||
Binding: s.StreamBinding(endpoint),
|
||||
}),
|
||||
Frames: len(links),
|
||||
Fragment: links,
|
||||
}
|
||||
}
|
||||
|
||||
// DocURL is the published URL for a document id.
|
||||
func (s *Site) DocURL(id string) string {
|
||||
if d, ok := s.Content.ByID(id); ok {
|
||||
return d.URL
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// RouteFragmentURL is where the route driver's fragment file is served from.
|
||||
func (s *Site) RouteFragmentURL(id string) string {
|
||||
return stage.RouteDriver{BaseURL: "/stage"}.FragmentURL(id)
|
||||
}
|
||||
|
||||
// Fragment is one pre-rendered stage fragment.
|
||||
type Fragment struct {
|
||||
ID string
|
||||
DocID string
|
||||
Kind string
|
||||
Title string
|
||||
// ArtefactURL is the artefact this fragment was built from, if any. It is
|
||||
// how an evidence row finds the fragment that belongs to it.
|
||||
ArtefactURL string
|
||||
// File is relative to the published root, e.g. stage/doc-edge-overview.html.
|
||||
File string
|
||||
// HTML is the rendered fragment markup, written to File at build time.
|
||||
HTML string
|
||||
|
||||
subtitle string
|
||||
body templ.Component
|
||||
code *stage.Code
|
||||
src string
|
||||
alt string
|
||||
links []stage.Link
|
||||
confidence string
|
||||
docURL string
|
||||
docTitle string
|
||||
}
|
||||
|
||||
// Fragment returns a pre-rendered fragment by id.
|
||||
func (s *Site) Fragment(id string) (*Fragment, bool) {
|
||||
f, ok := s.fragByID[id]
|
||||
return f, ok
|
||||
}
|
||||
|
||||
func year() int { return time.Now().Year() }
|
||||
297
internal/site/site_test.go
Normal file
297
internal/site/site_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
|
||||
)
|
||||
|
||||
// newSite loads the repository's own content. If this fails, every other test
|
||||
// here would be testing nothing.
|
||||
func newSite(t *testing.T) *Site {
|
||||
t.Helper()
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := New(Config{Root: root, OutDir: filepath.Join(t.TempDir(), "dist")})
|
||||
if err != nil {
|
||||
t.Fatalf("load site: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestSixAreasHaveLandingPages is the DoD line "L1 (6) and L2 (6) pages
|
||||
// complete": every area has a published level 1 page and at least one level 2
|
||||
// page, and every area's L1 fragment is the default stream.
|
||||
func TestSixAreasHaveLandingPages(t *testing.T) {
|
||||
s := newSite(t)
|
||||
if len(content.Areas) != 6 {
|
||||
t.Fatalf("%d areas, want 6", len(content.Areas))
|
||||
}
|
||||
if got := len(s.StreamKeys()); got != 6 {
|
||||
t.Fatalf("stream keys = %d, want the six area landing fragments", got)
|
||||
}
|
||||
for i, a := range content.Areas {
|
||||
l1, ok := s.Content.AreaLanding(a.Key)
|
||||
if !ok {
|
||||
t.Errorf("%s: no level 1 page", a.Key)
|
||||
continue
|
||||
}
|
||||
if !l1.Public {
|
||||
t.Errorf("%s: level 1 page %s is not public", a.Key, l1.ID)
|
||||
}
|
||||
if f, ok := s.Fragment(s.StreamKeys()[i]); !ok {
|
||||
t.Errorf("%s: stream key %s is not a fragment", a.Key, s.StreamKeys()[i])
|
||||
} else if f.DocID != l1.ID {
|
||||
t.Errorf("%s: stream fragment %s belongs to %s, want %s", a.Key, f.ID, f.DocID, l1.ID)
|
||||
}
|
||||
levels := map[int]int{}
|
||||
for _, d := range s.PublicInArea(a.Key) {
|
||||
levels[d.Level]++
|
||||
}
|
||||
if levels[2] == 0 {
|
||||
t.Errorf("%s: no level 2 pages", a.Key)
|
||||
}
|
||||
if levels[3] == 0 {
|
||||
t.Errorf("%s: no level 3 pages", a.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryArtefactIsReachableWithinThreeClicks is acceptance test C4 against
|
||||
// the view models: landing → area → artefact page is three clicks, and every
|
||||
// artefact has a working link.
|
||||
func TestEveryArtefactIsReachableWithinThreeClicks(t *testing.T) {
|
||||
s := newSite(t)
|
||||
for _, area := range content.Areas {
|
||||
l1, ok := s.Content.AreaLanding(area.Key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
linkedFromL1 := map[string]bool{}
|
||||
for _, l := range s.Doc(l1).SubPages {
|
||||
linkedFromL1[l.Href] = true
|
||||
}
|
||||
for _, l := range s.Doc(l1).Evidence {
|
||||
linkedFromL1[l.Href] = true
|
||||
}
|
||||
|
||||
for _, d := range s.PublicInArea(area.Key) {
|
||||
if d.Level == 1 {
|
||||
continue
|
||||
}
|
||||
if d.Level == 2 {
|
||||
if !linkedFromL1[d.URL] {
|
||||
t.Errorf("%s/level 2 page %s is not linked from the area landing page", area.Key, d.URL)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Level 3: linked from an L2 page's drill-down or evidence table.
|
||||
reachable := false
|
||||
for _, l2 := range s.PublicInArea(area.Key) {
|
||||
if l2.Level != 2 {
|
||||
continue
|
||||
}
|
||||
view := s.Doc(l2)
|
||||
for _, l := range view.SubPages {
|
||||
if l.Href == d.URL {
|
||||
reachable = true
|
||||
}
|
||||
}
|
||||
for _, l := range view.Evidence {
|
||||
if l.Href == d.URL {
|
||||
reachable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reachable {
|
||||
t.Errorf("%s/level 3 page %s is not linked from any level 2 page", area.Key, d.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryArtefactHasAStageFragment keeps the panel honest: if an evidence row
|
||||
// offers hx-get, the fragment it names must exist.
|
||||
func TestEveryArtefactHasAStageFragment(t *testing.T) {
|
||||
s := newSite(t)
|
||||
for _, d := range s.PublicDocs() {
|
||||
for _, l := range s.evidence(d) {
|
||||
if l.HXGet == "" {
|
||||
t.Errorf("%s: evidence %q has no stage fragment", d.ID, l.Label)
|
||||
continue
|
||||
}
|
||||
id := strings.TrimPrefix(l.HXGet, "/stage/")
|
||||
if _, ok := s.Fragment(id); !ok {
|
||||
t.Errorf("%s: evidence %q points at missing fragment %q", d.ID, l.Label, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildWritesEveryPublishedPage runs the real build once and checks the
|
||||
// files the server depends on.
|
||||
func TestBuildWritesEveryPublishedPage(t *testing.T) {
|
||||
s := newSite(t)
|
||||
if err := s.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
for _, url := range s.PublishedPages() {
|
||||
file := s.filePathForURL(url)
|
||||
if !strings.HasPrefix(file, s.Config.OutDir) {
|
||||
t.Errorf("%s: output escapes the build directory", url)
|
||||
}
|
||||
if _, err := os.ReadFile(file); err != nil {
|
||||
t.Errorf("%s: %v", url, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{IndexFile, ManifestFile, BuildInfoFile, "static/site.css", "static/htmx.min.js", "static/chroma.css"} {
|
||||
if _, err := os.ReadFile(filepath.Join(s.Config.OutDir, filepath.FromSlash(name))); err != nil {
|
||||
t.Errorf("build output %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
info, err := LoadBuildInfo(s.Config.OutDir)
|
||||
if err != nil {
|
||||
t.Fatalf("build info: %v", err)
|
||||
}
|
||||
if info.Pages == 0 || info.Fragments == 0 || info.Indexed == 0 {
|
||||
t.Errorf("build info is empty: %+v", info)
|
||||
}
|
||||
// The manifest order is the server's default stream: six document
|
||||
// fragments first, and they are the six area landings.
|
||||
if len(s.Manifest.Entries) < 6 {
|
||||
t.Fatalf("manifest has %d entries", len(s.Manifest.Entries))
|
||||
}
|
||||
for _, e := range s.Manifest.Entries[:6] {
|
||||
if !strings.HasPrefix(e.ID, "doc-") {
|
||||
t.Errorf("manifest entry %s is not a document fragment; the default stream must be the six areas", e.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// copyTree copies a directory tree. The freshness test needs its own content
|
||||
// tree so it can change a page without touching the repository.
|
||||
func copyTree(src, dst string) error {
|
||||
return filepath.WalkDir(src, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, raw, 0o644)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSearchIndexFollowsContent is the AUD-151 freshness line: a content change
|
||||
// followed by a build is reflected in search. The deploy path is exactly this —
|
||||
// deploy/deploy.sh runs `make dist` before it stages a release, and the build
|
||||
// rewrites dist/search.db from content/ every time (search.Build removes the old
|
||||
// file), so search can never serve an index from a previous content tree.
|
||||
func TestSearchIndexFollowsContent(t *testing.T) {
|
||||
root, err := filepath.Abs("../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
contentDir := filepath.Join(dir, "content")
|
||||
if err := copyTree(filepath.Join(root, "content"), contentDir); err != nil {
|
||||
t.Fatalf("copy content: %v", err)
|
||||
}
|
||||
out := filepath.Join(dir, "dist")
|
||||
|
||||
build := func() {
|
||||
t.Helper()
|
||||
s, err := New(Config{Root: root, ContentDir: contentDir, OutDir: out})
|
||||
if err != nil {
|
||||
t.Fatalf("load site: %v", err)
|
||||
}
|
||||
if err := s.Build(); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
}
|
||||
query := func(q string) []search.Result {
|
||||
t.Helper()
|
||||
idx, err := search.Open(filepath.Join(out, IndexFile))
|
||||
if err != nil {
|
||||
t.Fatalf("open index: %v", err)
|
||||
}
|
||||
defer idx.Close()
|
||||
results, err := idx.Query(q, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("query %q: %v", q, err)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
const token = "kumquatprotocol"
|
||||
build()
|
||||
if got := query(token); len(got) != 0 {
|
||||
t.Fatalf("token is already indexed in %d results; the test proves nothing", len(got))
|
||||
}
|
||||
|
||||
page := filepath.Join(contentDir, "edge", "edge-fleet.md")
|
||||
raw, err := os.ReadFile(page)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
added := string(raw) + "\n\nA " + token + " harness keeps the nodes in step.\n"
|
||||
if err := os.WriteFile(page, []byte(added), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
build()
|
||||
results := query(token)
|
||||
if len(results) == 0 {
|
||||
t.Fatal("content change is not in the rebuilt index")
|
||||
}
|
||||
if results[0].URL != "/edge/edge-fleet/" {
|
||||
t.Errorf("token found at %s, want /edge/edge-fleet/", results[0].URL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImagesReserveTheirSpace is the layout-shift guard: every image a content
|
||||
// page carries must declare its intrinsic size and load lazily, so a page of
|
||||
// diagrams and screenshots never reflows as the files arrive.
|
||||
func TestImagesReserveTheirSpace(t *testing.T) {
|
||||
s := newSite(t)
|
||||
seen := 0
|
||||
for _, d := range s.Content.Docs {
|
||||
html := d.HTML
|
||||
for i := 0; ; i++ {
|
||||
start := strings.Index(html, "<img ")
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(html[start:], ">")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
tag := html[start : start+end+1]
|
||||
html = html[start+end+1:]
|
||||
seen++
|
||||
for _, want := range []string{`width="`, `height="`, `loading="lazy"`, `alt="`} {
|
||||
if !strings.Contains(tag, want) {
|
||||
t.Errorf("%s: image tag missing %s: %s", d.Path, want, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if seen == 0 {
|
||||
t.Fatal("no images found in content; the guard is not exercising anything")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user