18 Commits

Author SHA1 Message Date
Sam Rolfe
447a0523fb deploy: publish on the LAN address so Caddy on .35 can reach it
127.0.0.1:8096 is unreachable from .35, which is where Caddy runs. Every other
service on .13 is published this way (8095, 8092, 3909, 4200) and proxied by the
same Caddy. Port is LAN-only; Caddy remains the sole public listener.
2026-10-26 22:38:54 +11:00
Paperclip Builder
0b910d216e ingestion: group oversize and binary rejections in the report 2026-10-26 22:36:34 +11:00
Paperclip Builder
720feddfcc ingestion: strip repeated front-matter blocks from notes 2026-10-26 22:33:57 +11:00
Paperclip Builder
4896e18a41 ingestion: strip front-matter from harvested markdown 2026-10-26 22:32:14 +11:00
Paperclip Builder
da674b5555 ingestion: skip empty files instead of failing the run 2026-10-26 22:30:36 +11:00
Paperclip Builder
4e52015fd2 ingestion: report per-platform added/updated correctly; qdrant compose subnet 2026-10-26 22:28:57 +11:00
Paperclip Builder
e3f54f58c1 Phase 4 ingestion: exclusion/provenance/incremental/graph tests 2026-10-26 22:28:21 +11:00
Paperclip Builder
1e46ec009a WIP Phase 4 ingestion: platform guides, exclusion gate, gitea/vault/graph harvest, payload provenance 2026-10-26 22:26:45 +11:00
Sam Rolfe
5c0622c1b1 Ingestion redesigned: harvest from the estate, not just content/
- docs/phase-4-corpus.md rewritten: Gitea (all repos), vault via Gitea, Graphify
  graph, curated content as tier 1. Platform guides are config and public copy.
- docs/phase-5-ai-overlay.md: citations to originals, tier preference, live
  source readout.
- Board: AUD-144 (index content/ only) retired; AUD-147 replaces it, blocked by
  AUD-141 and AUD-142. AUD-145 relinked to AUD-143 and AUD-147.
2026-10-26 22:16:33 +11:00
9fae1c7dd8 Merge Phase 4: corpus and retrieval
Chunks, deterministic ids, payload filter, FTS5 keyword index, incremental mode,
freshness audit, Qdrant container shape, measured memory. Deploying Qdrant to
the runtime host and the Prefect deployment are still outstanding and belong to
the runtime host, not this repository.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-26 21:04:59 +11:00
f9dc574172 Phase 4: corpus and retrieval — chunks, Qdrant, FTS5, freshness audit
Satisfies docs/phase-4-corpus.md. content/ stays the single source of truth:
one pass over it writes both indexes, so the static pages and the retrieval
corpus cannot drift.

- corpus/: stdlib-only pipeline. validate -> chunk -> enrich -> embed -> upsert.
  Chunking is block-based, so a boundary cannot fall inside a fenced block, a
  table or front-matter (verified structurally, not by inspection).
- Deterministic ids: sha256(source_path + chunk_index + content_hash), rendered
  as a UUID. Re-running updates points; re-chunking deletes the old ones by
  source filter, so duplicates and orphans are both impossible.
- Incremental by content hash against a SQLite manifest. A provider, dimension
  or chunker change is refused instead of mixed: it needs a rebuild.
- Payloads per section 4.2 plus provenance, validated before any vector is
  written; a missing field fails the run and names the document and chunk.
- SQLite FTS5 over chunks (the keyword half of hybrid retrieval, decision D10)
  with area/confidence filters and a `terms` column so front-matter tech and
  tags are findable.
- Freshness audit: stale documents, due soon, index drift, contract drift;
  optional Apprise alert; exit code for Prefect.
- Embeddings behind one interface: omniroute (OpenAI-compatible, retries, key
  from the environment only) and local-hashing (offline placeholder). The
  provider decision is still Sam's; the pipeline does not depend on it.
- deploy/qdrant.yml: mem_limit = memswap_limit = 400m, loopback only.
- docs/phase-4-notes.md: operator manual, DoD evidence, measured memory.
- 76 unit tests, no container and no network.

Measured, Qdrant 1.20.0 under a 400 m cap at 1024 dimensions: 500 / 5,000 /
20,000 chunks settle at 128 / 138 / 151 MiB, peaking at 149 / 305 / 292 MiB
during ingest. Qdrant's default settings are OOM-killed between 5,000 and
20,000, so the collection is created with HNSW and payloads on disk; recorded in
reference/stack.md with the ceiling and the escalation order.

Note: the `make corpus*` targets were swept into the Phase 3 deploy commit
24590cc by concurrent work in this tree; the Makefile content is correct, and
this commit contains the pipeline they call.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-26 21:04:52 +11:00
24590cc351 deploy: static binary and a compose project name that does not collide
Two failures from the first real deploy, both repo-side:

1. The container logged "exec /usr/bin/resume-server: no such file or
   directory" and restarted until the health gate failed. The build host has
   cgo enabled, so the binary carried the nix glibc interpreter, and the
   distroless/static image has no dynamic loader. Every SQLite path already
   uses pure-Go modernc.org/sqlite, so build with CGO_ENABLED=0 and ship a
   static binary.

2. Compose warned about orphan containers ai-resume-backend-1 and
   ai-resume-db-1: /home/sam/deployment/ai-resume on .13 already owns the
   `ai-resume` compose project (on the ai-mesh network). Sharing the name makes
   each stack visible as orphans to the other, so a `compose down
   --remove-orphans` in either could stop the other. Run the site as project
   `ai-resume-site`; the image tag stays ai-resume:local and the port binding,
   mem_limit and memswap_limit are untouched.
2026-10-26 20:53:25 +11:00
c95e5361e3 deploy: pin the compose network to an explicit /28
The first deploy failed before the health gate:

    Network ai-resume_default Error Error response from daemon:
    all predefined address pools have been fully subnetted

.13's docker daemon has 31 bridge networks already, which exhausts its
default-address-pools, so any new implicit compose network fails to create.
Pinning 10.89.0.0/28 (no route, bridge or LAN overlap on .13) makes this
project independent of that host condition without touching another project,
container or the daemon config. mem_limit, memswap_limit and the 127.0.0.1
port binding are unchanged.
2026-10-26 20:51:17 +11:00
2906683f44 deploy: add deploy.sh (release, rollback, status) for Phase 3 section 3.1
The release directory under /home/sam/deployment/resume is the rollback unit;
deploy.sh stages releases/<timestamp>-<sha>/, flips `current` atomically,
builds the image from that directory and gates on /healthz, restoring the
previous release on any failure.

The rehearsal recipe in the header is kept as comments only; the pasted spec
lost the comment prefixes on the multi-line printf, which would have made the
script exit 1 on startup. It now uses \n escapes on one commented line.
2026-10-26 20:49:53 +11:00
c2b4e33897 Merge Phase 2: Mode A static site
DoD satisfied (evidence in AUD-142): A1 with no AI services, one binary,
deterministic templ generate, 6 L1 + 6 L2 + artefact pages, FTS5 search,
Lighthouse 100/100 mobile and desktop, <100ms first byte, stage panel driven
by route or stream, print legible.
2026-10-26 20:43:03 +11:00
84fc585c91 Phase 2: Mode A static site — build, serve, search, stage panel
One Go binary serves a pre-rendered site. No Node, no bundler, no node_modules:
htmx and the SSE extension are vendored files, Markdown and code highlighting
run at build time (goldmark + chroma), and the server only writes files.

Implements docs/phase-2-static-site.md:

  2.1 service skeleton   cmd/build (content -> dist), cmd/server (dist -> HTTP),
                         env-configured, 127.0.0.1:8096, /healthz with separate
                         static / ai / search readiness, --health-check probe,
                         deploy/compose.yml + deploy/Dockerfile at 120m
  2.2 rendering          templ components (Layout, Nav, PillarCard, Stage,
                         EvidenceTable, CodeBlock, Timeline, Footer);
                         `make generate` is a build step and is byte-stable
  2.3 navigation         six-area rail, <details> disclosure under 900px,
                         breadcrumbs, aria-current, L1/L2/L3 with artefact pages
  2.4 stage panel        internal/stage owns the contract; RouteDriver (fragment
                         swap) and SSEDriver (event stream) differ only in the
                         Binding, never in layout. fragment ids live in the
                         contract so route files, links and streamed frames agree
  2.5 search             SQLite FTS5 over titles and body, rebuilt by the build;
                         one handler returns a page without HTMX, a fragment with
                         it. Visitors' FTS operators are never honoured
  2.6 quality floor      print stylesheet, JS-off navigation, focus states,
                         semantic landmarks, dark default; security headers

Definition of Done evidence (see issue AUD-142):
  A1  all pages 200 in a network namespace with no external route
  one binary serves the site; make dist writes 45 pages, 86 fragments, 40 docs
  templ generate is deterministic (md5 identical across runs)
  6 L1 + 6 L2 pages, artefact pages reachable in <= 3 clicks
  FTS5 returns sensible hits for zigbee / signage / routing (tested)
  Lighthouse 100 performance and 100 accessibility, mobile and desktop
  first byte well inside 100ms over loopback (test)
  panel renders from a route driver and re-points at the stream untouched
  print legible: /edge/ renders to a 3-page PDF with the print stylesheet

Also fixes found while finishing the build:
  - reject "." / ".." path segments before ServeMux redirects them into a file
    outside the asset directory (/static/../build.json now 404, was 307->200)
  - make test is the real gate: go test ./... plus the validator self-test
2026-10-26 20:42:52 +11:00
fd9974d07e Merge branch 'main' into feat/phase-2-static-site 2026-10-26 20:01:38 +11:00
ea2c722816 WIP Phase 2: content loader, stage contract, search index, templ components 2026-10-26 20:01:38 +11:00
72 changed files with 15130 additions and 58 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ __pycache__/
*.pyc
# build output
/bin/
/dist/
/current/
*.test

115
Makefile
View File

@@ -1,19 +1,124 @@
# 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-offline corpus-audit corpus-verify \
corpus-demo corpus-test corpus-graph corpus-scan corpus-sources
help:
@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 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 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 pool from the estate (drops both collections)"
@echo "make corpus-offline rebuild content/ only, no Gitea (recovery and CI)"
@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-graph Q= query graph nodes and edges (with their source note)"
@echo "make corpus-scan search the pool for credential-shaped content"
@echo "make corpus-sources show the platform guides and the last harvest"
@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-offline:
@$(CORPUS) --offline 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-graph:
@test -n "$(Q)" || { echo "usage: make corpus-graph Q='photo pipeline'"; exit 2; }
@$(CORPUS) graph --query "$(Q)"
corpus-scan:
@$(CORPUS) scan
corpus-sources:
@$(CORPUS) sources
corpus-test:
@$(PYTHON) -m unittest discover -s corpus/tests -t .
clean:
@rm -rf $(BIN) $(DIST)

View File

@@ -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
View 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
View 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
}

22
corpus/__init__.py Normal file
View 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
View 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())

94
corpus/areas.py Normal file
View File

@@ -0,0 +1,94 @@
"""Which resume area a harvested document feeds.
The payload `area` is what lets the query path filter *before* it searches, so a
harvested file has to be classified once, deterministically, and never promoted
to make a page read better.
The classification is keyword evidence, in this order: the path, the repository
name, the description and topics, then the languages. The result is constrained
to the platform guide's `fits` list: a platform cannot feed an area it does not
declare. When nothing matches, the first `fits` entry wins, which is why every
guide lists its strongest area first.
This is a heuristic and it is labelled as one. It is not a claim about Sam's
career; the curated `content/` tree carries the claims.
"""
from __future__ import annotations
import re
from .payloads import AREAS
# Order matters: the first area with a hit wins, so the more specific evidence
# (an ESP32 repository is edge, not platform) is checked before the general.
_RULES = (
("edge", (
"esp32", "esp8266", "arduino", "lora", "lorawan", "mqtt", "sensor",
"zigbee", "z-wave", "relay", "gpio", "pir", "modbus", "iot",
"firmware", "breadboard", "raspberry pi", "rpi", "camera", "cam",
"temperature", "watering", "fish feeder", "driveway", "light",
"snapcast", "mopidy", "audio", "voice bridge",
)),
("ai", (
"ai", "llm", "agent", "agentic", "rag", "embedding", "vector",
"model", "ml", "prompt", "graphify", "omniroute", "paperclip",
"langgraph", "prefect", "whisper", "piper", "chat", "memory",
"knowledge graph", "vision", "triage",
)),
("platform", (
"nixos", "nix", "docker", "compose", "kubernetes", "k8s", "deploy",
"deployment", "backup", "borg", "kopia", "restic", "network", "dns",
"email", "caddy", "proxmox", "postgres", "terraform", "ansible",
"linux", "systemd", "homelab", "server", "infrastructure", "git",
"gitea", "monitor", "logging", "security", "firewall", "vpn",
"tailscale", "storage", "garage", "s3",
)),
("products", (
"product", "app", "web", "dashboard", "react", "laravel", "flutter",
"kotlin", "calendar", "photo", "kontra", "store", "resume", "site",
"gps", "tracker", "api", "frontend", "ui", "ux",
)),
("history", (
"history", "agency", "client", "archive", "old", "legacy", "2019",
"2018", "2020", "2021", "career", "resume copy",
)),
("about", ("about", "profile", "bio", "personal", "cv")),
)
_WORD_SPLIT = re.compile(r"[^0-9a-z]+")
def area_for(evidence: str, fits, fallback: str = None) -> str:
"""Classify one document from its path, name, description and topics."""
haystack = " " + _WORD_SPLIT.sub(" ", (evidence or "").lower()) + " "
allowed = [area for area in (fits or AREAS) if area in AREAS]
if not allowed:
allowed = list(AREAS)
for area, needles in _RULES:
if area not in allowed:
continue
for needle in needles:
if f" {needle} " in haystack or f" {needle}s " in haystack:
return area
if fallback in allowed:
return fallback
return allowed[0]
def area_for_repo(repo: dict, fits) -> str:
topics = " ".join(repo.get("topics") or [])
evidence = " ".join(
str(part)
for part in (
repo.get("full_name", ""),
repo.get("name", ""),
repo.get("description", ""),
topics,
)
)
return area_for(evidence, fits, fallback=(fits or [None])[0])
def area_for_note(path: str, fits) -> str:
return area_for(path, fits, fallback=(fits or [None])[0])

408
corpus/chunker.py Normal file
View File

@@ -0,0 +1,408 @@
"""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})"
)
# -- code -----------------------------------------------------------------
def _code_depth(line: str, depth: int) -> int:
"""Approximate bracket depth for one line, ignoring strings and comments.
This is a heuristic lexer, not a parser: it is here to answer one question —
"is this a safe line to break after?" — and it errs towards *not* breaking.
A line with an ambiguous quote or an unterminated bracket simply keeps the
chunk open, which can only produce a larger chunk, never a broken one.
"""
index = 0
quote = None
length = len(line)
while index < length:
char = line[index]
if quote:
if char == "\\":
index += 2
continue
if line.startswith(quote, index):
index += len(quote)
quote = None
continue
index += 1
continue
if line.startswith("//", index) or line.startswith("#", index) and line.lstrip().startswith("#"):
break
if char in "\"'`":
triple = char * 3
if line.startswith(triple, index):
quote = triple
index += 3
continue
quote = char
index += 1
continue
if char in "([{":
depth += 1
elif char in ")]}":
depth = max(0, depth - 1)
index += 1
return depth
def code_segments(text: str) -> list:
"""Split code into whole top-level segments. A function is never cut."""
lines = text.split("\n")
segments = []
current = []
depth = 0
start = 1
for number, line in enumerate(lines, start=1):
if not current:
start = number
depth = _code_depth(line, depth)
current.append(line)
# A blank line at bracket depth zero is the only automatic boundary.
if depth == 0 and not line.strip() and len(current) > 1:
segments.append((start, number, "\n".join(current)))
current = []
if current:
segments.append((start, len(lines), "\n".join(current)))
return [(start, end, body) for start, end, body in segments if body.strip()]
def chunk_code(text: str) -> list:
"""Chunk a source file without cutting a function, class or block.
Segments are whole top-level units. A unit larger than the ceiling is kept
as one oversize chunk and flagged, exactly like an oversized Markdown fence:
cutting it would break the rule this function exists to keep.
Overlap repeats the previous unit, never part of one, so an overlapping
chunk is still syntactically whole.
"""
segments = code_segments(text)
if not segments:
raise ChunkError("source file produced no segments (empty?)")
chunks = []
current = [] # list of (start, end, body)
tokens = 0
def flush():
nonlocal current, tokens
if not current:
return
body = "\n".join(segment[2] for segment in current).strip("\n")
chunks.append(
Chunk(
index=len(chunks),
text=body,
tokens=estimate_tokens(body),
line_start=current[0][0],
line_end=current[-1][1],
blocks=tuple(
Block("code", segment[2], estimate_tokens(segment[2]), segment[0], segment[1])
for segment in current
),
oversize=len(current) == 1 and estimate_tokens(current[0][2]) > TOKEN_MAX,
)
)
current = []
tokens = 0
for segment in segments:
segment_tokens = estimate_tokens(segment[2])
if current and tokens + segment_tokens > TOKEN_MAX:
previous = current[-1]
flush()
budget = int(round(TOKEN_TARGET * OVERLAP_RATIO))
if estimate_tokens(previous[2]) <= budget:
current = [previous]
tokens = estimate_tokens(previous[2])
current.append(segment)
tokens += segment_tokens
flush()
return chunks

377
corpus/cli.py Normal file
View File

@@ -0,0 +1,377 @@
"""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, guides, pipeline, store as store_module
from .config import Config, ConfigError
from .qdrant import Qdrant, QdrantError
from .sources import graph as graph_module
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,
"offline": True if args.offline else None,
"platforms": tuple(part.strip() for part in args.platforms.split(",") if part.strip())
if args.platforms
else None,
"graph_path": args.graph_path or None,
}
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,
"graph": _cmd_graph,
"graph-neighbours": _cmd_graph_neighbours,
"scan": _cmd_scan,
"sources": _cmd_sources,
}[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")
parser.add_argument("--offline", action="store_true", help="read content/ only; do not harvest the estate")
parser.add_argument("--platforms", help="comma-separated platforms to harvest (default: all enabled)")
parser.add_argument("--graph-path", help="read graph.json from this path instead of Gitea")
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")
graph = sub.add_parser("graph", help="query graph nodes and edges by keyword or vector")
graph.add_argument("--query", required=True)
graph.add_argument("--area")
graph.add_argument("--limit", type=int, default=10)
graph.add_argument("--json", action="store_true")
neighbours = sub.add_parser("graph-neighbours", help="edges attached to a graph node id")
neighbours.add_argument("--node", required=True)
neighbours.add_argument("--limit", type=int, default=50)
neighbours.add_argument("--json", action="store_true")
scan = sub.add_parser("scan", help="search the finished pool for credential-shaped content")
scan.add_argument("--json", action="store_true")
sources = sub.add_parser("sources", help="show the platform guides and the last harvest")
sources.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_graph(cfg, args, log) -> int:
with store_module.Store(cfg.db_path) as store:
report = graph_module.query(cfg, store, args.query, area=args.area, limit=args.limit)
if args.json:
print(json.dumps(report, indent=2, sort_keys=True, default=str))
return 0
print(f"graph query {args.query!r} area={args.area!r}")
print("")
print(f"keyword ({len(report['keyword'])} row(s)):")
for row in report["keyword"]:
kind = row.get("kind")
print(f" [{kind}] {row.get('label')} -> {row.get('source_note')} {row.get('origin_url')}")
print("")
print(f"vector ({len(report['vector'])} row(s)):")
for row in report["vector"]:
print(f" {row['score']:.3f} [{row['kind']}] {row['label']} -> {row['source_note']}")
if report.get("vector_note"):
print(f" note: {report['vector_note']}")
return 0 if report["ok"] else 1
def _cmd_graph_neighbours(cfg, args, log) -> int:
with store_module.Store(cfg.db_path) as store:
node = store.graph_node(args.node)
edges = store.graph_neighbours(args.node, limit=args.limit)
if args.json:
print(json.dumps({"node": node, "edges": edges}, indent=2, sort_keys=True, default=str))
return 0
if not node:
print(f"no graph node with id {args.node!r}")
return 1
print(f"{node.get('label')} ({node.get('node_type')})")
print(f" note: {node.get('source_note')}")
print(f" origin: {node.get('origin_url')}")
for edge in edges:
print(
f" {edge.get('source_label')} --{edge.get('relation')}--> {edge.get('target_label')}"
f" [{edge.get('source_note')}]"
)
return 0
def _cmd_scan(cfg, args, log) -> int:
report = pipeline.scan_pool(cfg)
if args.json:
print(json.dumps(report, indent=2, sort_keys=True))
else:
print(
f"scanned {report['chunks']} chunk(s): "
+ ("clean" if report["ok"] else f"{len(report['hits'])} hit(s)")
)
for hit in report["hits"]:
print(f" {hit}")
return 0 if report["ok"] else 1
def _cmd_sources(cfg, args, log) -> int:
loaded = guides.load_guides(cfg.root)
public = guides.build_public_manifest(loaded)
last = {}
if os.path.exists(cfg.db_path):
with store_module.Store(cfg.db_path) as store:
last = store.get_meta("sources") or {}
if args.json:
print(json.dumps({"guides": public, "last_harvest": last}, indent=2, sort_keys=True))
return 0
for row in public["platforms"]:
state = "enabled" if row["enabled"] else "disabled"
print(f"{row['name']} ({state}) — {row['what']}")
print(f" base: {row['base']} fits: {', '.join(row['fits'])} read: {', '.join(row['read'])}")
print(f" include: {json.dumps(row['include'])}")
print(f" exclude: {json.dumps(row['exclude'])}")
seen = last.get(row["name"]) or {}
if seen:
print(
f" last harvest: {seen.get('documents', 0)} document(s), "
f"newest {seen.get('newest') or '—'}"
)
print("")
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)}")
print(f"graph: {json.dumps(store.graph_counts(), sort_keys=True)}")
sources = store.get_meta("sources") or {}
for name in sorted(sources):
row = sources[name]
print(
f"source {name}: {row.get('documents', 0)} document(s), "
f"newest {row.get('newest') or '—'}"
)
else:
print(f"store: {cfg.db_path} does not exist")
return 0
if __name__ == "__main__":
sys.exit(main())

206
corpus/config.py Normal file
View File

@@ -0,0 +1,206 @@
"""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-v2-code-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"
DEFAULT_GITEA_URL = "https://gitea.lab.audasmedia.com.au"
DEFAULT_PLATFORMS = ("gitea", "obsidian", "graphify")
DEFAULT_GRAPH_COLLECTION = "resume_graph"
# A source that has not changed in this window is reported by the audit. The
# estate is active; silence is evidence that ingestion stopped, not that the
# work stopped.
DEFAULT_SOURCE_SILENCE_DAYS = 30
# 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
gitea_url: str = DEFAULT_GITEA_URL
gitea_token: str = ""
platforms: tuple = DEFAULT_PLATFORMS
offline: bool = False
graph_path: str = ""
graph_collection: str = DEFAULT_GRAPH_COLLECTION
source_silence_days: int = DEFAULT_SOURCE_SILENCE_DAYS
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 "",
gitea_url=(os.environ.get("GITEA_URL") or os.environ.get("RESUME_GITEA_URL") or DEFAULT_GITEA_URL).rstrip("/"),
gitea_token=os.environ.get("GITEA_TOKEN") or "",
platforms=tuple(
part.strip()
for part in (os.environ.get("RESUME_PLATFORMS") or ",".join(DEFAULT_PLATFORMS)).split(",")
if part.strip()
),
offline=(os.environ.get("RESUME_OFFLINE") or "").lower() in ("1", "true", "yes"),
graph_path=os.environ.get("RESUME_GRAPH_PATH") or "",
graph_collection=os.environ.get("RESUME_GRAPH_COLLECTION") or DEFAULT_GRAPH_COLLECTION,
source_silence_days=int(
os.environ.get("RESUME_SOURCE_SILENCE_DAYS") or DEFAULT_SOURCE_SILENCE_DAYS
),
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}")
unknown = [name for name in self.platforms if name not in ("gitea", "obsidian", "graphify")]
if unknown:
raise ConfigError(
f"RESUME_PLATFORMS contains unknown platform(s) {unknown}; "
"expected gitea, obsidian, graphify"
)
if not self.offline and self.gitea_token == "":
# Only a hard error when a platform that needs Gitea is selected; the
# token itself is never printed or stored.
if set(self.platforms) & {"gitea", "obsidian", "graphify"}:
raise ConfigError(
"GITEA_TOKEN is not set. Harvesting needs read access to Gitea "
"(~/.config/environment.d/10-secrets.conf), or set RESUME_OFFLINE=1 "
"to rebuild only the curated tier."
)
# -- 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"estate=platforms:{','.join(self.platforms)} offline:{self.offline} "
f"gitea:{self.gitea_url} token:{'set' if self.gitea_token else 'unset'} "
f"graph_path:{self.graph_path or 'from gitea'} silence_days:{self.source_silence_days}\n"
f"qdrant vectors_on_disk={self.vectors_on_disk} graph_collection={self.graph_collection}\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
View 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

256
corpus/exclusions.py Normal file
View File

@@ -0,0 +1,256 @@
"""What must never reach the pool.
AGENTS.md §4.4 and `docs/phase-4-corpus.md` §6 make this a hard gate, not a
suggestion: node_modules, virtualenvs, git internals, `.trash`, `.obsidian`, the
SilverBullet auth file, credential-shaped files and binaries are refused. The
platform guide excludes are *additional*; they cannot widen this list.
Two different outcomes, because they are different things:
* **reject** — the file is not read at all. The reason is recorded and reported
so a run says *why* it skipped something.
* **image** — a media file is recorded as an image node reference (path only,
never bytes, never extracted text). Phase 4 §1.
Credential-shaped *content* is a third thing: a file with a valid path can still
contain a private key or an API token. `scan_text` rejects the whole document
rather than redacting part of it, and the same scan runs over the finished pool
so "no secret in the pool" is verified by search, not asserted.
"""
from __future__ import annotations
import re
from . import globs
# Directory names that are never descended into. Checked per path component, so
# a file deep inside one is refused by the same rule as its parent.
EXCLUDED_DIRS = frozenset(
{
"node_modules",
".venv",
"venv",
".git",
".trash",
".obsidian",
".pi",
"__pycache__",
".mypy_cache",
".pytest_cache",
".ruff_cache",
"graphify-out",
"graphify-out-temp",
".next",
".cache",
"site-packages",
}
)
# Basenames (and simple suffix patterns) that are credential-shaped by name.
CREDENTIAL_FILES = (
".silverbullet.auth.json",
".env",
".env.*",
"*.env",
"*.env.*",
".netrc",
".git-credentials",
".npmrc",
".pypirc",
"credentials",
"credentials.*",
"secrets.*",
"secret.*",
"auth.json",
"*.pem",
"*.key",
"*.p12",
"*.pfx",
"*.jks",
"*.keystore",
"*.kdbx",
"*.age",
"id_rsa",
"id_ed25519",
"id_ecdsa",
"*.lock",
"*.lockb",
)
# Media is never text. It becomes an image node reference and nothing else.
IMAGE_EXTENSIONS = frozenset(
{"png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "heic", "avif", "ico"}
)
VIDEO_EXTENSIONS = frozenset({"mp4", "mov", "mkv", "webm", "avi", "m4v", "mpg", "mpeg"})
AUDIO_EXTENSIONS = frozenset({"mp3", "wav", "flac", "ogg", "m4a", "aac", "opus", "wma"})
MEDIA_EXTENSIONS = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS | AUDIO_EXTENSIONS
# Everything else that is bytes, not prose. Rejected with a reason; not indexed
# and not recorded as media.
BINARY_EXTENSIONS = frozenset(
{
"pdf", "zip", "tar", "gz", "bz2", "xz", "7z", "rar", "iso", "dmg",
"exe", "dll", "so", "dylib", "o", "a", "class", "jar", "war",
"pyc", "pyo", "wasm", "ttf", "otf", "woff", "woff2", "eot",
"sqlite", "sqlite3", "db", "mdb", "bin", "dat", "pkl", "npy", "npz",
"stl", "step", "obj", "blend", "psd", "ai", "sketch", "fig",
}
)
# Text-ish extensions the estate actually contains. A file with an unknown
# extension is admitted only when it decodes as UTF-8 and does not look binary.
TEXT_EXTENSIONS = frozenset(
{
"md", "markdown", "mdx", "txt", "rst", "adoc", "org",
"py", "go", "ts", "tsx", "js", "jsx", "mjs", "cjs", "rs", "rb",
"php", "kt", "kts", "java", "cs", "c", "h", "cc", "cpp", "hpp",
"ino", "lua", "pl", "r", "scala", "swift", "dart", "ex", "exs",
"sh", "bash", "zsh", "fish", "ps1", "bat",
"nix", "toml", "yml", "yaml", "ini", "conf", "cfg", "properties",
"json", "jsonc", "xml", "html", "htm", "css", "scss", "sass", "less",
"sql", "graphql", "proto", "tf", "hcl", "dockerfile", "makefile",
"gitignore", "dockerignore", "editorconfig", "service", "timer",
"csv", "tsv", "svg", "tex", "patch", "diff", "env.example",
"example", "sample", "template", "j2", "tmpl",
}
)
_CREDENTIAL_PATTERNS = (
("private-key", re.compile(r"-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----")),
("aws-access-key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
("github-token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b")),
("slack-token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
("openai-key", re.compile(r"\bsk-[A-Za-z0-9]{20,}\b")),
("anthropic-key", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}\b")),
("jwt", re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b")),
("age-key", re.compile(r"\bAGE-SECRET-KEY-1[0-9A-Z]{20,}\b")),
(
"assigned-secret",
re.compile(
r"(?im)^\s*(?:export\s+)?[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|"
r"ACCESS_KEY|PRIVATE_KEY)[A-Z0-9_]*\s*[:=]\s*[\"']?[^\s\"'#]{8,}"
),
),
)
# A `.env.example`-style file may document the *name* of a variable. It may not
# carry a value. This filter keeps `GITEA_TOKEN=` and `TOKEN=changeme` out.
_PLACEHOLDERS = {"", "changeme", "change-me", "xxx", "todo", "placeholder", "your-token-here", "redacted"}
class Rejection:
"""One file that was not admitted, and the rule that refused it."""
__slots__ = ("path", "platform", "reason", "detail")
def __init__(self, path: str, platform: str, reason: str, detail: str = "") -> None:
self.path = path
self.platform = platform
self.reason = reason
self.detail = detail
def to_dict(self) -> dict:
return {
"path": self.path,
"platform": self.platform,
"reason": self.reason,
"detail": self.detail,
}
def extension(path: str) -> str:
base = path.rsplit("/", 1)[-1].lower()
if "." not in base:
return base
return base.rsplit(".", 1)[-1]
def basename(path: str) -> str:
return path.rsplit("/", 1)[-1]
def classify(path: str, guide=None, size: int | None = None) -> tuple:
"""Return `(kind, reason)` for a path.
`kind` is one of `text`, `image`, `reject`. `reason` is empty for text and
names the rule for the other two. Guide excludes are applied here so a
caller cannot forget them.
"""
path = globs.normalise(path)
if not path:
return "reject", "empty path"
parts = path.split("/")
for part in parts[:-1]:
if part.lower() in EXCLUDED_DIRS:
return "reject", f"excluded directory {part!r}"
name = basename(path)
lowered = name.lower()
if lowered in EXCLUDED_DIRS:
return "reject", f"excluded directory {lowered!r}"
for pattern in CREDENTIAL_FILES:
if globs.matches(lowered, pattern) or globs.matches(name, pattern):
return "reject", "credential-shaped file name"
if guide is not None:
exclude = getattr(guide, "exclude", {}) or {}
if globs.under_any(path, exclude.get("paths")):
return "reject", "excluded by platform guide"
if globs.matches_any(path, exclude.get("files") or ()):
return "reject", "excluded by platform guide"
ext = extension(path)
if ext in MEDIA_EXTENSIONS:
return "image", f"media ({ext}) recorded as an image reference"
if ext in BINARY_EXTENSIONS:
return "reject", f"binary ({ext})"
if size is not None:
limit = int(((getattr(guide, "limits", {}) or {}).get("max_file_kb") or 200) * 1024)
if size > limit:
return "reject", f"oversize ({size} bytes > {limit})"
if guide is not None:
include = getattr(guide, "include", {}) or {}
include_paths = include.get("paths") or []
if include_paths and not globs.under_any(path, include_paths):
return "reject", "not under an included path"
if ext not in TEXT_EXTENSIONS and name.lower() not in TEXT_EXTENSIONS:
# Unknown extension: admitted only if the caller proves it is UTF-8 text.
return "unknown", ""
return "text", ""
def looks_binary(data: bytes) -> bool:
if b"\x00" in data[:4096]:
return True
try:
data.decode("utf-8")
except UnicodeDecodeError:
return True
return False
def scan_text(text: str) -> list:
"""Return `[(kind, line_number, excerpt)]` for credential-shaped content.
The excerpt is short and never includes the matched value: a rejection
report must not become the leak it is reporting.
"""
hits = []
for number, line in enumerate(text.splitlines(), start=1):
for kind, pattern in _CREDENTIAL_PATTERNS:
match = pattern.search(line)
if not match:
continue
value = match.group(0)
if kind == "assigned-secret":
assigned = value.split("=", 1)[-1].split(":", 1)[-1].strip().strip("\"'")
if assigned.lower() in _PLACEHOLDERS:
continue
hits.append((kind, number, line.strip()[:60].split("=", 1)[0]))
break
return hits

279
corpus/freshness.py Normal file
View File

@@ -0,0 +1,279 @@
"""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
from .sources import estate as estate_module
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()
# The audit reads the same estate the pipeline reads, so "the source has
# stopped changing" is measured against the source, not against the index.
harvest = None
try:
harvest = estate_module.harvest(cfg, log=log)
documents = harvest.documents
except estate_module.EstateError as exc:
documents = loader.load_documents(cfg)
estate_error = str(exc)
else:
estate_error = ""
findings = {
"stale": [],
"due_soon": [],
"index_drift": [],
"contract_drift": [],
"stale_source": [],
"source_missing": [],
"source_unavailable": [],
}
if estate_error:
findings["source_unavailable"].append({"platform": "estate", "problem": estate_error})
if harvest is not None:
for name, row in sorted((harvest.sources or {}).items()):
if not row.get("enabled"):
continue
# Curated content is measured by last_verified above; source silence
# is about the harvested estate, which should change on its own.
if name == "curated":
continue
if row.get("missing") or not row.get("documents"):
findings["source_missing"].append(
{
"platform": name,
"problem": "no documents were harvested",
"repo": row.get("repo") or row.get("base") or "",
}
)
continue
newest = row.get("newest") or ""
try:
changed = datetime.date.fromisoformat(newest[:10])
except ValueError:
findings["source_missing"].append(
{"platform": name, "problem": f"no usable last_modified ({newest!r})"}
)
continue
age = (today - changed).days
if age > cfg.source_silence_days:
findings["stale_source"].append(
{
"platform": name,
"repo": row.get("repo") or row.get("base") or "",
"newest": newest,
"age_days": age,
"problem": f"the source has not changed in {age} days",
}
)
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/"}
)
graph_counts = store.graph_counts()
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,
"graph_collection": cfg.graph_collection,
"graph_nodes": graph_counts["nodes"],
"graph_edges": graph_counts["edges"],
"provider": cfg.provider,
"model": cfg.model,
"dim": cfg.dim,
"chunker_version": CHUNKER_VERSION,
},
"findings": findings,
"sources": (harvest.sources if harvest is not None else {}),
"warnings": (harvest.warnings if harvest is not None else []),
"summary": {
"stale": len(findings["stale"]),
"due_soon": len(findings["due_soon"]),
"index_drift": len(findings["index_drift"]),
"contract_drift": len(findings["contract_drift"]),
"stale_source": len(findings["stale_source"]),
"source_missing": len(findings["source_missing"]),
"source_unavailable": len(findings["source_unavailable"]),
},
# `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"]
or findings["stale_source"]
or findings["source_missing"]
or findings["source_unavailable"]
),
}
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"]
graph = ""
if corpus.get("graph_nodes") is not None:
graph = f", graph {corpus['graph_nodes']} node(s) / {corpus['graph_edges']} edge(s)"
lines.append(
f"corpus: {corpus['documents']} document(s), {corpus['chunks']} chunk(s), "
f"{corpus['collection_points']} vector(s) in {corpus['collection']}{graph}"
)
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 the sources"),
("contract_drift", "CONTRACT DRIFT — rebuild required"),
("stale_source", "STALE SOURCE — the estate has stopped changing"),
("source_missing", "SOURCE MISSING — an enabled platform produced nothing"),
("source_unavailable", "SOURCE UNAVAILABLE — the estate could not be read"),
):
rows = findings.get(kind) or []
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, every source current."
+ (" (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

89
corpus/globs.py Normal file
View File

@@ -0,0 +1,89 @@
"""Path globs, with the one extension the guides need: `**/` matches nothing.
`fnmatch` is close but wrong for this job in two ways: it lets `*` cross a `/`,
and it has no `**`. Platform guides are read by a human and written as ordinary
gitignore-style globs (`**/node_modules/**`, `200 projects/**`), so this module
implements exactly that little language and nothing else.
Matching is always against a normalised, repository-relative POSIX path.
"""
from __future__ import annotations
import re
_CACHE: dict = {}
def normalise(path: str) -> str:
"""Repository-relative, POSIX separators, no leading `./` or `/`."""
path = str(path).replace("\\", "/").strip()
while path.startswith("./"):
path = path[2:]
return path.lstrip("/")
def compile_glob(pattern: str):
"""Compile one glob to a regular expression. Cached."""
cached = _CACHE.get(pattern)
if cached is not None:
return cached
pattern = normalise(pattern)
out = ["^"]
index = 0
while index < len(pattern):
char = pattern[index]
if char == "*":
if pattern[index : index + 2] == "**":
index += 2
if pattern[index : index + 1] == "/":
# `**/` — any number of whole directories, including none.
out.append("(?:[^/]+/)*")
index += 1
else:
out.append(".*")
continue
out.append("[^/]*")
elif char == "?":
out.append("[^/]")
elif char == "[":
close = pattern.find("]", index + 1)
if close == -1:
out.append(re.escape(char))
else:
body = pattern[index + 1 : close]
if body.startswith("!"):
body = "^" + body[1:]
out.append("[" + body + "]")
index = close
else:
out.append(re.escape(char))
index += 1
out.append("$")
compiled = re.compile("".join(out))
_CACHE[pattern] = compiled
return compiled
def matches(path: str, pattern: str) -> bool:
return bool(compile_glob(pattern).match(normalise(path)))
def matches_any(path: str, patterns) -> bool:
return any(matches(path, pattern) for pattern in (patterns or ()))
def under_any(path: str, patterns) -> bool:
"""True when `path` is a pattern's directory itself or inside it.
`200 projects/**` should also exclude the bare directory entry `200 projects`
that a tree listing returns, not only the files underneath it.
"""
path = normalise(path)
for pattern in patterns or ():
if matches(path, pattern):
return True
if pattern.endswith("/**") and path == normalise(pattern[:-3]):
return True
return False

227
corpus/guides.py Normal file
View File

@@ -0,0 +1,227 @@
"""Platform guides: one small file per platform, configuration and public copy.
`platforms/<name>.yml` says what a platform is, where it lives, what to include,
what to exclude, which resume area it feeds, and whether it is enabled. The same
wording is shown on the public site, so every value here is written to be read
by a visitor, not just by the pipeline.
The schema is fixed and validated at load time. An unknown area, a missing
`what`, or a guide whose filename and `name` disagree is a configuration error,
not a default. A disabled guide is loaded and shown, but not harvested.
There is exactly one YAML dialect in this repository and one parser for it
(`tools/validate_content.py`); this module imports that parser rather than
adding a second one.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from . import loader
from .payloads import AREAS # one area enum for guides and payloads
# The platforms the pipeline knows how to harvest. `curated` is content/ and is
# always on: it is tier 1 and has no guide because it is not an external estate.
KINDS = ("gitea", "obsidian", "graphify")
REQUIRED = ("name", "enabled", "what", "fits", "read")
DEFAULT_LIMITS = {
"max_documents": 4000,
"max_file_kb": 200,
"max_files_per_repo": 40,
"max_commits": 20,
"max_tree_entries": 3000,
"max_nodes": 20000,
"max_edges": 40000,
}
class GuideError(RuntimeError):
"""A platform guide is missing, malformed, or inconsistent."""
@dataclass
class Guide:
name: str
enabled: bool
what: str
fits: list
read: list
base: str = ""
auth: str = ""
include: dict = field(default_factory=dict)
exclude: dict = field(default_factory=dict)
limits: dict = field(default_factory=dict)
public: bool = True
path: str = "" # repository-relative, for the public page and error messages
# -- derived ----------------------------------------------------------
@property
def kind(self) -> str:
return self.name
@property
def primary_area(self) -> str:
return self.fits[0]
def limit(self, name: str) -> int:
return int(self.limits.get(name) or DEFAULT_LIMITS.get(name) or 0)
def to_public_dict(self) -> dict:
"""What the site shows. No auth variable values, only its name."""
return {
"name": self.name,
"enabled": self.enabled,
"what": self.what,
"fits": list(self.fits),
"read": list(self.read),
"base": self.base,
"include": {
"repos": _as_list((self.include or {}).get("repos")),
"paths": _as_list((self.include or {}).get("paths")),
},
"exclude": {
"repos": _as_list((self.exclude or {}).get("repos")),
"paths": _as_list((self.exclude or {}).get("paths")),
},
"public": self.public,
"path": self.path,
}
def to_dict(self) -> dict:
data = self.to_public_dict()
data["limits"] = dict(self.limits)
data["auth"] = self.auth # the variable name, never its value
return data
def load_guides(root: str, directory: str = None) -> dict:
"""Load every guide under platforms/, keyed by name. Sorted, deterministic."""
directory = directory or os.path.join(root, "platforms")
if not os.path.isdir(directory):
raise GuideError(f"platform guide directory not found: {directory}")
validator = loader.load_validator(root)
guides = {}
for filename in sorted(os.listdir(directory)):
if not filename.endswith((".yml", ".yaml")):
continue
path = os.path.join(directory, filename)
rel = os.path.relpath(path, root).replace(os.sep, "/")
guide = _load_one(validator, path, rel, os.path.splitext(filename)[0])
if guide.name in guides:
raise GuideError(f"{rel}: duplicate platform name {guide.name!r}")
guides[guide.name] = guide
missing = sorted(set(KINDS) - set(guides))
if missing:
raise GuideError(
f"no platform guide for {', '.join(missing)} under {directory}. "
"One small file per platform is required (docs/phase-4-corpus.md §2)."
)
return guides
def _load_one(validator, path: str, rel: str, stem: str) -> Guide:
with open(path, "r", encoding="utf-8") as handle:
text = handle.read()
lines = [(number, line) for number, line in enumerate(text.splitlines(), start=1)]
try:
node = validator.parse_front_matter(lines)
except validator.YamlError as exc:
raise GuideError(f"{rel}:{exc.line}: invalid YAML: {exc.msg}") from exc
values = loader.to_python(node)
if not isinstance(values, dict):
raise GuideError(f"{rel}: a platform guide must be a mapping")
for key in REQUIRED:
if values.get(key) in (None, "", []):
raise GuideError(f"{rel}: missing required key {key!r}")
name = str(values["name"]).strip()
if name != stem:
raise GuideError(f"{rel}: name {name!r} does not match the filename {stem!r}")
if name not in KINDS:
raise GuideError(f"{rel}: unknown platform {name!r}; expected one of {list(KINDS)}")
fits = [str(area) for area in values["fits"]]
unknown = [area for area in fits if area not in AREAS]
if unknown:
raise GuideError(f"{rel}: fits contains unknown area(s) {unknown}; allowed {list(AREAS)}")
read = [str(item) for item in values["read"]]
if not read:
raise GuideError(f"{rel}: read must name at least one thing to harvest")
include = _mapping(values.get("include"), f"{rel}: include")
exclude = _mapping(values.get("exclude"), f"{rel}: exclude")
limits = _mapping(values.get("limits"), f"{rel}: limits")
for key, value in limits.items():
if isinstance(value, list):
# `graph_paths` is the one list-valued limit: the places a graph may
# be committed, tried in order.
continue
try:
limits[key] = int(value)
except (TypeError, ValueError):
raise GuideError(f"{rel}: limit {key!r} must be an integer, got {value!r}") from None
return Guide(
name=name,
enabled=bool(values.get("enabled")),
what=str(values["what"]).strip(),
fits=fits,
read=read,
base=str(values.get("base") or "").strip(),
auth=str(values.get("auth") or "").strip(),
include=include,
exclude=exclude,
limits=limits,
public=bool(values.get("public", True)),
path=rel,
)
def _mapping(value, where: str) -> dict:
if value is None:
return {}
if not isinstance(value, dict):
raise GuideError(f"{where} must be a mapping")
out = {}
for key, item in value.items():
if isinstance(item, list):
out[key] = [str(entry) for entry in item]
else:
out[key] = item
return out
def _as_list(value) -> list:
"""`repos: all` is a scalar; the public manifest still emits a list."""
if value in (None, ""):
return []
if isinstance(value, list):
return [str(item) for item in value]
return [str(value)]
def enabled_guides(guides: dict) -> list:
return [guide for _, guide in sorted(guides.items()) if guide.enabled]
def auth_value(guide: Guide, environ=None) -> str:
"""Read a guide's token from the environment. Never from a file in the repo."""
if not guide.auth:
return ""
environ = os.environ if environ is None else environ
return environ.get(guide.auth, "")
def build_public_manifest(guides: dict) -> dict:
"""The guides as the public site shows them. Deterministic JSON shape."""
return {
"version": 1,
"platforms": [guide.to_public_dict() for _, guide in sorted(guides.items())],
}

180
corpus/loader.py Normal file
View File

@@ -0,0 +1,180 @@
"""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
# Provenance (docs/phase-4-corpus.md §3). Curated content is tier 1 and has
# no external origin; harvested documents set these in corpus/sources/.
platform: str = "curated"
tier: int = 1
origin_url: str = ""
last_modified: str = ""
source_type: str = "" # empty means: derive from tags (payloads.source_type_for)
kind: str = "markdown" # "markdown" or "code", selects the chunker
repo_path: str = "" # origin-relative path; `path` above is the unique source key
# 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),
platform="curated",
tier=1,
# A curated claim is traceable to the page it appears on. The URL is
# site-relative because the public origin is a deployment concern.
origin_url=page_url(str(fields["area"]), str(fields["id"]), level),
last_modified=f"{fields['last_verified']}T00:00:00Z",
kind="markdown",
repo_path=rel,
)
return doc

242
corpus/measure.py Normal file
View 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)))

202
corpus/payloads.py Normal file
View File

@@ -0,0 +1,202 @@
"""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)
# docs/phase-4-corpus.md §3 fixes the harvested types; adr and metric were
# already used by curated content and stay valid.
SOURCE_TYPES = (
"doc", "code", "commit", "graph_node", "graph_edge", "claim", "adr", "metric",
)
CONFIDENCE = ("delivered", "running", "in-progress", "abandoned", "historic")
PLATFORMS = ("curated", "gitea", "obsidian", "graphify")
TIERS = (1, 2)
# 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",
# docs/phase-4-corpus.md §3: provenance is part of the payload, not an
# optional extra. A chunk that cannot say where it came from is not admitted.
"platform",
"tier",
"origin_url",
"path",
"last_modified",
)
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.
Harvested documents carry an explicit `source_type`. Curated content does
not: the schema is frozen and has no such field, so it is derived from the
tags the schema does allow.
"""
explicit = str(getattr(doc, "source_type", "") or "").strip()
if explicit:
return explicit
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 (docs/phase-4-corpus.md §3) ------------------------
"platform": doc.platform,
"tier": int(doc.tier),
"origin_url": doc.origin_url,
"path": getattr(doc, "repo_path", "") or doc.path,
"last_modified": doc.last_modified,
# -- 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)}"
)
if payload["platform"] not in PLATFORMS:
raise PayloadError(
f"{where}: platform {payload['platform']!r} is not one of {list(PLATFORMS)}"
)
if payload["tier"] not in TIERS:
raise PayloadError(f"{where}: tier {payload['tier']!r} is not one of {list(TIERS)}")
for field in (
"period", "title", "claim", "url", "source_path", "source_id",
"embedding_model", "origin_url", "path",
):
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)
_check_timestamp(payload["last_modified"], 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_timestamp(value: str, where: str) -> None:
"""`last_modified` is an ISO-8601 instant, e.g. 2026-10-14T05:00:00Z."""
text = str(value)
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
datetime.datetime.fromisoformat(text)
except ValueError as exc:
raise PayloadError(
f"{where}: field 'last_modified' must be an ISO-8601 timestamp, got {value!r}"
) from exc
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

679
corpus/pipeline.py Normal file
View File

@@ -0,0 +1,679 @@
"""The ingestion pipeline: estate -> normalise -> chunk -> enrich -> embed -> upsert.
One entry point, `run`, with two modes:
* **rebuild** — drop the collections, empty the store, embed everything. This is
the "one command rebuilds the pool 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, including the graph when graph.json is byte-for-byte
unchanged.
The order matters. Every payload for every changed document is built and
validated *before* a single vector is written, so an invalid payload stops the
run with an empty diff rather than leaving half an index behind. Embedding is one
flat pass over the changed chunks, batched, so the provider call count is a
direct measure of how much work incremental mode actually did.
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.
Two Qdrant collections, one store:
* `resume_corpus` — one point per chunk of a document, from every platform;
* `resume_graph` — one point per graph node and one per graph edge, joined to the
note it came from by `source_note`.
Neither is ever read by the public site (acceptance test A1): the site serves
pre-rendered files and does not open a socket to Qdrant.
"""
from __future__ import annotations
import hashlib
import json
import time
import uuid
from dataclasses import asdict, dataclass, field
from . import chunker, embeddings, exclusions, payloads, store as store_module
from .config import CHUNKER_VERSION
from .qdrant import Qdrant, QdrantError
from .sources import estate as estate_module
from .sources import graph as graph_module
# How many texts one embed call carries. The provider may batch smaller
# internally (OmniRoute does); this bounds memory on the ingest host.
EMBED_BATCH = 128
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
graph_nodes: int = 0
graph_edges: int = 0
graph_points: int = 0
graph_rebuilt: bool = False
rejections: int = 0
images: int = 0
duration_s: float = 0.0
collection: str = ""
graph_collection: str = ""
provider: str = ""
model: str = ""
dim: int = 0
chunker_version: str = CHUNKER_VERSION
platforms: dict = field(default_factory=dict)
rejection_summary: dict = field(default_factory=dict)
warnings: list = field(default_factory=list)
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"graph nodes={self.graph_nodes} edges={self.graph_edges} points={self.graph_points} "
f"rebuilt={self.graph_rebuilt}",
f"rejections={self.rejections} image references={self.images}",
f"platforms={json.dumps(self.platforms, sort_keys=True)}",
f"provider={self.provider} model={self.model} dim={self.dim} chunker={self.chunker_version}",
f"collection={self.collection} graph_collection={self.graph_collection} "
f"duration={self.duration_s:.2f}s",
]
if self.rejection_summary:
rows.append(f"rejected: {json.dumps(self.rejection_summary, sort_keys=True)}")
rows.extend(self.notes)
rows.extend(f"warning: {warning}" for warning in self.warnings)
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 pool. 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,
graph_collection=cfg.graph_collection,
provider=cfg.provider,
model=cfg.model,
dim=cfg.dim,
chunker_version=cfg.chunker_version,
)
# 1. Read the estate. Curated content is validated first (loader does it),
# so a broken content tree stops the run before Gitea is touched.
harvest = estate_module.harvest(cfg, log=log)
documents = harvest.documents
stats.documents = len(documents)
stats.rejections = len(harvest.rejections)
stats.images = len(harvest.images)
stats.rejection_summary = harvest.rejection_summary()
stats.warnings = list(harvest.warnings)
stats.platforms = {
name: {"documents": row.get("documents", 0)} for name, row in harvest.sources.items()
}
log(f"estate: {len(documents)} document(s), {stats.rejections} rejection(s), {stats.images} image reference(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.collection, 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}")
# 2. Build and validate every payload before any write.
prepared = [] # [(doc, payloads)]
for doc in changed:
prepared.append((doc, _prepare(doc, cfg)))
all_payloads = [payload for _, rows in prepared for payload in rows]
# 3. Embed in one flat, batched pass. This is the number the DoD uses to
# prove incremental mode: it is proportional to changed chunks only.
vectors = _embed_all(provider, [payload["text"] for payload in all_payloads])
if len(vectors) != len(all_payloads):
raise PipelineError(
f"provider returned {len(vectors)} vectors for {len(all_payloads)} chunks"
)
cursor = 0
platform_changes = stats.platforms
for doc, rows in prepared:
points = []
for payload in rows:
vector = vectors[cursor]
cursor += 1
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})
# Remove what this document used to have, 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, rows)
stats.chunks_written += len(rows)
bucket = platform_changes.setdefault(doc.platform, {"documents": 0})
key = "updated" if doc.path in manifest else "added"
bucket[key] = bucket.get(key, 0) + 1
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"]
# 4. Graph: its own collection, rebuilt only when graph.json changed.
graph_stats = _sync_graph(qdrant, store, cfg, harvest, contract, mode, provider, log)
stats.graph_nodes = graph_stats["nodes"]
stats.graph_edges = graph_stats["edges"]
stats.graph_points = graph_stats["points"]
stats.graph_rebuilt = graph_stats["rebuilt"]
stats.notes.extend(graph_stats["notes"])
# 5. Consistency and the credential scan. Both are checks, not hopes.
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`."
)
_scan_pool(store)
_scan_graph(store)
store.set_meta("collection", contract)
store.set_meta("sources", harvest.sources)
store.set_meta("rejections", harvest.rejections[:200])
store.set_meta("images", harvest.images[:500])
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, harvest)
stats.duration_s = round(time.time() - started, 3)
return stats
# -- planning -------------------------------------------------------------
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:
if mode == "rebuild":
qdrant.drop_collection(cfg.collection)
qdrant.drop_collection(cfg.graph_collection)
if store.schema_stale:
store.rebuild_schema()
else:
store.reset()
store.set_meta("schema_version", store_module.SCHEMA_VERSION)
return mode
if store.schema_stale:
raise PipelineError(
"the corpus database was written by an older payload shape and has no "
"provenance columns. It is derived data: run `python3 -m corpus.cli rebuild`."
)
stored = store.get_meta("collection")
if stored is None:
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).\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, name: str, cfg) -> None:
if not qdrant.collection_exists(name):
qdrant.create_collection(name, cfg.dim, vectors_on_disk=cfg.vectors_on_disk)
return
info = qdrant.collection_info(name)
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 {name!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) -> list:
"""Chunk one document and build validated payloads. No network, no writes."""
try:
if getattr(doc, "kind", "markdown") == "code":
chunks = chunker.chunk_code(doc.body)
else:
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 _embed_all(provider, texts: list) -> list:
out = []
for start in range(0, len(texts), EMBED_BATCH):
out.extend(provider.embed(texts[start : start + EMBED_BATCH]))
return out
# -- graph ----------------------------------------------------------------
def _sync_graph(qdrant, store, cfg, harvest, contract, mode, provider, log) -> dict:
graph = harvest.graph
stored = store.get_meta("graph") or {}
notes = []
if graph is None:
if mode == "rebuild":
qdrant.drop_collection(cfg.graph_collection)
store.write_graph([], [])
store.set_meta("graph", {})
notes.append("graph: no graph.json was available; the graph collection is empty")
return {"nodes": 0, "edges": 0, "points": 0, "rebuilt": True, "notes": notes}
counts = store.graph_counts()
notes.append(
f"graph: graph.json was not available this run; kept the previous graph "
f"({counts['nodes']} nodes, {counts['edges']} edges)"
)
return {**counts, "points": counts["nodes"] + counts["edges"], "rebuilt": False, "notes": notes}
graph_contract = {
"digest": graph.digest,
"source_path": graph.source_path,
"provider": contract["provider"],
"model": contract["model"],
"dim": contract["dim"],
}
rebuild = mode == "rebuild" or stored != graph_contract
if not rebuild:
counts = store.graph_counts()
return {
**counts,
"points": counts["nodes"] + counts["edges"],
"rebuilt": False,
"notes": [f"graph: unchanged ({graph.digest[:12]}), not re-embedded"],
}
if not qdrant.collection_exists(cfg.graph_collection):
_ensure_collection(qdrant, cfg.graph_collection, cfg)
else:
qdrant.drop_collection(cfg.graph_collection)
qdrant.create_collection(cfg.graph_collection, cfg.dim, vectors_on_disk=cfg.vectors_on_disk)
rows = [("node", row) for row in graph.nodes] + [("edge", row) for row in graph.edges]
payloads_for_graph = [graph_module.point_payload(row, kind) for kind, row in rows]
vectors = _embed_all(provider, [payload["text"] for payload in payloads_for_graph])
points = [
{"id": row["point_id"], "vector": vector, "payload": payload}
for (_, row), vector, payload in zip(rows, vectors, payloads_for_graph)
]
sent = qdrant.upsert(cfg.graph_collection, points)
store.write_graph(graph.nodes, graph.edges)
store.set_meta("graph", graph_contract)
log(f"graph: rebuilt {len(graph.nodes)} node(s) and {len(graph.edges)} edge(s), {sent} point(s)")
return {
"nodes": len(graph.nodes),
"edges": len(graph.edges),
"points": sent,
"rebuilt": True,
"notes": notes,
}
# -- checks ---------------------------------------------------------------
def _scan_pool(store) -> None:
"""Search the finished pool for credential-shaped content. Fail, do not warn."""
hits = []
for source_path, chunk_index, text in store.pool_texts():
for kind, line, _excerpt in exclusions.scan_text(text):
hits.append((source_path, chunk_index, kind, line))
if hits:
detail = ", ".join(f"{path}#chunk-{index} ({kind} line {line})" for path, index, kind, line in hits[:10])
raise PipelineError(
f"credential-shaped content reached the pool in {len(hits)} place(s): {detail}. "
"The run is failed rather than published; fix the source or the exclusion rule."
)
def _scan_graph(store) -> None:
rows = store.db.execute("SELECT id, text FROM graph_nodes UNION ALL SELECT id, text FROM graph_edges")
hits = []
for row in rows:
for kind, line, _excerpt in exclusions.scan_text(row["text"]):
hits.append((row["id"], kind, line))
if hits:
detail = ", ".join(f"{node} ({kind} line {line})" for node, kind, line in hits[:10])
raise PipelineError(f"credential-shaped content reached the graph in {len(hits)} place(s): {detail}")
def _write_collection_file(cfg, store, stats: RunStats, harvest) -> None:
"""Mirror the contract and the last run to data/corpus/collection.json."""
body = {
"collection": cfg.collection,
"contract": _contract(cfg),
"qdrant_url": cfg.qdrant_url,
"chunks": store.chunk_count(),
"sources": store.source_count(),
"graph_collection": cfg.graph_collection,
"graph": store.graph_counts(),
"platforms": harvest.sources,
"rejections": harvest.rejection_summary(),
"images": len(harvest.images),
"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 the sources."""
log = log or (lambda _msg: None)
problems = []
harvest = estate_module.harvest(cfg)
documents = harvest.documents
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))
on_disk = {doc.path: doc for doc in documents}
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) - set(on_disk)):
problems.append(f"{source}: indexed but no longer in content/ or the estate (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"
)
# Graph: every row must resolve to a note, and Qdrant must agree.
counts = store.graph_counts()
graph_meta = store.get_meta("graph") or {}
if harvest.graph is not None:
if not graph_meta:
problems.append("graph: graph.json is available but the graph was never indexed")
if qdrant.collection_exists(cfg.graph_collection):
graph_points = qdrant.point_count(cfg.graph_collection)
if graph_points != counts["nodes"] + counts["edges"]:
problems.append(
f"graph: {graph_points} point(s) in Qdrant but "
f"{counts['nodes'] + counts['edges']} row(s) in SQLite"
)
elif counts["nodes"] or counts["edges"]:
problems.append(f"graph: collection {cfg.graph_collection!r} does not exist")
for row in store.db.execute("SELECT id, source_note, origin_url FROM graph_nodes LIMIT 5"):
if not row["source_note"] or not row["origin_url"]:
problems.append(f"graph node {row['id']}: no source note or origin")
missing_notes = [
row["id"]
for row in store.db.execute(
"SELECT id FROM graph_nodes WHERE source_note = '' OR origin_url = '' LIMIT 10"
)
]
if missing_notes:
problems.append(f"graph: {len(missing_notes)} node(s) carry no source note")
log(
f"verify: {store.chunk_count()} chunk(s) across {store.source_count()} source(s), "
f"{total_points} vector(s) in Qdrant, {counts['nodes']} node(s), {counts['edges']} edge(s)"
)
for warning in harvest.warnings:
problems.append(f"estate warning: {warning}")
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"),
"platform": payload.get("platform"),
"tier": payload.get("tier"),
"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, platform: str = None, tier: int = None) -> 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, platform=platform, tier=tier)
):
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, platform=platform, tier=tier, 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
def scan_pool(cfg) -> dict:
"""Run the credential scan without writing anything. Used by `make corpus-scan`."""
with store_module.Store(cfg.db_path) as store:
hits = []
texts = store.pool_texts()
for source_path, chunk_index, text in texts:
for kind, line, _excerpt in exclusions.scan_text(text):
hits.append(
{"source_path": source_path, "chunk": chunk_index, "kind": kind, "line": line}
)
try:
_scan_graph(store)
except PipelineError as exc:
hits.append({"source_path": "graph", "kind": "credential-shaped content", "detail": str(exc)})
chunks = len(texts)
return {"ok": not hits, "hits": hits, "chunks": chunks}

277
corpus/qdrant.py Normal file
View File

@@ -0,0 +1,277 @@
"""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,
platform: str = None,
tier: int = 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, platform, tier)
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, platform: str = None, tier: int = None
) -> list:
conditions = []
if area:
conditions.append({"key": "area", "match": {"value": area}})
if confidence:
conditions.append({"key": "confidence", "match": {"value": confidence}})
if platform:
conditions.append({"key": "platform", "match": {"value": platform}})
if tier is not None:
conditions.append({"key": "tier", "match": {"value": int(tier)}})
return conditions

848
corpus/sources/estate.py Normal file
View File

@@ -0,0 +1,848 @@
"""Harvest the estate into one set of documents.
Four sources, one shape (`loader.Document`), one tier rule:
| Platform | Tier | Reached by |
|---|---|---|
| `curated` (`content/`) | 1 | the local repository, already validated |
| `gitea` | 2 | the Gitea API |
| `obsidian` | 2 | the same Gitea API, one repository |
| `graphify` | 2 | `graph.json` committed to the vault repository |
`content/` is loaded first and is never displaced: the query layer uses `tier`
to prefer a curated claim over harvested text that disagrees with it.
Every refused file is recorded with the rule that refused it. The DoD asks for
this explicitly ("the run reports the rejection"), and it is the difference
between an exclusion list that works and one nobody can audit.
"""
from __future__ import annotations
import datetime
import hashlib
import json
import os
import re
from .. import areas, exclusions, globs, guides, loader
from . import graph as graph_module
from .gitea import GiteaClient, GiteaError, GiteaNotFound, file_url, repo_url
PLATFORM_ORDER = ("curated", "gitea", "obsidian", "graphify")
class EstateError(RuntimeError):
"""The estate could not be read. The run stops rather than indexing half of it."""
class Harvest:
def __init__(self) -> None:
self.documents: list = []
self.rejections: list = []
self.images: list = []
self.warnings: list = []
self.sources: dict = {}
self.graph = None # graph_module.GraphResult | None
self.offline = False
# -- reporting --------------------------------------------------------
def add_rejection(self, rejection) -> None:
self.rejections.append(rejection.to_dict() if hasattr(rejection, "to_dict") else rejection)
def rejection_summary(self) -> dict:
counts = {}
for row in self.rejections:
reason = str(row.get("reason") or "unknown")
# Keep the summary readable: 200 distinct oversize byte counts are
# one finding, not two hundred.
if reason.startswith("oversize"):
reason = "oversize"
elif reason.startswith("binary"):
reason = "binary"
key = f"{row.get('platform')}/{reason}"
counts[key] = counts.get(key, 0) + 1
return dict(sorted(counts.items()))
def to_dict(self) -> dict:
return {
"documents": len(self.documents),
"images": len(self.images),
"rejections": len(self.rejections),
"rejection_summary": self.rejection_summary(),
"warnings": list(self.warnings),
"sources": self.sources,
"graph": self.graph.counts() if self.graph else None,
"offline": self.offline,
}
def harvest(cfg, log=None) -> Harvest:
"""Read every enabled platform. Curated content is always read."""
log = log or (lambda _msg: None)
result = Harvest()
result.offline = bool(cfg.offline)
curated = loader.load_documents(cfg)
result.documents.extend(curated)
result.sources["curated"] = _source_row(
"curated", enabled=True, documents=curated, extra={"path": "content/"}
)
log(f"curated: {len(curated)} document(s) from content/")
if cfg.offline:
# Not a warning: offline is a deliberate mode, not a failure.
log("offline mode: only content/ was read (RESUME_OFFLINE=1)")
return result
all_guides = guides.load_guides(cfg.root)
selected = {
name: guide
for name, guide in all_guides.items()
if guide.enabled and name in cfg.platforms
}
missing = [name for name in cfg.platforms if name not in all_guides]
if missing:
result.warnings.append(f"platform(s) {missing} have no guide and were skipped")
needs_gitea = {"gitea", "obsidian", "graphify"} & set(selected)
if not needs_gitea:
return result
base = cfg.gitea_url or selected.get("gitea", all_guides.get("gitea")).base
client = GiteaClient(base, cfg.gitea_token, timeout=cfg.timeout)
try:
repos = client.repos()
except GiteaError as exc:
raise EstateError(f"cannot list Gitea repositories: {exc}") from exc
log(f"gitea: {len(repos)} repositories visible")
vault_repo = _vault_repo(selected)
vault_tree = None
if vault_repo:
try:
vault_tree = client.tree(vault_repo, "main")
except GiteaNotFound:
try:
vault_tree = client.tree(vault_repo, "master")
except GiteaError as exc:
raise EstateError(f"cannot read {vault_repo}: {exc}") from exc
except GiteaError as exc:
raise EstateError(f"cannot read {vault_repo}: {exc}") from exc
if "gitea" in selected:
_harvest_repos(result, client, selected["gitea"], repos, base, log)
if "obsidian" in selected:
_harvest_obsidian(result, client, selected["obsidian"], repos, vault_repo, vault_tree, cfg, base, log)
if "graphify" in selected:
_harvest_graph(result, client, selected["graphify"], repos, vault_repo, vault_tree, cfg, base, log)
return result
# -- gitea repositories ---------------------------------------------------
def _harvest_repos(result: Harvest, client, guide, repos, base_url, log) -> None:
excluded = set(_as_list((guide.exclude or {}).get("repos")))
include = (guide.include or {}).get("repos") or "all"
wanted = None if include == "all" or _as_list(include) == [] else set(_as_list(include))
documents = []
seen_repos = set()
for repo in repos:
full_name = repo.get("full_name") or ""
if not full_name or full_name in excluded:
continue
if wanted is not None and full_name not in wanted:
continue
seen_repos.add(full_name)
try:
documents.extend(_harvest_one_repo(result, client, guide, repo, base_url))
except GiteaError as exc:
result.warnings.append(f"gitea/{full_name}: {exc}")
continue
if len(documents) >= guide.limit("max_documents"):
result.warnings.append(
f"gitea: document limit {guide.limit('max_documents')} reached; "
"remaining repositories were skipped"
)
break
result.documents.extend(documents)
result.sources["gitea"] = _source_row(
"gitea", enabled=True, documents=documents,
extra={"repos": len(seen_repos), "base": base_url},
)
log(f"gitea: {len(documents)} document(s), {len(result.rejections)} rejection(s) so far")
def _harvest_one_repo(result: Harvest, client, guide, repo, base_url) -> list:
full_name = repo["full_name"]
branch = repo.get("default_branch") or "main"
updated = _timestamp(repo.get("updated_at"))
created = _timestamp(repo.get("created_at"))
area = areas.area_for_repo(repo, guide.fits)
topics = [str(topic) for topic in (repo.get("topics") or [])]
description = str(repo.get("description") or "").strip()
confidence = "abandoned" if repo.get("archived") else "historic"
documents = []
try:
entries = client.tree(full_name, branch)
except GiteaNotFound:
entries = []
blobs = [entry for entry in entries if entry.get("type") == "blob"]
admitted = []
for entry in blobs:
path = globs.normalise(entry.get("path") or "")
kind, reason = exclusions.classify(path, guide, size=entry.get("size"))
if kind == "reject":
result.add_rejection(exclusions.Rejection(path, "gitea", reason, full_name))
continue
if kind == "image":
result.images.append(
{"path": path, "platform": "gitea", "repo": full_name,
"origin_url": file_url(base_url, full_name, branch, path)}
)
continue
admitted.append(entry)
# The file tree is paths only. It is what lets a question like "does he have
# a Prometheus config anywhere?" find a repository whose README never says so.
tree_doc = _tree_document(guide, repo, area, updated, admitted, base_url)
if tree_doc:
documents.append(tree_doc)
# README first: it is the document a reader would open.
readmes = [
entry for entry in admitted
if _is_readme(entry.get("path") or "") and globs.normalise(entry.get("path") or "").count("/") <= 1
][:3]
used = set()
for entry in readmes:
doc = _file_document(
result, client, guide, repo, branch, area, updated, created,
confidence, description, topics, entry, base_url,
)
if doc:
documents.append(doc)
used.add(globs.normalise(entry["path"]))
# Selected source files: the evidence behind the README's claims.
candidates = [
entry for entry in admitted
if globs.normalise(entry.get("path") or "") not in used
and not _is_readme(entry.get("path") or "")
and _source_rank(entry.get("path") or "")
]
candidates.sort(key=lambda entry: (_depth(entry.get("path") or ""), globs.normalise(entry["path"])))
for entry in candidates[: guide.limit("max_files_per_repo")]:
doc = _file_document(
result, client, guide, repo, branch, area, updated, created,
confidence, description, topics, entry, base_url,
)
if doc:
documents.append(doc)
# One document per repository for commits and one for languages. These are
# short, stable, and answer "what is being worked on?" without a filesystem.
commits = _commit_document(result, client, guide, repo, area, updated, created, confidence, base_url)
if commits:
documents.append(commits)
languages = _language_document(result, client, guide, repo, area, updated, created, confidence, base_url)
if languages:
documents.append(languages)
if not any(not doc.source_id.endswith(("@tree", "@commits", "@languages")) for doc in documents):
# No README and no admitted source file: still admit a summary so the
# repository is discoverable and its silence is visible, not invisible.
summary = _summary_document(guide, repo, area, updated, created, confidence, description, topics, base_url)
if summary:
documents.insert(0, summary)
return documents
def _file_document(result, client, guide, repo, branch, area, updated, created, confidence, description, topics, entry, base_url) -> object:
path = globs.normalise(entry.get("path") or "")
full_name = repo["full_name"]
try:
raw = client.raw(full_name, branch, path)
except GiteaError as exc:
result.warnings.append(f"gitea/{full_name}/{path}: {exc}")
return None
if exclusions.looks_binary(raw):
result.add_rejection(exclusions.Rejection(path, "gitea", "binary content", full_name))
return None
text = raw.decode("utf-8", "replace")
if not text.strip():
result.add_rejection(exclusions.Rejection(path, "gitea", "empty file", full_name))
return None
hits = exclusions.scan_text(text)
if hits:
kinds = sorted({hit[0] for hit in hits})
result.add_rejection(
exclusions.Rejection(path, "gitea", "credential-shaped content", full_name + ": " + ", ".join(kinds))
)
return None
kind = "code" if exclusions.extension(path) not in ("md", "markdown", "mdx", "txt", "rst") else "markdown"
title = f"{full_name} — {path}"
body = _strip_front_matter(text) if kind == "markdown" else text
if not body.strip():
result.add_rejection(exclusions.Rejection(path, "gitea", "front-matter only", full_name))
return None
if description or topics:
header = [f"Repository: {full_name}"]
if description:
header.append(description)
if topics:
header.append("Topics: " + ", ".join(topics))
body = "\n\n".join(["\n".join(header), text])
return _make_document(
key=f"gitea:{full_name}/{path}",
platform="gitea",
area=area,
title=title,
claim=_claim(text, title),
body=body,
digest=hashlib.sha256(raw).hexdigest(),
origin_url=file_url(base_url, full_name, branch, path),
path=path,
repo_path=path,
source_id=f"{full_name}/{path}",
source_type="code" if kind == "code" else "doc",
last_modified=updated,
confidence=confidence,
period=_period(created, updated),
tags=topics + ["gitea", area],
tech=[],
public=guide.public,
kind=kind,
)
def _tree_document(guide, repo, area, updated, blobs, base_url) -> object:
if not blobs:
return None
full_name = repo["full_name"]
branch = repo.get("default_branch") or "main"
limit = guide.limit("max_tree_entries")
paths = [globs.normalise(entry.get("path") or "") for entry in blobs][:limit]
body = "\n".join(
[f"Repository: {full_name}", f"Branch: {branch}", f"Files: {len(blobs)}", "", "File tree:"]
+ [f"- {path}" for path in paths]
)
if len(blobs) > limit:
body += f"\n- … {len(blobs) - limit} more"
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
return _make_document(
key=f"gitea:{full_name}@tree",
platform="gitea",
area=area,
title=f"{full_name} — file tree",
claim=f"File tree of {full_name}, {len(blobs)} files.",
body=body,
digest=digest,
origin_url=repo_url(base_url, full_name),
path="",
repo_path="",
source_id=f"{full_name}@tree",
source_type="doc",
last_modified=updated,
confidence="abandoned" if repo.get("archived") else "historic",
period="",
tags=["gitea", "tree", area],
tech=[],
public=guide.public,
kind="markdown",
)
def _commit_document(result, client, guide, repo, area, updated, created, confidence, base_url) -> object:
full_name = repo["full_name"]
branch = repo.get("default_branch") or "main"
try:
commits = client.commits(full_name, branch, limit=guide.limit("max_commits"))
except GiteaError:
return None
if not commits:
return None
lines = [f"Repository: {full_name}", f"Recent commits on {branch}:", ""]
newest = updated
for item in commits:
commit = item.get("commit") or {}
message = (commit.get("message") or "").strip().splitlines()
headline = message[0] if message else "(no message)"
date = _timestamp(((commit.get("author") or {}).get("date")) or ((commit.get("committer") or {}).get("date")))
if date:
newest = max(newest, date)
lines.append(f"- {date[:10]} {headline}")
body = "\n".join(lines)
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
return _make_document(
key=f"gitea:{full_name}@commits",
platform="gitea",
area=area,
title=f"{full_name} — recent commits",
claim=f"Recent commit history for {full_name}.",
body=body,
digest=digest,
origin_url=f"{repo_url(base_url, full_name)}/commits/branch/{branch}",
path="",
repo_path="",
source_id=f"{full_name}@commits",
source_type="commit",
last_modified=newest,
confidence=confidence,
period=_period(created, newest),
tags=["gitea", "commits", area],
tech=[],
public=guide.public,
kind="markdown",
)
def _language_document(result, client, guide, repo, area, updated, created, confidence, base_url) -> object:
full_name = repo["full_name"]
try:
languages = client.languages(full_name)
except GiteaError:
return None
if not languages:
return None
total = sum(int(value) for value in languages.values()) or 1
rows = sorted(languages.items(), key=lambda item: int(item[1]), reverse=True)
lines = [f"Repository: {full_name}", "", "Language statistics:"]
for name, size in rows:
lines.append(f"- {name}: {int(size)} bytes ({100.0 * int(size) / total:.1f}%)")
body = "\n".join(lines)
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
return _make_document(
key=f"gitea:{full_name}@languages",
platform="gitea",
area=area,
title=f"{full_name} — languages",
claim=f"Language breakdown for {full_name}: " + ", ".join(name for name, _ in rows[:4]) + ".",
body=body,
digest=digest,
origin_url=repo_url(base_url, full_name),
path="",
repo_path="",
source_id=f"{full_name}@languages",
source_type="metric",
last_modified=updated,
confidence=confidence,
period=_period(created, updated),
tags=["gitea", "languages", area],
tech=[name for name, _ in rows[:6]],
public=guide.public,
kind="markdown",
)
def _summary_document(guide, repo, area, updated, created, confidence, description, topics, base_url) -> object:
full_name = repo["full_name"]
body = "\n".join(
[f"Repository: {full_name}", f"Description: {description or '(none)'}",
f"Topics: {', '.join(topics) if topics else '(none)'}"]
)
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
return _make_document(
key=f"gitea:{full_name}@summary",
platform="gitea",
area=area,
title=full_name,
claim=description or f"Repository {full_name}.",
body=body,
digest=digest,
origin_url=repo_url(base_url, full_name),
path="",
repo_path="",
source_id=f"{full_name}@summary",
source_type="doc",
last_modified=updated,
confidence=confidence,
period=_period(created, updated),
tags=topics + ["gitea", area],
tech=[],
public=guide.public,
kind="markdown",
)
# -- obsidian vault -------------------------------------------------------
def _harvest_obsidian(result, client, guide, repos, vault_repo, tree, cfg, base_url, log) -> None:
if not vault_repo or tree is None:
result.warnings.append("obsidian: the vault repository could not be listed")
result.sources["obsidian"] = _source_row("obsidian", enabled=True, documents=[], extra={"missing": True})
return
repo = next((row for row in repos if row.get("full_name") == vault_repo), {})
branch = repo.get("default_branch") or "main"
updated = _timestamp(repo.get("updated_at"))
created = _timestamp(repo.get("created_at"))
validator = loader.load_validator(cfg.root)
documents = []
blobs = [entry for entry in tree if entry.get("type") == "blob"]
for entry in blobs:
if len(documents) >= guide.limit("max_documents"):
result.warnings.append(
f"obsidian: document limit {guide.limit('max_documents')} reached"
)
break
path = globs.normalise(entry.get("path") or "")
kind, reason = exclusions.classify(path, guide, size=entry.get("size"))
if kind == "reject":
# A path outside the include list is expected and is not a finding;
# only hard exclusions are reported, so the report stays readable.
if reason != "not under an included path":
result.add_rejection(exclusions.Rejection(path, "obsidian", reason))
continue
if kind == "image":
result.images.append(
{"path": path, "platform": "obsidian",
"origin_url": file_url(base_url, vault_repo, branch, path)}
)
continue
try:
raw = client.raw(vault_repo, branch, path)
except GiteaError as exc:
result.warnings.append(f"obsidian/{path}: {exc}")
continue
if exclusions.looks_binary(raw):
result.add_rejection(exclusions.Rejection(path, "obsidian", "binary content"))
continue
text = raw.decode("utf-8", "replace")
if not text.strip():
result.add_rejection(exclusions.Rejection(path, "obsidian", "empty file"))
continue
hits = exclusions.scan_text(text)
if hits:
result.add_rejection(
exclusions.Rejection(
path, "obsidian", "credential-shaped content",
", ".join(sorted({hit[0] for hit in hits})),
)
)
continue
fields, body = _split_note(text, validator)
if not body.strip():
result.add_rejection(exclusions.Rejection(path, "obsidian", "empty note"))
continue
title = str(fields.get("title") or _heading(body) or os.path.splitext(os.path.basename(path))[0])
tag_values = fields.get("tags") or []
if isinstance(tag_values, str):
tag_values = [tag_values]
tags = [str(tag) for tag in tag_values] + _folder_tags(path)
area = areas.area_for_note(path, guide.fits)
documents.append(
_make_document(
key=f"obsidian:{path}",
platform="obsidian",
area=area,
title=title,
claim=_claim(body, title),
body=body,
digest=hashlib.sha256(raw).hexdigest(),
origin_url=file_url(base_url, vault_repo, branch, path),
path=path,
repo_path=path,
source_id=path,
source_type="doc",
last_modified=updated,
confidence="historic",
period=_period(created, updated),
tags=tags + ["obsidian", area],
tech=[],
public=guide.public,
kind="markdown",
)
)
result.documents.extend(documents)
result.sources["obsidian"] = _source_row(
"obsidian", enabled=True, documents=documents,
extra={"repo": vault_repo, "base": base_url},
)
log(f"obsidian: {len(documents)} note(s)")
# -- graphify -------------------------------------------------------------
def _harvest_graph(result, client, guide, repos, vault_repo, tree, cfg, base_url, log) -> None:
if not vault_repo:
result.warnings.append("graphify: no vault repository configured in the guide")
result.sources["graphify"] = _source_row("graphify", enabled=True, documents=[], extra={"missing": True})
return
repo = next((row for row in repos if row.get("full_name") == vault_repo), {})
branch = repo.get("default_branch") or "main"
updated = _timestamp(repo.get("updated_at"))
vault_paths = {
globs.normalise(entry.get("path") or "")
for entry in (tree or [])
if entry.get("type") == "blob"
}
raw = None
source_path = ""
if cfg.graph_path:
if not os.path.exists(cfg.graph_path):
raise EstateError(f"RESUME_GRAPH_PATH is set but {cfg.graph_path} does not exist")
with open(cfg.graph_path, "rb") as handle:
raw = handle.read()
source_path = cfg.graph_path
else:
for candidate in guide.limits.get("graph_paths") or ["graph.json"]:
try:
raw = client.raw(vault_repo, branch, candidate)
source_path = f"gitea:{vault_repo}/{candidate}"
break
except GiteaNotFound:
continue
except GiteaError as exc:
raise EstateError(f"cannot read graph.json from {vault_repo}: {exc}") from exc
if raw is None:
result.warnings.append(
f"graphify: graph.json is not committed to {vault_repo} "
f"(looked in {guide.limits.get('graph_paths')}). The graph was not ingested; "
"the nightly audit will keep reporting this until it is committed."
)
result.sources["graphify"] = _source_row(
"graphify", enabled=True, documents=[], extra={"missing": True, "repo": vault_repo}
)
return
graph = graph_module.parse(
raw,
platform="graphify",
base=base_url,
repo=vault_repo,
ref=branch,
last_modified=updated,
area=guide.primary_area,
vault_paths=vault_paths,
max_nodes=guide.limit("max_nodes"),
max_edges=guide.limit("max_edges"),
source_path=source_path,
)
result.graph = graph
for rejection in graph.rejections:
result.add_rejection(rejection)
result.images.extend(graph.images)
result.sources["graphify"] = _source_row(
"graphify", enabled=True, documents=[],
extra={
"repo": vault_repo,
"path": source_path,
"nodes": len(graph.nodes),
"edges": len(graph.edges),
"digest": graph.digest,
"built_at_commit": graph.built_at_commit,
},
)
if graph.warnings:
result.warnings.extend(f"graphify: {warning}" for warning in graph.warnings)
log(
f"graphify: {len(graph.nodes)} node(s), {len(graph.edges)} edge(s), "
f"{len(graph.images)} image node(s)"
)
# -- shared helpers -------------------------------------------------------
def _make_document(**values) -> loader.Document:
"""One Document constructor, so every source produces the same shape."""
defaults = dict(
metrics=[], artefacts=[], tech=[], tags=[], public=True, kind="markdown",
platform="gitea", tier=2, level=3, period="", source_type="doc",
)
defaults.update(values)
title = defaults["title"]
origin = defaults["origin_url"]
path = defaults.get("path") or ""
return loader.Document(
path=defaults["key"],
source_id=defaults.get("source_id") or (path or defaults["key"]),
repo_path=defaults.get("repo_path") or path,
area=defaults["area"],
level=defaults.get("level", 3),
title=title,
claim=defaults.get("claim") or title,
confidence=defaults.get("confidence") or "historic",
period=defaults.get("period") or "—",
tech=defaults.get("tech") or [],
metrics=defaults.get("metrics") or [],
artefacts=defaults.get("artefacts") or [],
public=bool(defaults.get("public", True)),
last_verified=str(defaults["last_modified"])[:10],
tags=defaults.get("tags") or [],
body=defaults["body"],
digest=defaults["digest"],
url=origin,
platform=defaults["platform"],
tier=int(defaults.get("tier", 2)),
origin_url=origin,
last_modified=defaults["last_modified"],
source_type=defaults.get("source_type") or "doc",
kind=defaults.get("kind") or "markdown",
)
def _strip_front_matter(text: str) -> str:
"""Drop leading YAML blocks. The Note path parses them; files only need them gone.
Some notes carry two blocks in a row (a stale export, or a block followed by
a horizontal rule that closes), so this repeats. It never strips a body that
does not have a closing delimiter.
"""
body = text
for _ in range(3):
lines = body.splitlines()
if not lines or lines[0].strip() != "---":
return body
closing = None
for index in range(1, len(lines)):
if lines[index].strip() in ("---", "..."):
closing = index
break
if closing is None:
return body
body = "\n".join(lines[closing + 1 :]).strip()
return body
def _split_note(text: str, validator) -> tuple:
"""Tolerant front-matter split. A note without front-matter is still a note."""
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return {}, text
end = None
for index in range(1, len(lines)):
if lines[index].strip() in ("---", "..."):
end = index
break
if end is None:
return {}, text
fields = {}
try:
node = validator.parse_front_matter(
[(number, line) for number, line in enumerate(lines[1:end], start=2)]
)
parsed = loader.to_python(node)
if isinstance(parsed, dict):
fields = parsed
except validator.YamlError:
fields = {}
# Strip any further leading block as well, so a duplicate front-matter block
# never reaches the chunker.
return fields, _strip_front_matter("\n".join(lines[end + 1 :]).strip())
def _source_row(name: str, enabled: bool, documents: list, extra: dict = None) -> dict:
stamps = sorted(doc.last_modified for doc in documents if doc.last_modified)
row = {
"platform": name,
"enabled": enabled,
"documents": len(documents),
"newest": stamps[-1] if stamps else "",
"oldest": stamps[0] if stamps else "",
"fetched_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
row.update(extra or {})
return row
def _vault_repo(selected: dict) -> str:
for name in ("obsidian", "graphify"):
guide = selected.get(name)
if guide and guide.base and "/" in guide.base and not guide.base.startswith("http"):
return guide.base
return ""
def _as_list(value) -> list:
if value in (None, ""):
return []
if isinstance(value, list):
return [str(item) for item in value]
return [str(value)]
def _timestamp(value) -> str:
"""Normalise any RFC3339 timestamp to UTC `…Z`. Empty stays empty."""
if not value:
return "1970-01-01T00:00:00Z"
text = str(value).strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
moment = datetime.datetime.fromisoformat(text)
except ValueError:
return "1970-01-01T00:00:00Z"
if moment.tzinfo is None:
moment = moment.replace(tzinfo=datetime.timezone.utc)
return moment.astimezone(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _period(created: str, updated: str) -> str:
start = (created or "")[:4]
end = (updated or "")[:4]
if not start or start == "1970":
return end or "—"
if start == end:
return start
return f"{start}–{end}"
def _claim(text: str, title: str) -> str:
for line in (text or "").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith(("#", "|", "```", "---", ">")):
continue
cleaned = re.sub(r"[*_`\[\]]", "", stripped)
if len(cleaned) > 20:
return cleaned[:180]
return title[:180]
def _heading(text: str) -> str:
for line in (text or "").splitlines():
if line.startswith("# "):
return line[2:].strip()
return ""
def _folder_tags(path: str) -> list:
parts = [part for part in path.split("/")[:-1] if part and not part[0].isdigit()]
return [part.lower() for part in parts[-2:]]
def _is_readme(path: str) -> bool:
return os.path.basename(path).lower().startswith("readme")
def _depth(path: str) -> int:
return globs.normalise(path).count("/")
def _source_rank(path: str) -> bool:
ext = exclusions.extension(path)
if ext not in exclusions.TEXT_EXTENSIONS:
return False
if ext in ("json", "csv", "tsv", "svg"):
return False
# Prefer files that carry decisions over generated or vendored files.
return not any(
part.lower() in ("vendor", "third_party", "examples", "testdata", "fixtures", "migrations")
for part in globs.normalise(path).split("/")[:-1]
)

165
corpus/sources/gitea.py Normal file
View File

@@ -0,0 +1,165 @@
"""Gitea API client — the only network dependency in the pipeline.
`docs/phase-4-corpus.md` §1: Gitea is reached over HTTPS, and the vault and the
graph arrive through the same API, so there is no cross-machine copy and no SSH
from the runtime host. This client is standard library only (`urllib`), it never
prints or stores the token, and every call names only the path it requested.
Read-only by construction: there is no method here that writes, creates, deletes
or changes anything in Gitea. The token only needs read scope.
"""
from __future__ import annotations
import http.client
import json
import time
import urllib.error
import urllib.parse
import urllib.request
class GiteaError(RuntimeError):
"""A Gitea call failed. The message never contains the token."""
class GiteaNotFound(GiteaError):
"""The requested repository, branch or file does not exist."""
class GiteaClient:
def __init__(self, base: str, token: str, timeout: float = 30.0, attempts: int = 3) -> None:
if not base:
raise GiteaError("a Gitea base URL is required (GITEA_URL or the guide's base)")
if not token:
raise GiteaError(
"a Gitea token is required. It is read from the environment variable "
"named by the platform guide (GITEA_TOKEN) and never from this repository."
)
self.base = base.rstrip("/")
self._token = token
self.timeout = timeout
self.attempts = max(1, attempts)
self.calls = 0
# -- transport --------------------------------------------------------
def _request(self, path: str, expect_bytes: bool = False):
"""One GET with retry/backoff. The only place that touches the network."""
url = f"{self.base}/api/v1{path}"
last_error = None
for attempt in range(self.attempts):
request = urllib.request.Request(
url,
method="GET",
headers={
"Authorization": f"token {self._token}",
"Accept": "application/vnd.gitea+json, application/json",
"User-Agent": "ai-resume-corpus/1",
},
)
try:
self.calls += 1
with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = response.read()
if expect_bytes:
return raw
return json.loads(raw.decode("utf-8")) if raw.strip() else {}
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:200]
exc.close()
if exc.code == 404:
raise GiteaNotFound(f"GET {path}: not found") from None
if exc.code == 401:
raise GiteaError(
f"GET {path}: authentication failed. Check the token named by the "
"platform guide; it is not printed here."
) from None
if exc.code == 403:
raise GiteaError(f"GET {path}: forbidden (the token may lack read scope)") from None
if exc.code != 429 and exc.code < 500:
raise GiteaError(f"GET {path}: HTTP {exc.code} {detail}") from None
last_error = f"HTTP {exc.code}"
except (urllib.error.URLError, TimeoutError, http.client.HTTPException, ConnectionError) as exc:
last_error = f"cannot reach {self.base}: {getattr(exc, 'reason', exc)}"
if attempt + 1 < self.attempts:
time.sleep(0.5 * (2 ** attempt))
raise GiteaError(f"GET {path} failed after {self.attempts} attempts: {last_error}")
# -- repositories -----------------------------------------------------
def repos(self, limit: int = 50) -> list:
"""Every repository the token can read, sorted by full name."""
out = []
page = 1
while True:
response = self._request(f"/repos/search?limit={limit}&page={page}")
rows = response.get("data") or []
out.extend(rows)
if len(rows) < limit:
break
page += 1
if page > 200: # a guard, not a limit anyone should reach
break
return sorted(out, key=lambda repo: repo.get("full_name", ""))
def repo(self, full_name: str) -> dict:
return self._request(f"/repos/{_repo_path(full_name)}")
# -- contents ---------------------------------------------------------
def tree(self, full_name: str, ref: str) -> list:
"""Recursive blob/tree listing. Returns every entry, following pages."""
entries = []
page = 1
while True:
response = self._request(
f"/repos/{_repo_path(full_name)}/git/trees/{urllib.parse.quote(ref, safe='')}"
f"?recursive=true&per_page=1000&page={page}"
)
rows = response.get("tree") or []
entries.extend(rows)
if not rows or not response.get("truncated") or len(rows) < 1000:
break
page += 1
if page > 50:
break
return entries
def raw(self, full_name: str, ref: str, path: str) -> bytes:
quoted = urllib.parse.quote(path, safe="/")
return self._request(
f"/repos/{_repo_path(full_name)}/raw/{quoted}?ref={urllib.parse.quote(ref, safe='')}",
expect_bytes=True,
)
def commits(self, full_name: str, ref: str, limit: int = 20) -> list:
return self._request(
f"/repos/{_repo_path(full_name)}/commits?sha={urllib.parse.quote(ref, safe='')}"
f"&limit={int(limit)}&page=1"
)
def languages(self, full_name: str) -> dict:
return self._request(f"/repos/{_repo_path(full_name)}/languages")
# -- presentation -----------------------------------------------------
def describe(self) -> dict:
return {"base": self.base, "token": "set", "calls": self.calls}
def file_url(base: str, full_name: str, ref: str, path: str) -> str:
"""The URL a reader can open: the file at its branch, in Gitea."""
return (
f"{base.rstrip('/')}/{full_name}/src/branch/{urllib.parse.quote(ref, safe='')}/"
f"{urllib.parse.quote(path, safe='/')}"
)
def repo_url(base: str, full_name: str) -> str:
return f"{base.rstrip('/')}/{full_name}"
def _repo_path(full_name: str) -> str:
owner, _, name = full_name.partition("/")
return f"{urllib.parse.quote(owner, safe='')}/{urllib.parse.quote(name, safe='')}"

335
corpus/sources/graph.py Normal file
View File

@@ -0,0 +1,335 @@
"""Graphify output: nodes and edges, each joined back to the note it came from.
`docs/phase-4-corpus.md` §4: *"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."*
The graph is not rebuilt here. It already exists and is rebuilt on the build host
by its own skill; this module consumes `graph.json`.
Two rules from §3 drive the whole file:
* **A node with no resolvable source note is refused, not defaulted.** Graphify
emits nodes for files outside the vault (`settings.json`, `.pi/…`). Those are
not traceable to a note, so they are rejected and counted.
* **Media never becomes text.** A media node becomes an image reference: path,
origin URL and note, and nothing else.
"""
from __future__ import annotations
import hashlib
import json
import re
import uuid
from .. import areas, exclusions, globs
from ..sources.gitea import file_url
class GraphError(RuntimeError):
"""graph.json is not a graph this pipeline can admit."""
class GraphResult:
def __init__(self, platform: str = "graphify"):
self.platform = platform
self.nodes: list = []
self.edges: list = []
self.images: list = []
self.rejections: list = []
self.warnings: list = []
self.digest = ""
self.source_path = ""
self.built_at_commit = ""
def counts(self) -> dict:
return {
"nodes": len(self.nodes),
"edges": len(self.edges),
"images": len(self.images),
"rejected": len(self.rejections),
}
def parse(
raw: bytes,
*,
platform: str,
base: str,
repo: str,
ref: str,
last_modified: str,
area: str,
vault_paths: set,
max_nodes: int = 20000,
max_edges: int = 40000,
source_path: str = "",
) -> GraphResult:
"""Turn graph.json bytes into rows that carry their note. Never guesses."""
result = GraphResult(platform)
result.digest = hashlib.sha256(raw).hexdigest()
result.source_path = source_path
try:
data = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise GraphError(f"graph.json is not valid UTF-8 JSON: {exc}") from exc
if not isinstance(data, dict):
raise GraphError("graph.json must be a JSON object")
result.built_at_commit = str(data.get("built_at_commit") or "")
raw_nodes = data.get("nodes") or []
raw_links = data.get("links") or data.get("edges") or []
if not isinstance(raw_nodes, list) or not isinstance(raw_links, list):
raise GraphError("graph.json must contain `nodes` and `links` arrays")
labels = {}
notes = {}
for item in raw_nodes:
if not isinstance(item, dict):
continue
node_id = str(item.get("id") or "").strip()
if not node_id:
continue
labels[node_id] = str(item.get("label") or node_id)
notes[node_id] = _note_for(item, vault_paths)
for item in raw_nodes:
if len(result.nodes) >= max_nodes:
result.warnings.append(f"graph node limit {max_nodes} reached; remaining nodes skipped")
break
if not isinstance(item, dict):
continue
node_id = str(item.get("id") or "").strip()
if not node_id:
result.rejections.append(
_rejection("graph.json", platform, "graph node has no id")
)
continue
source_file = globs.normalise(item.get("source_file") or "")
note = notes.get(node_id)
if not note:
result.rejections.append(
_rejection(
source_file or f"node:{node_id}", platform,
"no resolvable source note", "graph node does not point at a file in the vault",
)
)
continue
if globs.normalise(source_file) != note:
# A node can be derived from one note while naming another file; the
# note is the traceable origin either way.
pass
if exclusions.classify(note, None)[0] == "image" or exclusions.extension(note) in exclusions.MEDIA_EXTENSIONS:
result.images.append(
{
"path": note,
"label": str(item.get("label") or node_id),
"origin_url": file_url(base, repo, ref, note),
"platform": platform,
"note": note,
}
)
continue
node_area = areas.area_for(note, (area,), fallback=area)
label = str(item.get("label") or node_id)
content_hash = _hash(
{"kind": "node", "id": node_id, "label": label, "note": note,
"community": item.get("community"), "type": item.get("node_type")}
)
text = (
f"{label}. Knowledge graph node"
+ (f" in community {item.get('community')}" if item.get("community") is not None else "")
+ f", from the note {note}."
)
result.nodes.append(
{
"id": node_id,
"point_id": _point_id("node", node_id, content_hash),
"graph_id": node_id,
"label": label,
"norm_label": str(item.get("norm_label") or _norm(label)),
"node_type": str(item.get("node_type") or item.get("file_type") or "entity"),
"community": item.get("community"),
"source_note": note,
"origin_url": file_url(base, repo, ref, note),
"area": node_area,
"tier": 2,
"platform": platform,
"last_modified": last_modified,
"content_hash": content_hash,
"text": text,
}
)
admitted_nodes = {row["graph_id"]: row for row in result.nodes}
for item in raw_links:
if len(result.edges) >= max_edges:
result.warnings.append(f"graph edge limit {max_edges} reached; remaining edges skipped")
break
if not isinstance(item, dict):
continue
source = str(item.get("source") or "").strip()
target = str(item.get("target") or "").strip()
if not source or not target:
result.rejections.append(_rejection("graph.json", platform, "graph edge has no endpoints"))
continue
note = notes.get(source) or _note_for(item, vault_paths)
if not note:
result.rejections.append(
_rejection(
f"edge:{source}->{target}", platform,
"no resolvable source note", "graph edge does not point at a file in the vault",
)
)
continue
source_label = labels.get(source, source)
target_label = labels.get(target, target)
relation = str(item.get("relation") or item.get("rel") or "related_to")
area_name = areas.area_for(note, (area,), fallback=area)
content_hash = _hash(
{"kind": "edge", "source": source, "target": target,
"relation": relation, "note": note}
)
text = f"{source_label} {relation} {target_label}, from the note {note}."
result.edges.append(
{
"id": _edge_id(source, relation, target, content_hash),
"point_id": _point_id("edge", f"{source}>{relation}>{target}", content_hash),
"source_id": source,
"target_id": target,
"relation": relation,
"confidence": str(item.get("confidence") or "INFERRED"),
"confidence_score": item.get("confidence_score"),
"source_note": note,
"origin_url": file_url(base, repo, ref, note),
"area": area_name,
"tier": 2,
"platform": platform,
"last_modified": last_modified,
"content_hash": content_hash,
"text": text,
}
)
# Edges whose endpoints were not admitted are still traceable and are kept;
# an answer can name the relation and its note even when the node itself had
# no origin. `admitted_node_ids` is exposed for verify, which reports the gap.
result.admitted_node_ids = set(admitted_nodes)
result.orphan_edges = [
edge for edge in result.edges
if edge["source_id"] not in admitted_nodes or edge["target_id"] not in admitted_nodes
]
return result
def point_payload(row: dict, kind: str) -> dict:
"""One Qdrant point payload for a node or edge. Same provenance as the row."""
payload = {
"platform": row["platform"],
"tier": row["tier"],
"source_type": "graph_node" if kind == "node" else "graph_edge",
"area": row["area"],
"origin_url": row["origin_url"],
"path": row["source_note"],
"source_note": row["source_note"],
"title": row.get("label") or row.get("relation") or row["source_note"],
"confidence": row.get("confidence") or "INFERRED",
"last_modified": row["last_modified"],
"content_hash": row["content_hash"],
"text": row["text"],
"kind": kind,
"graph_id": row.get("graph_id") or row.get("id"),
}
if kind == "node":
payload["community"] = row.get("community")
payload["node_type"] = row.get("node_type")
else:
payload["relation"] = row.get("relation")
payload["source_id"] = row.get("source_id")
payload["target_id"] = row.get("target_id")
payload["confidence_score"] = row.get("confidence_score")
return payload
def query(cfg, store, text: str, area: str = None, limit: int = 10) -> dict:
"""Graph query: keyword hits from SQLite, vector hits from Qdrant when up.
Returning both is deliberate: the keyword side always works, and the vector
side proves the graph collection is live. Each row carries `source_note`.
"""
from ..qdrant import Qdrant, QdrantError
report = {"query": text, "area": area, "keyword": [], "vector": [], "ok": True}
report["keyword"] = store.graph_search(text, area=area, limit=limit)
if not report["keyword"]:
report["ok"] = False
report["reason"] = "no graph rows matched the query"
qdrant = Qdrant(cfg.qdrant_url, timeout=cfg.timeout)
if not qdrant.health() or not qdrant.collection_exists(cfg.graph_collection):
report["vector"] = []
report["vector_note"] = f"graph collection {cfg.graph_collection} is not available"
return report
from .. import embeddings
provider = embeddings.build_provider(cfg)
try:
vector = provider.embed([text])[0]
hits = qdrant.search(cfg.graph_collection, vector, limit=limit, area=area)
except (QdrantError, Exception) as exc: # noqa: BLE001 - a graph query must not crash a shell
report["vector_note"] = f"graph vector search unavailable: {exc}"
return report
report["vector"] = [
{
"score": round(hit.get("score", 0.0), 4),
"kind": (hit.get("payload") or {}).get("kind"),
"label": (hit.get("payload") or {}).get("title"),
"source_note": (hit.get("payload") or {}).get("source_note"),
"origin_url": (hit.get("payload") or {}).get("origin_url"),
}
for hit in hits
]
return report
# -- helpers -------------------------------------------------------------
def _note_for(item: dict, vault_paths: set) -> str:
source = globs.normalise(item.get("source_file") or "")
if not source:
return ""
if source in vault_paths:
return source
# Graphify sometimes prefixes `./` or uses an absolute scan root. A suffix
# match is only accepted when it is unique, so a note is never guessed.
candidates = [path for path in vault_paths if path.endswith("/" + source)]
if len(candidates) == 1:
return candidates[0]
return ""
def _rejection(path: str, platform: str, reason: str, detail: str = ""):
rejection = exclusions.Rejection(path, platform, reason, detail)
return rejection.to_dict()
def _hash(value) -> str:
body = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(body).hexdigest()
def _point_id(kind: str, key: str, content_hash: str) -> str:
digest = hashlib.sha256(f"graph\x00{kind}\x00{key}\x00{content_hash}".encode("utf-8")).hexdigest()
return str(uuid.UUID(hex=digest[:32]))
def _edge_id(source: str, relation: str, target: str, content_hash: str) -> str:
return hashlib.sha256(
f"edge\x00{source}\x00{relation}\x00{target}\x00{content_hash}".encode("utf-8")
).hexdigest()[:32]
def _norm(label: str) -> str:
return re.sub(r"[^0-9a-z]+", "_", (label or "").lower()).strip("_")

479
corpus/store.py Normal file
View File

@@ -0,0 +1,479 @@
"""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 = "2"
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,
platform TEXT NOT NULL,
tier INTEGER NOT NULL,
origin_url TEXT NOT NULL,
path TEXT NOT NULL,
last_modified TEXT 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,
platform UNINDEXED,
tier 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
);
-- The knowledge graph is not chunked (docs/phase-4-corpus.md §4). Nodes and
-- edges live in their own tables and their own Qdrant collection, joined to note
-- chunks by source_note. Every row carries the note it came from.
CREATE TABLE IF NOT EXISTS graph_nodes (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
norm_label TEXT NOT NULL,
node_type TEXT NOT NULL,
community INTEGER,
source_note TEXT NOT NULL,
origin_url TEXT NOT NULL,
area TEXT NOT NULL,
tier INTEGER NOT NULL,
platform TEXT NOT NULL,
last_modified TEXT NOT NULL,
content_hash TEXT NOT NULL,
text TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS graph_nodes_note ON graph_nodes(source_note);
CREATE TABLE IF NOT EXISTS graph_edges (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
relation TEXT NOT NULL,
confidence TEXT NOT NULL,
confidence_score REAL,
source_note TEXT NOT NULL,
origin_url TEXT NOT NULL,
area TEXT NOT NULL,
tier INTEGER NOT NULL,
platform TEXT NOT NULL,
last_modified TEXT NOT NULL,
content_hash TEXT NOT NULL,
text TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS graph_edges_source ON graph_edges(source_id);
CREATE INDEX IF NOT EXISTS graph_edges_target ON graph_edges(target_id);
CREATE INDEX IF NOT EXISTS graph_edges_note ON graph_edges(source_note);
CREATE VIRTUAL TABLE IF NOT EXISTS graph_fts USING fts5(
id UNINDEXED,
kind UNINDEXED,
label,
text,
area UNINDEXED,
tokenize = 'porter unicode61 remove_diacritics 2'
);
"""
# 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",
"platform", "tier", "origin_url", "path", "last_modified",
"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)
stored = self.get_meta("schema_version")
# A database from an older payload shape has no provenance columns. It is
# derived data, so the fix is a rebuild, but it must never be silently
# mixed with the new shape: `rebuild_schema` is called by a rebuild only.
self.schema_stale = stored not in (None, SCHEMA_VERSION)
if not self.schema_stale:
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", "graph_fts", "graph_nodes", "graph_edges"):
self.db.execute(f"DELETE FROM {table}")
self.db.commit()
def rebuild_schema(self) -> None:
"""Drop and recreate every table. Only a rebuild may call this."""
for table in (
"chunk_fts", "chunks", "manifest", "meta",
"graph_fts", "graph_nodes", "graph_edges",
):
self.db.execute(f"DROP TABLE IF EXISTS {table}")
self.db.commit()
self.db.executescript(SCHEMA)
self.schema_stale = False
self.set_meta("schema_version", SCHEMA_VERSION)
# -- 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, platform, tier) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
cursor.lastrowid,
payload["id"],
payload["source_path"],
payload["title"],
terms,
payload["text"],
payload["area"],
payload["confidence"],
payload["platform"],
payload["tier"],
),
)
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,
platform: str = None, tier: int = 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)
if platform:
sql.append("AND c.platform = ?")
args.append(platform)
if tier is not None:
sql.append("AND c.tier = ?")
args.append(int(tier))
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]
def pool_texts(self) -> list:
"""Every chunk text in the pool. The credential scan reads this."""
return [
(row["source_path"], row["chunk_index"], row["text"])
for row in self.db.execute("SELECT source_path, chunk_index, text FROM chunks")
]
# -- graph ------------------------------------------------------------
def write_graph(self, nodes: list, edges: list) -> None:
"""Replace the whole graph. Nodes and edges are rebuilt together."""
self.db.execute("DELETE FROM graph_fts")
self.db.execute("DELETE FROM graph_nodes")
self.db.execute("DELETE FROM graph_edges")
for node in nodes:
self.db.execute(
"INSERT INTO graph_nodes (id, label, norm_label, node_type, community, "
"source_note, origin_url, area, tier, platform, last_modified, content_hash, text) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
node["id"], node["label"], node.get("norm_label") or _norm(node["label"]),
node.get("node_type") or "entity", node.get("community"),
node["source_note"], node["origin_url"], node["area"], node["tier"],
node["platform"], node["last_modified"], node["content_hash"], node["text"],
),
)
self.db.execute(
"INSERT INTO graph_fts (id, kind, label, text, area) VALUES (?, 'node', ?, ?, ?)",
(node["id"], node["label"], node["text"], node["area"]),
)
for edge in edges:
self.db.execute(
"INSERT INTO graph_edges (id, source_id, target_id, relation, confidence, "
"confidence_score, source_note, origin_url, area, tier, platform, "
"last_modified, content_hash, text) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
edge["id"], edge["source_id"], edge["target_id"], edge["relation"],
edge["confidence"], edge.get("confidence_score"), edge["source_note"],
edge["origin_url"], edge["area"], edge["tier"], edge["platform"],
edge["last_modified"], edge["content_hash"], edge["text"],
),
)
self.db.execute(
"INSERT INTO graph_fts (id, kind, label, text, area) VALUES (?, 'edge', ?, ?, ?)",
(edge["id"], edge["relation"], edge["text"], edge["area"]),
)
self.db.commit()
def graph_counts(self) -> dict:
return {
"nodes": int(self.db.execute("SELECT count(*) FROM graph_nodes").fetchone()[0]),
"edges": int(self.db.execute("SELECT count(*) FROM graph_edges").fetchone()[0]),
}
def graph_node(self, node_id: str) -> dict:
row = self.db.execute("SELECT * FROM graph_nodes WHERE id = ?", (node_id,)).fetchone()
return dict(row) if row else {}
def graph_neighbours(self, node_id: str, limit: int = 50) -> list:
rows = self.db.execute(
"SELECT e.*, sn.label AS source_label, tn.label AS target_label "
"FROM graph_edges e "
"LEFT JOIN graph_nodes sn ON sn.id = e.source_id "
"LEFT JOIN graph_nodes tn ON tn.id = e.target_id "
"WHERE e.source_id = ? OR e.target_id = ? LIMIT ?",
(node_id, node_id, int(limit)),
)
return [dict(row) for row in rows]
def graph_search(self, query: str, area: str = None, limit: int = 10) -> list:
"""Keyword search over node labels and edge relations, with provenance."""
match = match_expression(query)
if not match:
return []
sql = [
"SELECT f.id, f.kind, f.label, f.text, f.area,",
" COALESCE(n.source_note, e.source_note) AS source_note,",
" COALESCE(n.origin_url, e.origin_url) AS origin_url,",
" n.community, e.relation, e.source_id, e.target_id",
"FROM graph_fts f",
"LEFT JOIN graph_nodes n ON f.kind = 'node' AND n.id = f.id",
"LEFT JOIN graph_edges e ON f.kind = 'edge' AND e.id = f.id",
"WHERE graph_fts MATCH ?",
]
args = [match]
if area:
sql.append("AND f.area = ?")
args.append(area)
sql.append("ORDER BY bm25(graph_fts) LIMIT ?")
args.append(int(limit))
return [dict(row) for row in self.db.execute(" ".join(sql), args)]
# -- 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 _norm(label: str) -> str:
return re.sub(r"[^0-9a-z]+", "_", (label or "").lower()).strip("_")
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
View File

112
corpus/tests/fake_qdrant.py Normal file
View 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

188
corpus/tests/support.py Normal file
View File

@@ -0,0 +1,188 @@
"""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",
graph_collection="test_graph",
offline=True,
)
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()

View 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()

View 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()

View 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()

View File

@@ -0,0 +1,346 @@
"""Ingestion from the estate: exclusions, provenance, incremental work, graph.
These tests never touch the network. `FakeGitea` implements the four read calls
the harvester uses; `FakeQdrant` implements the collection routes the pipeline
uses. The real estate is exercised separately (docs/phase-4-notes.md), so a
failure here is a logic failure, not a connectivity one.
"""
from __future__ import annotations
import hashlib
import json
import os
import unittest
from unittest import mock
from corpus import exclusions, guides, loader, pipeline, store as store_module
from corpus.sources import estate, graph as graph_module
from corpus.tests import support
def gitea_guide(**overrides) -> guides.Guide:
values = dict(
name="gitea", enabled=True, what="Self-hosted Git.", fits=["ai", "platform", "edge", "products"],
read=["readme", "tree", "selected_source", "commits", "languages"],
base="https://gitea.example", auth="GITEA_TOKEN",
include={"repos": "all", "paths": []},
exclude={"repos": [], "paths": []},
limits={"max_file_kb": 200, "max_files_per_repo": 10, "max_commits": 5, "max_tree_entries": 100},
)
values.update(overrides)
return guides.Guide(**values)
class FakeGitea:
def __init__(self, repos=None, trees=None, files=None, commits=None, languages=None):
self.repos_list = repos or []
self.trees = trees or {}
self.files = files or {}
self.commit_list = commits or []
self.language_map = languages or {}
self.calls = []
def repos(self):
return list(self.repos_list)
def tree(self, full_name, ref):
self.calls.append(("tree", full_name, ref))
return list(self.trees.get(full_name, []))
def raw(self, full_name, ref, path):
self.calls.append(("raw", full_name, ref, path))
try:
return self.files[(full_name, path)]
except KeyError:
from corpus.sources.gitea import GiteaNotFound
raise GiteaNotFound(f"{full_name}/{path}") from None
def commits(self, full_name, ref, limit=20):
return list(self.commit_list)
def languages(self, full_name):
return dict(self.language_map)
def repo(full_name="sam/photo-pipeline", **overrides):
values = dict(
full_name=full_name, name=full_name.split("/")[-1], description="Photo ingestion pipeline",
topics=["photo", "python"], default_branch="main", archived=False,
created_at="2024-01-01T00:00:00Z", updated_at="2026-10-14T05:00:00Z",
html_url=f"https://gitea.example/{full_name}",
)
values.update(overrides)
return values
def blob(path, size=None):
return {"type": "blob", "path": path, "size": len(path) if size is None else size}
class ExclusionTest(unittest.TestCase):
"""The DoD probe: a file in an excluded path never appears in the pool."""
def setUp(self):
self.guide = gitea_guide()
self.result = estate.Harvest()
tree = [
blob("README.md"),
blob("node_modules/left-pad/probe.md"),
blob(".venv/lib/probe.py"),
blob(".git/config"),
blob(".trash/old.md"),
blob("docs/.silverbullet.auth.json"),
blob("src/main.py"),
blob("assets/photo.png"),
]
files = {
("sam/photo-pipeline", "README.md"): b"# Photo pipeline\n\nDeduplicates by hash.\n",
("sam/photo-pipeline", "node_modules/left-pad/probe.md"): b"# PROBE\n",
("sam/photo-pipeline", ".venv/lib/probe.py"): b"PROBE = 1\n",
("sam/photo-pipeline", ".git/config"): b"[core]\n",
("sam/photo-pipeline", ".trash/old.md"): b"# old\n",
("sam/photo-pipeline", "docs/.silverbullet.auth.json"): b'{"secret_key": "x"}\n',
("sam/photo-pipeline", "src/main.py"): b"def dedup():\n return True\n",
}
self.client = FakeGitea(
repos=[repo()],
trees={"sam/photo-pipeline": tree},
files=files,
commits=[{"commit": {"message": "dedup by hash", "author": {"date": "2026-10-14T05:00:00Z"}}}],
languages={"Python": 900, "Shell": 100},
)
self.documents = estate._harvest_one_repo(
self.result, self.client, self.guide, repo(), "https://gitea.example"
)
def test_probe_in_excluded_paths_never_appears(self):
paths = {doc.repo_path for doc in self.documents}
for probe in (
"node_modules/left-pad/probe.md",
".venv/lib/probe.py",
".git/config",
".trash/old.md",
"docs/.silverbullet.auth.json",
):
self.assertNotIn(probe, paths)
self.assertIn("README.md", paths)
self.assertIn("src/main.py", paths)
def test_rejections_are_reported_with_a_reason(self):
reasons = {(row["path"], row["reason"]) for row in self.result.rejections}
self.assertIn(("node_modules/left-pad/probe.md", "excluded directory 'node_modules'"), reasons)
self.assertIn(("docs/.silverbullet.auth.json", "credential-shaped file name"), reasons)
self.assertEqual([image["path"] for image in self.result.images], ["assets/photo.png"])
def test_media_is_an_image_reference_not_text(self):
self.assertEqual([image["path"] for image in self.result.images], ["assets/photo.png"])
for doc in self.documents:
self.assertNotIn("assets/photo.png", doc.body)
def test_every_document_carries_provenance(self):
for doc in self.documents:
self.assertEqual(doc.platform, "gitea")
self.assertEqual(doc.tier, 2)
self.assertTrue(doc.origin_url.startswith("https://gitea.example/sam/photo-pipeline"))
self.assertTrue(doc.last_modified)
self.assertIn(doc.area, self.guide.fits)
class CredentialContentTest(unittest.TestCase):
def test_a_valid_path_with_a_private_key_is_rejected(self):
result = estate.Harvest()
guide = gitea_guide()
client = FakeGitea(
repos=[repo()],
trees={"sam/photo-pipeline": [blob("README.md"), blob("src/leak.py")]},
files={
("sam/photo-pipeline", "README.md"): b"# Photo pipeline\n",
("sam/photo-pipeline", "src/leak.py"): (
("KEY = '" + "-----BEGIN " + "PRIVATE KEY-----'\n").encode("utf-8")
),
},
commits=[],
languages={"Python": 10},
)
estate._harvest_one_repo(result, client, guide, repo(), "https://gitea.example")
reasons = {row["reason"]: row for row in result.rejections}
self.assertIn("credential-shaped content", reasons)
self.assertEqual(reasons["credential-shaped content"]["path"], "src/leak.py")
class PayloadProvenanceTest(unittest.TestCase):
def test_missing_provenance_field_fails_the_payload(self):
root = support.temp_repo()
self.addCleanup(lambda: __import__("shutil").rmtree(root, ignore_errors=True))
docs = loader.load_documents(support.make_config(root))
chunk = __import__("corpus.chunker", fromlist=["chunk_document"]).chunk_document(docs[0].body)[0]
payload = __import__("corpus.payloads", fromlist=["build_payload"]).build_payload(
docs[0], chunk, support.make_config(root)
)
payload["id"] = pipeline.chunk_id(docs[0].path, 0, docs[0].digest)
for field in ("platform", "tier", "origin_url", "path", "last_modified"):
broken = dict(payload)
broken.pop(field)
with self.assertRaises(Exception):
__import__("corpus.payloads", fromlist=["validate_payload"]).validate_payload(broken)
GRAPH = {
"built_at_commit": "abc123",
"nodes": [
{"id": "photo_ingest", "label": "Photo ingest", "source_file": "300 areas/Photo.md",
"community": 7, "node_type": "entity"},
{"id": "settings", "label": "settings.json", "source_file": "settings.json",
"community": 118, "node_type": "code"},
{"id": "image_node", "label": "diagram", "source_file": "300 areas/diagram.png",
"community": 7, "node_type": "image"},
],
"links": [
{"source": "photo_ingest", "target": "settings", "relation": "configured_by",
"source_file": "300 areas/Photo.md", "confidence": "EXTRACTED", "confidence_score": 0.9},
{"source": "settings", "target": "photo_ingest", "relation": "configures",
"source_file": "settings.json", "confidence": "INFERRED"},
],
}
class GraphTest(unittest.TestCase):
def parse(self):
return graph_module.parse(
json.dumps(GRAPH).encode("utf-8"),
platform="graphify", base="https://gitea.example", repo="sam/obsidian-vault",
ref="main", last_modified="2026-10-14T05:00:00Z", area="ai",
vault_paths={"300 areas/Photo.md", "300 areas/diagram.png"},
)
def test_nodes_and_edges_carry_their_source_note(self):
result = self.parse()
self.assertEqual(len(result.nodes), 1)
self.assertEqual(result.nodes[0]["id"], "photo_ingest")
self.assertEqual(result.nodes[0]["source_note"], "300 areas/Photo.md")
self.assertIn("/sam/obsidian-vault/src/branch/main/300%20areas/Photo.md", result.nodes[0]["origin_url"])
self.assertTrue(result.edges)
for edge in result.edges:
self.assertTrue(edge["source_note"])
def test_a_node_without_a_resolvable_note_is_refused(self):
result = self.parse()
rejected = {row["path"] for row in result.rejections}
self.assertIn("settings.json", rejected)
self.assertNotIn("settings", {row["id"] for row in result.nodes})
def test_media_becomes_an_image_reference(self):
result = self.parse()
self.assertEqual([image["path"] for image in result.images], ["300 areas/diagram.png"])
self.assertNotIn("image_node", {row["id"] for row in result.nodes})
def test_graph_query_returns_the_note(self):
root = support.temp_repo()
self.addCleanup(lambda: __import__("shutil").rmtree(root, ignore_errors=True))
cfg = support.make_config(root)
harvest = estate.Harvest()
harvest.documents = loader.load_documents(cfg)
harvest.graph = self.parse()
harvest.sources["graphify"] = {"platform": "graphify", "enabled": True, "documents": 0}
fake = support.FakeQdrant(cfg.dim)
with mock.patch("corpus.sources.estate.harvest", return_value=harvest):
with support.fake_qdrant(cfg, fake):
pipeline.run(cfg, mode="rebuild")
with store_module.Store(cfg.db_path) as store:
rows = store.graph_search("photo")
self.assertTrue(rows)
self.assertTrue(all(row["source_note"] for row in rows))
self.assertEqual(store.graph_node("photo_ingest")["source_note"], "300 areas/Photo.md")
self.assertTrue(store.graph_neighbours("photo_ingest"))
class IncrementalEstateTest(unittest.TestCase):
def setUp(self):
self.root = support.temp_repo()
self.addCleanup(lambda: __import__("shutil").rmtree(self.root, ignore_errors=True))
self.cfg = support.make_config(self.root)
self.fake = support.FakeQdrant(self.cfg.dim)
self.harvest = estate.Harvest()
self.harvest.documents = loader.load_documents(self.cfg)
self.harvest.documents.append(self.harvested("a" * 64, "First version of the repository."))
self.harvest.sources["gitea"] = {"platform": "gitea", "enabled": True, "documents": 1}
def harvested(self, digest, body):
return loader.Document(
path="gitea:sam/photo-pipeline/README.md", source_id="sam/photo-pipeline/README.md",
area="platform", level=3, title="sam/photo-pipeline — README.md",
claim=body, confidence="historic", period="2024–2026", tech=[], metrics=[], artefacts=[],
public=True, last_verified="2026-10-14", tags=["gitea"], body=body, digest=digest,
url="https://gitea.example/sam/photo-pipeline/src/branch/main/README.md",
platform="gitea", tier=2, origin_url="https://gitea.example/sam/photo-pipeline/src/branch/main/README.md",
last_modified="2026-10-14T05:00:00Z", source_type="doc", kind="markdown",
repo_path="README.md",
)
def run_pipeline(self, mode="sync"):
with mock.patch("corpus.sources.estate.harvest", return_value=self.harvest):
with support.fake_qdrant(self.cfg, self.fake):
return pipeline.run(self.cfg, mode=mode)
def test_rebuild_and_second_run_do_not_duplicate(self):
first = self.run_pipeline("rebuild")
points = len(self.fake.collections[self.cfg.collection])
self.assertEqual(first.platforms["gitea"]["documents"], 1)
second = self.run_pipeline("sync")
self.assertEqual(len(self.fake.collections[self.cfg.collection]), points)
self.assertEqual(second.added, 0)
self.assertEqual(second.updated, 0)
self.assertEqual(second.texts_embedded, 0)
def test_only_the_changed_harvested_document_is_reembedded(self):
self.run_pipeline("rebuild")
self.harvest.documents[-1] = self.harvested("b" * 64, "Second version, one line changed.")
stats = self.run_pipeline("sync")
self.assertEqual(stats.updated, 1)
expected = len(__import__("corpus.chunker", fromlist=["chunk_document"]).chunk_document(
"Second version, one line changed."
))
self.assertEqual(stats.texts_embedded, expected)
self.assertEqual(stats.embed_calls, 1)
self.assertEqual(stats.unchanged, 3)
def test_pool_scan_finds_a_planted_secret(self):
self.harvest.documents[-1] = self.harvested(
"c" * 64,
"AWS_ACCESS_KEY_ID=" + "AKIA" + "ABCDEFGHIJKLMNOP" + "\nThis line is a planted credential.",
)
with self.assertRaises(pipeline.PipelineError) as caught:
self.run_pipeline("rebuild")
self.assertIn("credential-shaped content", str(caught.exception))
class CodeChunkerTest(unittest.TestCase):
def test_a_function_is_never_cut(self):
from corpus import chunker
body = "\n\n".join(
f"def function_{index}():\n value = {{'index': {index}}}\n return value"
for index in range(120)
)
chunks = chunker.chunk_code(body)
self.assertGreater(len(chunks), 1)
for chunk in chunks:
depth = 0
for line in chunk.text.splitlines():
depth = chunker._code_depth(line, depth)
self.assertEqual(depth, 0, f"chunk {chunk.index} ends inside a block")
self.assertIn("def function_", chunk.text)
def test_oversize_single_function_is_flagged_not_cut(self):
from corpus import chunker
body = "def huge():\n" + "\n".join(f" x{index} = {index}" for index in range(2000))
chunks = chunker.chunk_code(body)
self.assertEqual(len(chunks), 1)
self.assertTrue(chunks[0].oversize)
if __name__ == "__main__":
unittest.main()

View 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()

View 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()

View 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
View 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
View 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
View 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).

390
deploy/deploy.sh Executable file
View File

@@ -0,0 +1,390 @@
#!/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
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://127.0.0.1:$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://127.0.0.1:$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 127.0.0.1 only.
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" = "127.0.0.1" ] || die "published on '${ip}', expected 127.0.0.1 only"
log "shape ok: mem_limit == memswap_limit == ${declared} MiB, published on 127.0.0.1:$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://127.0.0.1:$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

66
deploy/qdrant.yml Normal file
View File

@@ -0,0 +1,66 @@
# 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
# The host's docker daemon has every default address pool allocated by other
# projects, so an implicit compose network cannot be created here. Pin a /28 of
# RFC1918 space that no other bridge uses; it is separate from the site's
# 10.89.0.0/28 so stopping one project cannot disturb the other.
networks:
default:
ipam:
config:
- subnet: 10.89.0.16/28
gateway: 10.89.0.17
volumes:
qdrant_storage:

View File

@@ -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
{
"area": "ai|platform|edge|products|history|about",
"level": 1,
"source_type": "doc|code|adr|metric",
"confidence": "delivered|running|in-progress|abandoned|historic",
"period": "2023–",
"artefacts": ["..."],
"last_verified": "2026-10-20",
"platform": "gitea | obsidian | graphify | curated",
"tier": 1,
"source_type": "doc | code | commit | graph_node | claim",
"area": "ai | platform | edge | products | history | about",
"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",
"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
View 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".

View File

@@ -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.

27
flake.lock generated Normal file
View 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
View 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"
'';
};
});
};
}

127
flows/corpus.py Normal file
View File

@@ -0,0 +1,127 @@
"""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 of the whole estate. Runs on a push or on demand."""
logger = _get_run_logger()
cfg = Config.from_env()
stats = pipeline.run(cfg, mode="sync", log=logger.info)
return stats.to_dict()
@_flow(name="corpus-scheduled-ingest", log_prints=True)
def scheduled_ingest(alert: bool = True) -> dict:
"""Nightly: harvest, report what changed, then audit and alert on staleness.
This is the scheduled run the DoD asks for: it reports documents added,
updated and removed per platform, and it raises an Apprise alert when a
source is missing, unavailable or has stopped changing.
"""
logger = _get_run_logger()
cfg = Config.from_env()
stats = pipeline.run(cfg, mode="sync", log=logger.info)
logger.info(stats.summary())
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 run
logger.warning(f"Apprise alert failed: {exc}")
return {
"changed": {
"documents": stats.documents,
"added": stats.added,
"updated": stats.updated,
"removed": stats.removed,
"unchanged": stats.unchanged,
"embedding_calls": stats.embed_calls,
"texts_embedded": stats.texts_embedded,
"platforms": stats.platforms,
"rejections": stats.rejection_summary,
"graph_rebuilt": stats.graph_rebuilt,
},
"audit": report,
}
@_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())
elif command == "scheduled":
print(scheduled_ingest(alert=False))
else:
print(sync_corpus())

25
go.mod Normal file
View 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
View 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=

456
internal/content/content.go Normal file
View File

@@ -0,0 +1,456 @@
// 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
Gutter string // two-letter mark used by the nav and the stage panel
}
// Areas is the fixed navigation order from reference/content-map.md.
var Areas = []Area{
{Key: "ai", Title: "AI & Agentic Systems", Role: "AI / LLM Engineer", Gutter: "AI",
Blurb: "Harnesses, subagents and skills, memory, model routing and cost, retrieval, voice, guardrails, agent observability."},
{Key: "platform", Title: "Platform & Infrastructure", Role: "Platform / DevOps Engineer", Gutter: "PL",
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", Gutter: "ED",
Blurb: "Twenty-node fleet on constrained devices, custom radio, solar power, deep sleep, sensors and actuation — and the failures."},
{Key: "products", Title: "Products", Role: "", Gutter: "PR",
Blurb: "Three active businesses, built and run rather than advised on."},
{Key: "history", Title: "History", Role: "", Gutter: "HI",
Blurb: "2000–2014: agencies, clients, engagements, and the code that survives from them."},
{Key: "about", Title: "About", Role: "", Gutter: "AB",
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)
}
}

View File

@@ -0,0 +1,180 @@
package content
import (
"bytes"
"fmt"
stdhtml "html"
"io"
"path"
"regexp"
"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)
}
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)
}
// 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
View 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 ")
}

View 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)
}
}

602
internal/server/server.go Normal file
View File

@@ -0,0 +1,602 @@
// 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 results
// block; 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" {
c = templates.SearchResults(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
}

View File

@@ -0,0 +1,371 @@
package server
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"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)
}
if !strings.Contains(frag, `id="search-results"`) {
t.Errorf("htmx search %q did not return #search-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)
}
}

188
internal/server/stream.go Normal file
View 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
}

318
internal/site/build.go Normal file
View File

@@ -0,0 +1,318 @@
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
}
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
View 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 + " · " + templates.LevelLabel(d.Level),
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
}

509
internal/site/site.go Normal file
View File

@@ -0,0 +1,509 @@
// 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 + "/",
Gutter: a.Gutter,
Current: a.Key == area,
})
}
return items
}
// 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,
}
}
// 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)
levelLabel := templates.LevelLabel(d.Level)
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,
AreaTitle: area.Title,
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),
LevelLabel: levelLabel,
}
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, Kind: templates.LevelLabel(b.Level)})
}
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() }

174
internal/site/site_test.go Normal file
View File

@@ -0,0 +1,174 @@
package site
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
)
// 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)
}
}
}

317
internal/stage/stage.go Normal file
View File

@@ -0,0 +1,317 @@
// Package stage defines the presentation panel as a content sink rather than a
// page renderer.
//
// The contract (docs/phase-2-static-site.md §2.4):
//
// Stage ← driver(route) // Mode A: HTMX fragment swap, pre-rendered files
// Stage ← driver(SSE) // Mode B: streamed fragments (Phase 5)
//
// Layout code renders a Binding. It never asks which driver produced it, so
// re-pointing the panel at a stream is a one-line change at the call site in
// Mode B's page and no change at all to Layout or Stage.
package stage
import (
"context"
"encoding/json"
"fmt"
"sort"
"github.com/a-h/templ"
)
// Fragment kinds the panel can display. These are the whole vocabulary: adding
// one is a deliberate change to §2.4.
const (
KindText = "text"
KindCode = "code"
KindImage = "image"
KindVideo = "video"
KindDiagram = "diagram"
KindLinks = "links"
)
// PanelID is the element both drivers swap into. The templates render this id
// and the bindings target it, so there is one name for it in the codebase.
const PanelID = "stage-content"
// PanelTarget is the CSS selector for PanelID.
func PanelTarget() string { return "#" + PanelID }
// DocFragmentID is the id of the fragment that carries a document's own body.
// The shape lives here, with the rest of the panel contract, so that a row in an
// evidence table, a pre-rendered file name and a streamed frame all agree on it.
func DocFragmentID(docID string) string { return "doc-" + docID }
// ArtefactFragmentID is the id of the i-th artefact in a document. It is
// derived from the document and the artefact's position, so it stays stable
// across builds and a saved link keeps working when the content is re-rendered.
func ArtefactFragmentID(docID string, i int) string { return fmt.Sprintf("art-%s-%02d", docID, i+1) }
// Mode is how a panel receives content.
type Mode string
const (
// ModeRoute pulls one pre-rendered fragment per interaction. Mode A.
ModeRoute Mode = "route"
// ModeStream receives fragments pushed over SSE. Mode B.
ModeStream Mode = "stream"
)
// Code is a highlighted code exhibit.
type Code struct {
Language string
Label string
HTML templ.Component // chroma-highlighted, or a <pre> fallback
}
// Link is one row of a link-list fragment.
type Link struct {
Label string
Href string
Note string
}
// Fragment is one thing the panel can show. The same struct is rendered to a
// static file for the route driver and pushed over SSE for the stream driver.
type Fragment struct {
ID string
Kind string
Title string
Subtitle string
Body templ.Component // pre-rendered HTML for text fragments
Code *Code
Src string // image, video or diagram source
Alt string
Links []Link
DocURL string
DocTitle string
Confidence string
// Rendered marks Body as a complete fragment: the build already rendered
// the article and everything inside it, so a transport writes these bytes
// as-is instead of wrapping them again. A source that hands over structured
// content leaves it false and the panel renders the fragment normally.
Rendered bool
}
// Binding is everything the Stage component needs to know about its driver.
// Route mode contributes nothing but a marker; stream mode contributes the SSE
// wiring. Both are rendered by the same Stage template.
type Binding struct {
Mode Mode
Endpoint string // stream: the SSE endpoint
Event string // stream: the event name to swap on
Target string // CSS selector the driver swaps into
Swap string // htmx swap style
Label string // human description of the driver, shown in the empty state
Hint string // one line telling the reader what to do
}
// Attributes returns the htmx/SSE attributes for the panel. Only the stream
// driver needs any: in route mode the trigger elements carry hx-get and
// hx-target, because a fragment is chosen by clicking evidence.
func (b Binding) Attributes() templ.Attributes {
attrs := templ.Attributes{"data-stage-driver": string(b.Mode)}
switch b.Mode {
case ModeStream:
attrs["hx-ext"] = "sse"
attrs["sse-connect"] = b.Endpoint
attrs["sse-swap"] = b.Event
attrs["hx-swap"] = b.Swap
attrs["aria-live"] = "polite"
}
return attrs
}
// TargetSelector is the panel a link or a stream should swap into.
func (b Binding) TargetSelector() string {
if b.Target == "" {
return PanelTarget()
}
return b.Target
}
// Driver is the whole contract between the panel and whatever feeds it.
type Driver interface {
Mode() Mode
// Bind returns the binding to render on the Stage panel.
Bind() Binding
}
// RouteDriver feeds the panel from pre-rendered fragment files. Mode A.
type RouteDriver struct {
// BaseURL is where fragment files are served from, e.g. "/stage".
BaseURL string
Label string
Hint string
}
// Mode is part of the Driver contract.
func (RouteDriver) Mode() Mode { return ModeRoute }
// Bind is part of the Driver contract.
func (d RouteDriver) Bind() Binding {
return Binding{
Mode: ModeRoute,
Target: PanelTarget(),
Swap: "innerHTML",
Label: defaultString(d.Label, "Evidence panel · driven by route"),
Hint: defaultString(d.Hint, "Choose a claim from the evidence table; the artefact appears here."),
}
}
// FragmentURL is the pre-rendered URL for a fragment id.
func (d RouteDriver) FragmentURL(id string) string {
base := d.BaseURL
if base == "" {
base = "/stage"
}
return base + "/" + id
}
// SSEDriver feeds the panel from an event stream. Same panel, same fragments,
// different transport. Used by the Phase 5 console; demonstrated in Phase 2 by
// a stream of the pre-built fragments so the plumbing is provable without AI.
type SSEDriver struct {
Endpoint string
Event string
Label string
Hint string
}
// Mode is part of the Driver contract.
func (SSEDriver) Mode() Mode { return ModeStream }
// Bind is part of the Driver contract.
func (d SSEDriver) Bind() Binding {
event := d.Event
if event == "" {
event = "stage"
}
return Binding{
Mode: ModeStream,
Endpoint: d.Endpoint,
Event: event,
Target: PanelTarget(),
Swap: "innerHTML",
Label: defaultString(d.Label, "Evidence panel · driven by stream"),
Hint: defaultString(d.Hint, "Frames arrive as they are produced and replace the panel each time."),
}
}
func defaultString(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
// Source produces the fragments a stream driver pushes. Phase 2 provides
// ManifestSource; Phase 5 provides the retrieval-driven source. Neither
// touches the panel.
type Source interface {
// Stream sends fragments for keys, in order, until done or ctx is done.
// Returning an error ends the stream; the panel keeps the last fragment.
Stream(ctx context.Context, keys []string, send func(Fragment) error) error
}
// Entry is one pre-rendered fragment, as recorded at build time.
type Entry struct {
ID string `json:"id"`
DocID string `json:"doc"`
Title string `json:"title"`
Kind string `json:"kind"`
// File is relative to the published root, e.g. "stage/edge-fleet/1.html".
File string `json:"file"`
}
// Manifest is the build-time index of fragments. The server uses it to stream
// fragments that were rendered once, and the route driver serves the same
// files directly.
type Manifest struct {
Entries []Entry `json:"entries"`
}
// ByID looks an entry up.
func (m *Manifest) ByID(id string) (Entry, bool) {
for _, e := range m.Entries {
if e.ID == id {
return e, true
}
}
return Entry{}, false
}
// ForDoc returns the entries belonging to a document, oldest first.
func (m *Manifest) ForDoc(docID string) []Entry {
var out []Entry
for _, e := range m.Entries {
if e.DocID == docID {
out = append(out, e)
}
}
return out
}
// Marshal encodes the manifest for the build to write.
func (m *Manifest) Marshal() ([]byte, error) { return json.MarshalIndent(m, "", " ") }
// LoadManifest parses a manifest written by the build.
func LoadManifest(b []byte) (*Manifest, error) {
var m Manifest
if err := json.Unmarshal(b, &m); err != nil {
return nil, fmt.Errorf("stage manifest: %w", err)
}
return &m, nil
}
// ManifestSource streams fragments that were pre-rendered at build time. It
// exists to prove the stream path end to end without a model in the loop; the
// Phase 5 source implements the same interface with retrieval behind it.
type ManifestSource struct {
manifest *Manifest
// HTML returns the pre-rendered markup for an entry.
HTML func(Entry) (string, error)
// Frame is an optional hook called as each fragment is sent, for pacing.
Frame func(index int)
}
// NewManifestSource wires a manifest and a reader together.
func NewManifestSource(m *Manifest, read func(Entry) (string, error), frame func(int)) *ManifestSource {
return &ManifestSource{manifest: m, HTML: read, Frame: frame}
}
// Stream is part of the Source contract.
func (s *ManifestSource) Stream(ctx context.Context, keys []string, send func(Fragment) error) error {
entries := make([]Entry, 0, len(keys))
if len(keys) == 0 {
entries = append(entries, s.manifest.Entries...)
}
for _, k := range keys {
if e, ok := s.manifest.ByID(k); ok {
entries = append(entries, e)
continue
}
// A document id expands to that document's fragments.
entries = append(entries, s.manifest.ForDoc(k)...)
}
sort.SliceStable(entries, func(i, j int) bool { return entries[i].ID < entries[j].ID })
for i, e := range entries {
if err := ctx.Err(); err != nil {
return err
}
html, err := s.HTML(e)
if err != nil {
return err
}
if s.Frame != nil {
s.Frame(i)
}
frag := Fragment{ID: e.ID, Kind: e.Kind, Title: e.Title, Body: templ.Raw(html), Rendered: true}
if err := send(frag); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,148 @@
package stage
import (
"context"
"strings"
"testing"
)
// TestRouteDriverBindsWithoutTransport checks §2.4: in route mode the panel
// carries no stream wiring at all, because the trigger elements do the work.
func TestRouteDriverBindsWithoutTransport(t *testing.T) {
d := RouteDriver{BaseURL: "/stage"}
if d.Mode() != ModeRoute {
t.Fatalf("Mode() = %q, want %q", d.Mode(), ModeRoute)
}
b := d.Bind()
if b.Mode != ModeRoute {
t.Errorf("binding mode = %q", b.Mode)
}
if b.TargetSelector() != PanelTarget() {
t.Errorf("target = %q, want %q", b.TargetSelector(), PanelTarget())
}
for _, k := range []string{"hx-ext", "sse-connect", "sse-swap"} {
if _, ok := b.Attributes()[k]; ok {
t.Errorf("route binding has %q; route mode must not open a stream", k)
}
}
if got := d.FragmentURL("doc-x"); got != "/stage/doc-x" {
t.Errorf("FragmentURL = %q", got)
}
}
// TestSSEDriverChangesOnlyTheBinding is the design contract: the same panel,
// the same target, a different transport.
func TestSSEDriverChangesOnlyTheBinding(t *testing.T) {
sse := SSEDriver{Endpoint: "/api/stage/stream"}.Bind()
route := RouteDriver{}.Bind()
if sse.TargetSelector() != route.TargetSelector() {
t.Errorf("stream targets %q, route targets %q; the panel must not move",
sse.TargetSelector(), route.TargetSelector())
}
if sse.Mode == route.Mode {
t.Fatal("stream and route bindings have the same mode")
}
attrs := sse.Attributes()
want := map[string]string{
"hx-ext": "sse",
"sse-connect": "/api/stage/stream",
"sse-swap": "stage",
"hx-swap": "innerHTML",
}
for k, v := range want {
if got := attrs[k]; got != v {
t.Errorf("attribute %s = %v, want %q", k, got, v)
}
}
if attrs["data-stage-driver"] != string(ModeStream) {
t.Errorf("driver marker = %v", attrs["data-stage-driver"])
}
}
// TestManifestSourceServesPreRenderedFragments checks the Phase 2 source: it
// expands a document id, keeps order and marks the payload as already rendered
// so a transport does not wrap the article twice.
func TestManifestSourceServesPreRenderedFragments(t *testing.T) {
m := &Manifest{Entries: []Entry{
{ID: "doc-a", DocID: "a", Kind: KindText, Title: "A", File: "stage/doc-a.html"},
{ID: "art-a-01", DocID: "a", Kind: KindCode, Title: "code", File: "stage/art-a-01.html"},
{ID: "doc-b", DocID: "b", Kind: KindText, Title: "B", File: "stage/doc-b.html"},
}}
read := func(e Entry) (string, error) { return "<article>" + e.ID + "</article>", nil }
src := NewManifestSource(m, read, nil)
var got []Fragment
err := src.Stream(context.Background(), []string{"a"}, func(f Fragment) error {
got = append(got, f)
return nil
})
if err != nil {
t.Fatalf("Stream: %v", err)
}
if len(got) != 2 {
t.Fatalf("streamed %d fragments for doc a, want 2", len(got))
}
for _, f := range got {
if !f.Rendered {
t.Errorf("fragment %s is not marked Rendered; a transport would wrap it again", f.ID)
}
}
if got[0].ID != "art-a-01" || got[1].ID != "doc-a" {
t.Errorf("order = %s,%s; wanted the stable manifest order", got[0].ID, got[1].ID)
}
}
// TestManifestSourceStopsOnCancel checks that a disconnected visitor stops the
// stream instead of leaving a goroutine producing frames (acceptance test N7).
func TestManifestSourceStopsOnCancel(t *testing.T) {
m := &Manifest{Entries: []Entry{
{ID: "doc-a", DocID: "a", Kind: KindText, File: "stage/doc-a.html"},
{ID: "doc-b", DocID: "b", Kind: KindText, File: "stage/doc-b.html"},
}}
read := func(e Entry) (string, error) { return "<article></article>", nil }
ctx, cancel := context.WithCancel(context.Background())
src := NewManifestSource(m, read, func(i int) {
if i == 0 {
cancel()
}
})
sent := 0
err := src.Stream(ctx, nil, func(Fragment) error { sent++; return nil })
if err == nil {
t.Fatal("Stream returned nil after cancellation")
}
if sent >= len(m.Entries) {
t.Errorf("sent all %d frames after cancellation; the stream ignored the context", sent)
}
}
// TestFragmentIDsAreStable keeps the link between an evidence row and its
// fragment stable across builds.
func TestFragmentIDsAreStable(t *testing.T) {
if DocFragmentID("edge-fleet") != "doc-edge-fleet" {
t.Error("document fragment id changed shape")
}
if ArtefactFragmentID("edge-fleet", 0) != "art-edge-fleet-01" {
t.Errorf("artefact fragment id = %q", ArtefactFragmentID("edge-fleet", 0))
}
}
// TestKindsAreTheWholeVocabulary guards §2.4: the panel can show six things.
func TestKindsAreTheWholeVocabulary(t *testing.T) {
kinds := []string{KindText, KindCode, KindImage, KindVideo, KindDiagram, KindLinks}
seen := map[string]bool{}
for _, k := range kinds {
if strings.TrimSpace(k) == "" {
t.Fatal("empty fragment kind")
}
if seen[k] {
t.Errorf("duplicate fragment kind %q", k)
}
seen[k] = true
}
if len(kinds) != 6 {
t.Errorf("%d fragment kinds, want 6", len(kinds))
}
}

12
internal/web/assets.go Normal file
View File

@@ -0,0 +1,12 @@
// Package web embeds the browser-side assets: htmx, the SSE extension and the
// stylesheet. They are compiled into the binary, so the built site has no
// dependency on a bundler, a CDN or the working directory.
package web
import "embed"
// Static holds everything served from /static/. The build publishes it
// verbatim, and adds a chroma.css generated from the Markdown renderer's style.
//
//go:embed static
var Static embed.FS

1
internal/web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1360
internal/web/static/site.css Normal file

File diff suppressed because it is too large Load Diff

290
internal/web/static/sse.js Normal file
View File

@@ -0,0 +1,290 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('sse', {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource
}
},
getSelectors: function() {
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
var source = internalData.sseEventSource
if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
}
return
// Try to create EventSources when elements are processed
case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent)
}
}
})
/// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')
for (var i = 0; i < sseEventNames.length; i++) {
const sseEventName = sseEventNames[i].trim()
const listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(elt)) {
source.removeEventListener(sseEventName, listener)
return
}
// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(elt, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
}
// Add message handlers for every `hx-trigger="sse:*"` attribute
if (api.getAttributeValue(elt, 'hx-trigger')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var triggerSpecs = api.getTriggerSpecs(elt)
triggerSpecs.forEach(function(ts) {
if (ts.trigger.slice(0, 4) !== 'sse:') {
return
}
var listener = function (event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(elt)) {
source.removeEventListener(ts.trigger.slice(4), listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(elt, ts.trigger, event)
htmx.trigger(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(ts.trigger.slice(4), listener)
})
}
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}
// handle extension source creation attribute
if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) {
return
}
ensureEventSource(elt, sseURL, retryCount)
}
registerSSE(elt)
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, retryCount)
}, timeout)
}
}
source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })
if (retryCount && retryCount > 0) {
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
for (let i = 0; i < childrenToFix.length; i++) {
registerSSE(childrenToFix[i])
}
// We want to increase the reconnection delay for consecutive failed attempts only
retryCount = 0
}
}
api.getInternalData(elt).sseEventSource = source
var closeAttribute = api.getAttributeValue(elt, "sse-close");
if (closeAttribute) {
// close eventsource when this message is received
source.addEventListener(closeAttribute, function() {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'message',
})
source.close()
});
}
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null
return true
}
}
return false
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt)
})
var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt)
api.swap(target, content, swapSpec)
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null
}
})()

View File

@@ -0,0 +1,343 @@
package templates
import (
"strconv"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
)
// Layout is the whole document. It renders the stage binding it is handed and
// never inspects which driver produced it.
templ Layout(v LayoutView) {
<!DOCTYPE html>
<html lang="en-AU">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ v.Title }</title>
<meta name="description" content={ v.Description }/>
<meta name="color-scheme" content="dark"/>
<link rel="stylesheet" href="/static/site.css"/>
<link rel="stylesheet" href="/static/chroma.css"/>
<script src="/static/htmx.min.js" defer></script>
if v.Stage.Mode == stage.ModeStream {
<script src="/static/sse.js" defer></script>
}
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
@Nav(v.Nav, v.Area)
<div class="shell">
<main id="main" class="main">
@Breadcrumbs(v.Crumbs)
{ children... }
</main>
@Stage(StageView{Binding: v.Stage})
</div>
@Footer(v)
</body>
</html>
}
// Nav is the six-area navigation: a sidebar on desktop, a <details> disclosure
// below 900 px so it still works with JavaScript disabled.
templ Nav(items []NavItem, current string) {
<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>
@SearchForm("", "nav-search")
<details class="nav-disclosure">
<summary class="nav-toggle" aria-label="Sections">Sections</summary>
<nav class="nav" aria-label="Sections">
@navLinks(items, current)
@evidenceLink(current)
</nav>
</details>
<nav class="nav nav-rail" aria-label="Sections">
@navLinks(items, current)
@evidenceLink(current)
</nav>
</header>
}
templ navLinks(items []NavItem, current string) {
for _, it := range items {
<a href={ templ.SafeURL(it.Href) } class={ navClass(it) } aria-current={ currentAttr(it) }>
<span class="nav-gutter" aria-hidden="true">{ it.Gutter }</span>
<span class="nav-title">{ it.Title }</span>
</a>
}
}
templ evidenceLink(current string) {
<a href="/evidence/" class={ navClassFor(current == "evidence") }>
<span class="nav-gutter" aria-hidden="true">EV</span>
<span class="nav-title">Evidence index</span>
</a>
}
// Breadcrumbs shows where the reader is, and is a real link trail.
templ Breadcrumbs(crumbs []Crumb) {
if len(crumbs) > 0 {
<nav class="crumbs" aria-label="Breadcrumb">
<ol>
for i, c := range crumbs {
<li>
if i == len(crumbs)-1 {
<span aria-current="page">{ c.Label }</span>
} else {
<a href={ templ.SafeURL(c.Href) }>{ c.Label }</a>
<span class="crumb-sep" aria-hidden="true">/</span>
}
</li>
}
</ol>
</nav>
}
}
// SearchForm is a GET form: it works with JavaScript disabled and is upgraded by
// HTMX when scripts are available.
templ SearchForm(q string, class string) {
<form class={ "search", class } action="/search" method="get" role="search"
hx-get="/search" hx-target="#search-results" hx-swap="innerHTML" hx-push-url="true">
<label class="visually-hidden" for={ "q-" + class }>Search the site</label>
<input id={ "q-" + class } type="search" name="q" value={ q } placeholder="Search — try “zigbee”, “signage”, “routing”" autocomplete="off"/>
<button type="submit">Search</button>
</form>
}
// Stage is the presentation panel. Everything about how it is fed comes from
// the binding: route mode swaps in pre-rendered fragments, stream mode listens
// on an event stream. The markup is identical either way.
templ Stage(v StageView) {
<aside id="stage" class="stage" aria-label="Presentation panel">
<header class="stage-head">
<h2 class="stage-title">Presentation panel</h2>
<p class="stage-driver" data-driver={ string(v.Binding.Mode) }>{ driverLabel(v.Binding.Mode) }</p>
</header>
<div id={ stage.PanelID } class="stage-body" { v.Binding.Attributes()... }>
if v.Fragment != nil {
@FragmentView(*v.Fragment)
} else {
<div class="stage-empty">
<p class="stage-empty-title">{ v.Binding.Label }</p>
<p class="stage-hint">{ v.Binding.Hint }</p>
</div>
}
</div>
<footer class="stage-foot">
<a href="/evidence/">Every artefact</a>
<a href="/stream/">Streaming panel</a>
</footer>
</aside>
}
// FragmentView renders one stage fragment. This is the only place that knows
// what a fragment kind looks like, and it is shared by the route driver (at
// build time) and the stream driver (at request time).
templ FragmentView(f stage.Fragment) {
<article class={ "frag frag-" + f.Kind }>
<header class="frag-head">
<p class="frag-kind">{ f.Kind }</p>
<h3 class="frag-title">{ f.Title }</h3>
if f.Subtitle != "" {
<p class="frag-sub">{ f.Subtitle }</p>
}
</header>
switch f.Kind {
case stage.KindText:
<div class="frag-text">@f.Body</div>
case stage.KindCode:
if f.Code != nil {
<figure class="code">
<figcaption>{ f.Code.Label }<span class="code-lang">{ f.Code.Language }</span></figcaption>
@f.Code.HTML
</figure>
}
case stage.KindDiagram, stage.KindImage:
<figure class="media">
<img src={ templ.SafeURL(f.Src) } alt={ f.Alt } loading="lazy"/>
if f.Subtitle != "" {
<figcaption>{ f.Subtitle }</figcaption>
}
</figure>
case stage.KindVideo:
<figure class="media">
<video controls preload="metadata" src={ templ.SafeURL(f.Src) }></video>
if f.Subtitle != "" {
<figcaption>{ f.Subtitle }</figcaption>
}
</figure>
case stage.KindLinks:
if len(f.Links) > 0 {
<ul class="frag-links">
for _, l := range f.Links {
<li>
<a href={ templ.SafeURL(l.Href) }>{ l.Label }</a>
if l.Note != "" {
<span class="frag-note">{ l.Note }</span>
}
</li>
}
</ul>
}
}
<footer class="frag-foot">
if f.Confidence != "" {
<span class={ ConfidenceClass(f.Confidence) }>{ f.Confidence }</span>
}
if f.DocURL != "" {
<a class="frag-doc" href={ templ.SafeURL(f.DocURL) }>{ f.DocTitle }</a>
}
</footer>
</article>
}
// EvidenceTable lists artefacts. Rows with HXGet load the artefact into the
// stage panel; every row is also a plain link, so this works with JavaScript
// disabled.
templ EvidenceTable(rows []EvidenceRow) {
<table class="evidence">
<thead>
<tr>
<th scope="col">Evidence</th>
<th scope="col">Kind</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
for _, r := range rows {
<tr>
<td>
if r.HXGet != "" {
<a href={ templ.SafeURL(r.Href) } hx-get={ r.HXGet } hx-target={ stage.PanelTarget() } hx-swap="innerHTML" hx-push-url="false">{ r.Label }</a>
} else {
<a href={ templ.SafeURL(r.Href) }>{ r.Label }</a>
}
</td>
<td><span class="kind">{ r.Kind }</span></td>
<td>
if r.Confidence != "" {
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
}
</td>
</tr>
}
</tbody>
</table>
}
// PillarCard is one landing-page pillar: the claim, the numbers, and the way in.
templ PillarCard(c PillarCardView) {
<article class="pillar">
<p class="pillar-kicker">
<span class="pillar-gutter" aria-hidden="true">{ c.Area.Gutter }</span>
if c.Area.Role != "" {
<span>{ c.Area.Role }</span>
}
</p>
<h2 class="pillar-title">
<a href={ templ.SafeURL(c.Href) }>{ c.Title }</a>
</h2>
<p class="pillar-claim">{ c.Claim }</p>
if len(c.Metrics) > 0 {
<ul class="metrics">
for _, m := range c.Metrics {
<li class="chip">
<strong>{ m.Value }</strong>
<span>{ m.Label }</span>
</li>
}
</ul>
}
if len(c.Tech) > 0 {
<ul class="tech" aria-label="Technology">
for _, t := range c.Tech {
<li>{ t }</li>
}
</ul>
}
<p class="pillar-foot">
<a class="pillar-go" href={ templ.SafeURL(c.Href) }>
if c.Level == 1 {
<span>Start here</span>
} else {
<span>Read the capability page</span>
}
</a>
<span class="pillar-count">{ strconv.Itoa(c.Pages) } pages</span>
<span class={ ConfidenceClass(c.Confidence) }>{ c.Confidence }</span>
</p>
</article>
}
// Timeline renders the history area as a timeline.
templ Timeline(items []TimelineItem) {
<ol class="timeline">
for _, it := range items {
<li class="timeline-item">
<p class="timeline-period">{ it.Period }</p>
<div class="timeline-body">
<h3><a href={ templ.SafeURL(it.Href) }>{ it.Title }</a></h3>
<p>{ it.Claim }</p>
<span class={ ConfidenceClass(it.Confidence) }>{ it.Confidence }</span>
</div>
</li>
}
</ol>
}
// CodeBlock renders an already-highlighted code exhibit.
templ CodeBlock(c stage.Code) {
<figure class="code">
<figcaption>{ c.Label }<span class="code-lang">{ c.Language }</span></figcaption>
@c.HTML
</figure>
}
// Footer closes the page with the honesty notes a recruiter needs.
templ Footer(v LayoutView) {
<footer class="footer">
<p class="footer-line">Twenty-five years shipping software · Melbourne · remote · hybrid · office · travel</p>
<p class="footer-note">
Status labels are literal: <span class="conf conf-running">running</span> and
<span class="conf conf-delivered">delivered</span> are claims; <span class="conf conf-in-progress">in-progress</span> and
<span class="conf conf-abandoned">abandoned</span> are stated as such.
</p>
<p class="footer-links">
<a href="/evidence/">Evidence index</a>
<a href="/search">Search</a>
<a href="/stream/">Streaming panel</a>
<span class="footer-copy">© { strconv.Itoa(v.Year) } Sam Rolfe</span>
</p>
</footer>
}
func navClass(it NavItem) string { return navClassFor(it.Current) }
func navClassFor(current bool) string {
if current {
return "nav-link nav-current"
}
return "nav-link"
}
func currentAttr(it NavItem) string {
if it.Current {
return "page"
}
return ""
}
func driverLabel(m stage.Mode) string {
if m == stage.ModeStream {
return "stream driver · SSE"
}
return "route driver · pre-rendered"
}

View File

@@ -0,0 +1,254 @@
package templates
import "strconv"
// HomePage is the landing page: the 90-second read.
templ HomePage(v HomeView) {
@Layout(v.Layout) {
<section class="hero">
<p class="hero-kicker">Melbourne · twenty-five years · AI · platform · edge</p>
<h1 class="hero-title">{ v.Positioning }</h1>
<p class="hero-support">{ v.Support }</p>
<p class="hero-facts">
<span>{ strconv.Itoa(v.Areas) } areas</span>
<span>{ strconv.Itoa(v.Pages) } pages</span>
<span>{ strconv.Itoa(v.Artifacts) } linked artefacts</span>
<span>no page needs a model to render</span>
</p>
@SearchForm("", "hero-search")
</section>
<section class="pillars" aria-label="Six areas">
for _, c := range v.Cards {
@PillarCard(c)
}
</section>
<section class="honesty" aria-labelledby="how-to-read">
<h2 id="how-to-read">How to read this site</h2>
<ul>
<li><strong>Level 1</strong> is one screen per area: the claim, the numbers, the way in.</li>
<li><strong>Level 2</strong> is what is actually run, with an evidence table and the trade-offs.</li>
<li><strong>Level 3</strong> is the artefact: code, diagram, screenshot, log.</li>
<li>Every claim carries a <strong>status</strong>, and finished, running, in-progress and abandoned are all shown as they are.</li>
<li>Every artefact is at most three clicks from here, and the <a href="/evidence/">evidence index</a> lists all of them on one page.</li>
</ul>
</section>
}
}
// DocPage renders an L1, L2 or L3 document.
templ DocPage(v DocView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">{ v.AreaTitle } · { v.LevelLabel }</p>
<h1 class="doc-title">{ v.Doc.Title }</h1>
<p class="doc-claim">{ v.Doc.Claim }</p>
<div class="doc-chips">
<span class={ ConfidenceClass(v.Doc.Confidence) }>{ v.Doc.Confidence }</span>
if v.Doc.Period != "" {
<span class="chip chip-plain">{ v.Doc.Period }</span>
}
for _, m := range v.Metrics {
<span class="chip">
<strong>{ m.Value }</strong>
<span>{ m.Label }</span>
</span>
}
</div>
if len(v.Tech) > 0 {
<ul class="tech" aria-label="Technology">
for _, t := range v.Tech {
<li>{ t }</li>
}
</ul>
}
</header>
<div class="prose">
@v.Body
</div>
if len(v.Evidence) > 0 {
<section class="doc-evidence" aria-labelledby="evidence-heading">
<h2 id="evidence-heading">Evidence for this page</h2>
<p class="section-note">Select a row to load the artefact into the presentation panel. Each row is also a link, so this works without JavaScript.</p>
@EvidenceTable(evidenceRows(v.Evidence))
</section>
}
if len(v.Timeline) > 0 {
<section class="doc-timeline" aria-labelledby="timeline-heading">
<h2 id="timeline-heading">In time order</h2>
@Timeline(v.Timeline)
</section>
}
if len(v.SubPages) > 0 {
<section class="doc-sub" aria-labelledby="sub-heading">
<h2 id="sub-heading">{ subHeading(v.Doc.Level) }</h2>
<ul class="drill-list">
for _, s := range v.SubPages {
<li>
<a href={ templ.SafeURL(s.Href) }>{ s.Label }</a>
<span class="drill-kind">{ s.Kind }</span>
</li>
}
</ul>
</section>
}
if len(v.Backlinks) > 0 {
<section class="doc-back" aria-labelledby="back-heading">
<h2 id="back-heading">Referenced from</h2>
<ul class="drill-list">
for _, b := range v.Backlinks {
<li><a href={ templ.SafeURL(b.Href) }>{ b.Label }</a></li>
}
</ul>
</section>
}
<footer class="doc-foot">
<p>Status <span class={ ConfidenceClass(v.Doc.Confidence) }>{ v.Doc.Confidence }</span> — { ConfidenceNote(v.Doc.Confidence) }</p>
<p>Last verified { v.Doc.LastVerified } · id <code>{ v.Doc.ID }</code> · { v.Doc.Path }</p>
</footer>
</article>
}
}
// EvidencePage is one page listing every artefact, grouped by area. It is the
// shortest path from the landing page to any proof.
templ EvidencePage(v EvidenceView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">Index</p>
<h1>Evidence index</h1>
<p class="doc-claim">Every artefact this site claims, on one page. { strconv.Itoa(v.Total) } links, each one a file in the repository.</p>
</header>
for _, g := range v.Groups {
<section class="evidence-group">
<h2><a href={ templ.SafeURL(g.Href) }>{ g.AreaTitle }</a></h2>
@EvidenceTable(g.Rows)
</section>
}
</article>
}
}
// SearchPage is the keyword search. The results block is swapped by HTMX on the
// same page; with JavaScript off the form simply reloads the page.
templ SearchPage(v SearchView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">Keyword search</p>
<h1>Search { strconv.Itoa(v.Indexed) } pages</h1>
<p class="doc-claim">Full-text search over page titles and body text. Served from a local index — no model, no network call.</p>
</header>
@SearchForm(v.Query, "page-search")
@SearchResults(v)
</article>
}
}
// SearchResults is swapped into #search-results by HTMX.
templ SearchResults(v SearchView) {
<div id="search-results" aria-live="polite">
if v.Query == "" {
<p class="section-note">Try “zigbee”, “signage”, “routing”, “backups” or “guardrails”.</p>
} else if v.Failed {
<p class="search-empty">That query could not be run. Try fewer or simpler words.</p>
} else if len(v.Results) == 0 {
<p class="search-empty">No page matches <q>{ v.Query }</q>. The <a href="/evidence/">evidence index</a> lists everything by area.</p>
} else {
<p class="search-count">{ strconv.Itoa(len(v.Results)) } results for <q>{ v.Query }</q></p>
<ol class="results">
for _, r := range v.Results {
<li class="result">
<h2><a href={ templ.SafeURL(r.URL) }>{ r.Title }</a></h2>
<p class="result-meta">
<span class="kind">{ r.Area }</span>
<span class="kind">L{ strconv.Itoa(r.Level) }</span>
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
</p>
<p class="result-snippet">@SafeSnippet(r.Snippet)</p>
</li>
}
</ol>
}
</div>
}
// StreamPage proves the panel can be re-pointed at a stream. The layout and the
// panel markup are the ones every other page uses; only the binding differs.
templ StreamPage(v StreamView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">Design note · not a product feature</p>
<h1>The panel takes content from a driver</h1>
<p class="doc-claim">This page renders the same layout and the same panel as every other page. Its panel is bound to an event stream instead of pre-rendered files, and no layout code changed.</p>
</header>
<div class="prose">
<p>The presentation panel is a content sink. In Mode A it takes pre-rendered fragments over
HTMX: you click a row in an evidence table and one file is swapped in. On this page the same
panel is bound to <code>/api/stage/stream</code> and receives { strconv.Itoa(v.Frames) } fragments
over server-sent events, one after another.</p>
<p>Fragments are pushed, not requested. Nothing about the panel — its markup, its fragment
view, its empty state — knows the difference. That is the point: the Phase 5 console
re-points this panel at retrieval output and the layout does not change.</p>
<p class="section-note">This demonstration streams the artefacts already built for the site, with
no model in the loop. It exists to prove the transport, not to pretend an AI is answering.</p>
</div>
if len(v.Fragment) > 0 {
<section class="doc-sub">
<h2>Fragments in this stream</h2>
<ul class="drill-list">
for _, f := range v.Fragment {
<li>
<a href={ templ.SafeURL(f.Href) } hx-get={ f.HXGet } hx-target="#stage-content" hx-swap="innerHTML">{ f.Label }</a>
<span class="drill-kind">{ f.Kind }</span>
</li>
}
</ul>
</section>
}
</article>
}
}
// NotFoundPage is the 404.
templ NotFoundPage(v NotFoundView) {
@Layout(v.Layout) {
<article class="doc">
<header class="doc-head">
<p class="doc-kicker">404</p>
<h1>That page is not here</h1>
<p class="doc-claim">The link may be from an older build. The six areas and the evidence index are the reliable ways in.</p>
</header>
<ul class="drill-list">
<li><a href="/">Landing page</a></li>
<li><a href="/evidence/">Evidence index</a></li>
<li><a href="/search">Search</a></li>
</ul>
</article>
}
}
func evidenceRows(links []LinkView) []EvidenceRow {
rows := make([]EvidenceRow, 0, len(links))
for _, l := range links {
rows = append(rows, EvidenceRow{Label: l.Label, Href: l.Href, HXGet: l.HXGet, Kind: l.Kind, Confidence: l.Confidence})
}
return rows
}
func subHeading(level int) string {
if level == 1 {
return "Drill-downs in this area"
}
return "Artefacts behind this page"
}

View File

@@ -0,0 +1,229 @@
// Package templates holds every templ component and the view models they read.
// Layout never branches on the stage driver: it renders the binding it is given.
//
// The generated *_templ.go files are not committed; `make generate` writes them
// from the repository root before every build. There is deliberately no
// //go:generate directive: templ records the .templ path in its error messages,
// so generating from a package directory and generating from the root produce
// different files. One entry point keeps the output byte-identical.
package templates
import (
stdhtml "html"
"strings"
"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"
)
// NavItem is one left-nav entry.
type NavItem struct {
Title string
Href string
Gutter string
Current bool
}
// Crumb is one breadcrumb.
type Crumb struct {
Label string
Href string
}
// LayoutView is everything the chrome needs.
type LayoutView struct {
Title string
Description string
Area string
Nav []NavItem
Crumbs []Crumb
Stage stage.Binding
SearchQuery string
Year int
SkipAI bool // true on Mode A pages, which never need a model
}
// StageView wraps the binding so the Stage component has one argument.
type StageView struct {
Binding stage.Binding
// Fragment, when set, is rendered into the panel at build time.
Fragment *stage.Fragment
}
// MetricView is one approved chip.
type MetricView struct {
Label string
Value string
}
// LinkView is a drill-down link. HXGet makes it a stage trigger; Href keeps it
// a working link when JavaScript is off.
type LinkView struct {
Label string
Href string
HXGet string
Kind string
Note string
Confidence string
}
// EvidenceRow is one row of an EvidenceTable.
type EvidenceRow struct {
Label string
Href string
HXGet string
Kind string
Confidence string
}
// EvidenceGroup groups rows by area on the evidence index.
type EvidenceGroup struct {
AreaTitle string
Href string
Rows []EvidenceRow
}
// TimelineItem is one row of a timeline.
type TimelineItem struct {
Period string
Title string
Href string
Confidence string
Claim string
}
// PillarCardView is one of the six landing cards.
type PillarCardView struct {
Area content.Area
Title string
Claim string
Href string
Metrics []MetricView
Tech []string
Confidence string
Level int
Pages int
}
// Layout of each page kind.
type (
// HomeView is the landing page.
HomeView struct {
Layout LayoutView
Positioning string
Support string
Location string
Cards []PillarCardView
Artifacts int
Pages int
Areas int
}
// DocView is an L1, L2 or L3 content page.
DocView struct {
Layout LayoutView
Doc *content.Document
AreaTitle string
Role string
Body templ.Component
Metrics []MetricView
Tech []string
Evidence []LinkView
Backlinks []LinkView
SubPages []LinkView
Timeline []TimelineItem
LevelLabel string
}
// EvidenceView is the index of every artefact.
EvidenceView struct {
Layout LayoutView
Groups []EvidenceGroup
Total int
}
// SearchView is the keyword search page.
SearchView struct {
Layout LayoutView
Query string
Results []search.Result
Total int
Indexed int
Failed bool
}
// StreamView is the SSE-driven stage demonstration page.
StreamView struct {
Layout LayoutView
Frames int
Fragment []LinkView
}
// NotFoundView is the 404 page.
NotFoundView struct {
Layout LayoutView
}
)
// MarkdownBody wraps pre-rendered Markdown HTML.
func MarkdownBody(html string) templ.Component { return templ.Raw(html) }
// SafeSnippet escapes a search snippet but keeps the <mark> tags the index
// inserted. Content is in-repo and trusted; escaping is defence in depth.
func SafeSnippet(s string) templ.Component {
s = stdhtml.EscapeString(s)
s = strings.ReplaceAll(s, "&lt;mark&gt;", "<mark>")
s = strings.ReplaceAll(s, "&lt;/mark&gt;", "</mark>")
return templ.Raw(s)
}
// ConfidenceClass maps a confidence value onto a CSS class. The value itself is
// always displayed — never softened.
func ConfidenceClass(c string) string {
switch c {
case content.ConfidenceDelivered:
return "conf conf-delivered"
case content.ConfidenceRunning:
return "conf conf-running"
case content.ConfidenceInProgress:
return "conf conf-in-progress"
case content.ConfidenceAbandoned:
return "conf conf-abandoned"
case content.ConfidenceHistoric:
return "conf conf-historic"
}
return "conf"
}
// ConfidenceNote is the sentence shown under a status badge.
func ConfidenceNote(c string) string {
switch c {
case content.ConfidenceDelivered:
return "Delivered."
case content.ConfidenceRunning:
return "Running today."
case content.ConfidenceInProgress:
return "In progress — not finished."
case content.ConfidenceAbandoned:
return "Abandoned. Kept in the record deliberately."
case content.ConfidenceHistoric:
return "Historic — worked on, not maintained today."
}
return ""
}
// LevelLabel names a depth level for the page kicker.
func LevelLabel(level int) string {
switch level {
case 1:
return "Level 1 · capability in one screen"
case 2:
return "Level 2 · what is actually run"
case 3:
return "Level 3 · artefact"
}
return ""
}

46
platforms/gitea.yml Normal file
View File

@@ -0,0 +1,46 @@
# Platform guide — Gitea.
#
# This file is both configuration and public copy. The site shows it so a
# visitor can see exactly what the assistant reads. Keep the wording honest.
name: gitea
enabled: true
what: >-
Self-hosted Git. The source of truth for every current project: README files,
file trees, selected source files, recent commit messages and language
statistics. This is where the work itself lives.
fits: [ai, platform, edge, products]
base: https://gitea.lab.audasmedia.com.au
auth: GITEA_TOKEN
include:
repos: all
# No path include means: every text file the repository rules admit.
paths: []
exclude:
repos:
# The vault is read by the obsidian guide, so its notes are not harvested
# twice under a different platform.
- sam/obsidian-vault
paths:
- "**/node_modules/**"
- "**/.venv/**"
- "**/venv/**"
- "**/.git/**"
- "**/.trash/**"
- "**/.obsidian/**"
- "**/.pi/**"
- "**/__pycache__/**"
- "**/graphify-out/**"
- "**/graphify-out-temp/**"
- "**/vendor/**"
- "**/third_party/**"
- "**/*.min.js"
- "**/*.min.css"
- "**/*.lock"
read: [readme, tree, selected_source, commits, languages]
public: true
limits:
max_documents: 4000
max_file_kb: 200
max_files_per_repo: 40
max_commits: 20
max_tree_entries: 3000

23
platforms/graphify.yml Normal file
View File

@@ -0,0 +1,23 @@
# Platform guide — Graphify knowledge graph.
#
# This file is both configuration and public copy.
name: graphify
enabled: true
what: >-
A knowledge graph over the notes. It answers "what connects X to Y" rather
than "what mentions X": nodes are the things in the estate, edges are the
relations between them, and every one points back at the note it came from.
fits: [ai]
base: sam/obsidian-vault
read: [graph_json]
public: true
limits:
max_documents: 4000
# graph.json is the only file this platform reads. These are the places it may
# be committed; the first match wins.
graph_paths:
- graphify-out/graph.json
- graph.json
- Graph/graph.json
max_nodes: 20000
max_edges: 40000

44
platforms/obsidian.yml Normal file
View File

@@ -0,0 +1,44 @@
# Platform guide — Obsidian vault (sam/obsidian-vault, read through Gitea).
#
# This file is both configuration and public copy.
#
# Privacy note: only project, area and index notes are read. The personal and
# family folders (000 daily, 400 Personal Family, 500 archive) and the templates
# are deliberately out of scope. They are not work evidence and they contain
# third-party personal detail. See docs/content-redaction.md.
name: obsidian
enabled: true
what: >-
A personal knowledge base. High-level notes on every system, in Sam's own
words: what a system is for, what changed, and what went wrong. Project and
area notes only; personal, family and daily notes are not read.
fits: [about, ai, platform, edge, products]
base: sam/obsidian-vault
include:
paths:
- "010 inbox/**"
- "100 Table of Contents/**"
- "200 projects/**"
- "300 areas/**"
exclude:
paths:
- ".obsidian/**"
- ".trash/**"
- ".venv/**"
- "templates/**"
- "000 daily/**"
- "400 Personal Family/**"
- "500 archive/**"
- "**/*.excalidraw"
- "**/*.png"
- "**/*.jpg"
- "**/*.jpeg"
- "**/*.gif"
- "**/*.webp"
- "**/*.pdf"
- "**/*Bedroom Lights*"
read: [notes]
public: true
limits:
max_documents: 4000
max_file_kb: 200

View File

@@ -36,6 +36,25 @@
| Guardrail (if local) | 300 m |
| **Total ceiling** | **1.0 GiB / 4 containers** |
### Qdrant memory, measured (2026-10-26, v1.20.0, 400 m cap, 1024 dims)
| Chunks | Settled RSS | Peak during ingest |
|---|---|---|
| 500 | 128 MiB | 149 MiB |
| 5,000 | 138 MiB | 305 MiB |
| 20,000 | 151 MiB | 292 MiB |
Within the cap at all three sizes, but only with the memory-frugal collection
settings in `corpus/qdrant.py`: HNSW graph and payloads on disk
(`hnsw_config.on_disk`, `on_disk_payload`), `m=8`, `ef_construct=64`,
`memmap_threshold=5000`, `indexing_threshold=20000`. With Qdrant's defaults the
container is OOM-killed between 5,000 and 20,000 chunks. The pipeline is the only
code that creates the collection, so those settings cannot drift.
Ceiling: 20,000 chunks at 1024 dimensions. Beyond that, set
`RESUME_QDRANT_VECTORS_ON_DISK=1`, reduce the dimension, or raise the cap — in
that order. Detail: `docs/phase-4-notes.md` §7.
Every service sets `mem_limit` **and** `memswap_limit` to the same value so it cannot consume host swap. Bind to `127.0.0.1`; Caddy is the only public listener.
## Paths
@@ -56,6 +75,6 @@ Every service sets `mem_limit` **and** `memswap_limit` to the same value so it c
| Item | Owner |
|---|---|
| Embedding provider (Gemini free tier vs OpenRouter vs local) | Sam — decide before Phase 4 |
| Embedding provider (Gemini free tier vs OpenRouter vs local) | Sam — Phase 4 shipped provider-agnostic (`corpus/embeddings.py`); the `omniroute` adapter is ready, `local-hashing` is a placeholder, so the decision is still Sam's before Phase 5 answers questions |
| Repo public/private after launch | Sam — decide after Phase 3 |
| Phone number on the contact page | Sam |