Compare commits
18 Commits
content/ph
...
feat/phase
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
447a0523fb | ||
|
|
0b910d216e | ||
|
|
720feddfcc | ||
|
|
4896e18a41 | ||
|
|
da674b5555 | ||
|
|
4e52015fd2 | ||
|
|
e3f54f58c1 | ||
|
|
1e46ec009a | ||
|
|
5c0622c1b1 | ||
| 9fae1c7dd8 | |||
| f9dc574172 | |||
| 24590cc351 | |||
| c95e5361e3 | |||
| 2906683f44 | |||
| c2b4e33897 | |||
| 84fc585c91 | |||
| fd9974d07e | |||
| ea2c722816 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ __pycache__/
|
||||
*.pyc
|
||||
|
||||
# build output
|
||||
/bin/
|
||||
/dist/
|
||||
/current/
|
||||
*.test
|
||||
|
||||
115
Makefile
115
Makefile
@@ -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)
|
||||
|
||||
42
README.md
42
README.md
@@ -37,6 +37,42 @@ This folder **is** the git working copy of `sam/ai-resume`. One repo, one produc
|
||||
|
||||
Previously held an abandoned Vite/React scaffold. It was replaced by this build spec; the history is retained because it honestly records the false start, and it is referenced in the site's "alternatives considered" content.
|
||||
|
||||
## Build and run
|
||||
|
||||
The site is one Go binary serving pre-rendered HTML. There is no Node, no bundler and no
|
||||
`node_modules`; `go.sum` is the only dependency lock.
|
||||
|
||||
```sh
|
||||
nix develop # go, templ, sqlite3, make, curl
|
||||
make validate # content gate: front-matter against the frozen schema
|
||||
make test # go test ./... plus the validator self-test
|
||||
make dist # templ generate → bin/ → pre-rendered dist/
|
||||
make dev # build, then serve on http://127.0.0.1:8096
|
||||
```
|
||||
|
||||
`bin/resume-build` reads `content/` and writes `dist/` (pages, stage fragments, the fragment
|
||||
manifest, the SQLite FTS5 index, assets). `bin/resume-server` serves `dist/` and never reads
|
||||
`content/`, never calls a model, and never opens a socket to another service — that is what
|
||||
keeps acceptance test A1 true.
|
||||
|
||||
The two things that are not files are `/search` (FTS5 over page titles and body text) and
|
||||
`/api/stage/stream` (the presentation panel driven by an event stream instead of a route).
|
||||
|
||||
The retrieval corpus (Phase 4) is built separately, because it writes to a service rather
|
||||
than to `dist/`:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/qdrant.yml up -d # on the runtime host, loopback only
|
||||
make corpus # content/ -> chunks -> Qdrant + SQLite FTS5
|
||||
make corpus-verify # does the index agree with content/ and with Qdrant?
|
||||
make corpus-audit # stale documents, index drift, contract drift
|
||||
make corpus-test # 76 unit tests, no container and no network
|
||||
```
|
||||
|
||||
The site never calls Qdrant, so acceptance test A1 is unaffected: stopping the corpus
|
||||
services leaves every page working. Operator manual and measurements:
|
||||
`docs/phase-4-notes.md`.
|
||||
|
||||
## Ground rules
|
||||
|
||||
- **Do not touch other systems.** No stopping containers, no editing other projects, no `nixos-rebuild`, no Caddy changes except the single block specified in Phase 3 — and that one Sam applies.
|
||||
@@ -48,10 +84,10 @@ Previously held an abandoned Vite/React scaffold. It was replaced by this build
|
||||
## Current state
|
||||
|
||||
- [x] Repo prepared, scaffold retired, build spec written
|
||||
- [ ] Phase 1 — content source of truth
|
||||
- [ ] Phase 2 — static site
|
||||
- [x] Phase 1 — content source of truth (40 documents across six areas, `make validate`)
|
||||
- [x] Phase 2 — static site (`make dist`, 45 pages, 86 stage fragments, FTS5 search)
|
||||
- [ ] Phase 3 — public deployment
|
||||
- [ ] Phase 4 — corpus
|
||||
- [x] Phase 4 — corpus and retrieval (51 chunks, FTS5 + Qdrant, freshness audit)
|
||||
- [ ] Phase 5 — AI overlay
|
||||
- [ ] Phase 6 — guardrails
|
||||
- [ ] Phase 7 — polish
|
||||
|
||||
58
cmd/build/main.go
Normal file
58
cmd/build/main.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Command build pre-renders the whole site into a directory.
|
||||
//
|
||||
// It reads content/ and writes dist/: every page as HTML, one file per stage
|
||||
// fragment, the fragment manifest, the SQLite FTS5 index, the assets and the
|
||||
// static files. No network call is made, and the output is what the server
|
||||
// serves — Mode A never renders a page per request.
|
||||
//
|
||||
// Configuration is from the environment, so the same binary is used by
|
||||
// `make build`, by CI and by the container image:
|
||||
//
|
||||
// RESUME_ROOT repository root (default ".")
|
||||
// RESUME_CONTENT content directory (default $RESUME_ROOT/content)
|
||||
// RESUME_DIST output directory (default $RESUME_ROOT/dist)
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "usage: build [--root DIR] [--content DIR] [--dist DIR]\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "environment: RESUME_ROOT, RESUME_CONTENT, RESUME_DIST\n")
|
||||
}
|
||||
root := flag.String("root", env("RESUME_ROOT", "."), "repository root")
|
||||
contentDir := flag.String("content", env("RESUME_CONTENT", ""), "content directory")
|
||||
distDir := flag.String("dist", env("RESUME_DIST", ""), "output directory")
|
||||
flag.Parse()
|
||||
|
||||
cfg := site.Config{Root: *root, ContentDir: *contentDir, OutDir: *distDir}
|
||||
s, err := site.New(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "build: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := s.Build(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "build: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
info, err := site.LoadBuildInfo(s.Config.OutDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "build: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("built %d pages, %d stage fragments, %d indexed documents into %s\n",
|
||||
info.Pages, info.Fragments, info.Indexed, s.Config.OutDir)
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
155
cmd/server/main.go
Normal file
155
cmd/server/main.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Command server serves the site the build wrote.
|
||||
//
|
||||
// It reads a directory produced by cmd/build and serves it on loopback. It does
|
||||
// not read content/, does not need Qdrant, OmniRoute or any other service, and
|
||||
// makes no outbound connection, so every page it serves works with the AI stack
|
||||
// stopped (acceptance test A1).
|
||||
//
|
||||
// Configuration comes from the environment so the same binary works under
|
||||
// `make dev`, systemd and compose:
|
||||
//
|
||||
// RESUME_ADDR listen address (default 127.0.0.1:8096)
|
||||
// RESUME_DIST build directory (default "dist")
|
||||
// RESUME_STREAM_DELAY pacing for the streamed panel, e.g. 350ms
|
||||
//
|
||||
// Flags with the same names override the environment for local work.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", env("RESUME_ADDR", server.DefaultAddr), "listen address")
|
||||
dist := flag.String("dist", env("RESUME_DIST", server.DefaultDist), "build directory")
|
||||
streamDelay := flag.Duration("stream-delay", envDuration("RESUME_STREAM_DELAY", server.DefaultStreamDelay), "delay between streamed panel frames")
|
||||
healthCheck := flag.Bool("health-check", false, "ask a running server for /healthz and exit 0 only if static is ready")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "usage: server [--addr HOST:PORT] [--dist DIR] [--stream-delay DURATION] [--health-check]\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "environment: RESUME_ADDR, RESUME_DIST, RESUME_STREAM_DELAY\n")
|
||||
}
|
||||
flag.Parse()
|
||||
|
||||
// The container image has no shell and no curl, so the health probe is this
|
||||
// binary talking to itself (deploy/compose.yml healthcheck).
|
||||
if *healthCheck {
|
||||
if err := probe(*addr); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "health check failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("static ready")
|
||||
return
|
||||
}
|
||||
|
||||
log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
site, err := server.New(server.Config{
|
||||
Addr: *addr,
|
||||
DistDir: *dist,
|
||||
StreamDelay: *streamDelay,
|
||||
Logger: log,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("start failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer site.Close()
|
||||
|
||||
// No WriteTimeout on purpose: the stage stream keeps the response open for
|
||||
// as long as it is producing frames. Reads are bounded.
|
||||
srv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: site.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 64 << 10,
|
||||
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
log.Info("shutting down")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Warn("shutdown", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Info("listening", "addr", *addr, "dist", *dist)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error("serve failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// probe asks a running server whether the static site is ready. Only the static
|
||||
// field decides the exit code: Mode A must report ready with every AI service
|
||||
// stopped (acceptance test A1).
|
||||
func probe(addr string) error {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
res, err := client.Get("http://" + net.JoinHostPort(host, port) + "/healthz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("/healthz returned %s", res.Status)
|
||||
}
|
||||
var body struct {
|
||||
Static struct {
|
||||
Ready bool `json:"ready"`
|
||||
} `json:"static"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(res.Body, 64<<10)).Decode(&body); err != nil {
|
||||
return fmt.Errorf("decode /healthz: %w", err)
|
||||
}
|
||||
if !body.Static.Ready {
|
||||
return errors.New("static is not ready")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envDuration(key string, fallback time.Duration) time.Duration {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s=%q is not a duration: %v\n", key, v, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
return d
|
||||
}
|
||||
22
corpus/__init__.py
Normal file
22
corpus/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Phase 4 — the retrievable corpus.
|
||||
|
||||
`content/` is the source of truth. This package turns it into the two indexes
|
||||
the AI console reads:
|
||||
|
||||
* **vectors** in Qdrant, one point per chunk, with the payload that lets the
|
||||
query path filter *before* searching (`area`, `confidence`);
|
||||
* **keywords** in SQLite FTS5, one row per chunk, so exact tokens (repo names,
|
||||
`LoRaWAN`, `MQTT`, dates) are not lost the way they are with vectors alone
|
||||
(decision D10, hybrid retrieval).
|
||||
|
||||
Everything here is driven by `python3 -m corpus.cli`; `docs/phase-4-notes.md`
|
||||
is the operator manual and records what was measured. The pipeline is
|
||||
Provider-agnostic by design: `corpus/embeddings.py` is the only place that knows
|
||||
how a vector is made, and the provider + model + dimension are recorded per
|
||||
collection so a provider change is a governed migration, not silent corruption.
|
||||
|
||||
Standard library only, plus the repository's own validator (`tools/`). No new
|
||||
dependency, no Node, no framework (AGENTS.md section 4.1).
|
||||
"""
|
||||
|
||||
__all__ = ["config", "loader", "chunker", "payloads", "embeddings", "qdrant", "store", "pipeline", "freshness"]
|
||||
8
corpus/__main__.py
Normal file
8
corpus/__main__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""`python3 -m corpus` is the same as `python3 -m corpus.cli`."""
|
||||
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
94
corpus/areas.py
Normal file
94
corpus/areas.py
Normal 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
408
corpus/chunker.py
Normal 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
377
corpus/cli.py
Normal 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
206
corpus/config.py
Normal 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
241
corpus/embeddings.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""The one place a vector is made.
|
||||
|
||||
`docs/phase-4-corpus.md` §4.3: *"Record the embedding model and dimension per
|
||||
collection. A provider change is a governed migration, not a silent corruption.
|
||||
Provider decision pending Sam — build provider-agnostic behind one interface."*
|
||||
|
||||
So: one interface (`Provider`), two implementations, and the provider name,
|
||||
model and dimension are written to the collection metadata by the pipeline. A
|
||||
later run with a different model refuses to mix vectors into the same
|
||||
collection; it reports a governed migration instead.
|
||||
|
||||
**local-hashing** is deterministic, offline, and *not semantic*. It hashes word
|
||||
tokens into a fixed-width vector. It exists so that the pipeline, its tests and
|
||||
a from-scratch rebuild never depend on a network or on an unmade decision. It is
|
||||
the default for `make corpus-test` and for the Qdrant memory measurement.
|
||||
|
||||
**omniroute** is the production path: an OpenAI-compatible `/v1/embeddings`
|
||||
call through OmniRoute, which is the only permitted model gateway
|
||||
(AGENTS.md §4.5). No provider SDK, no direct vendor call, key from the
|
||||
environment only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
class EmbeddingError(RuntimeError):
|
||||
"""Embedding failed. The pipeline aborts rather than writing partial vectors."""
|
||||
|
||||
|
||||
class Provider:
|
||||
"""Interface every embedding provider implements."""
|
||||
|
||||
name = "provider"
|
||||
model = "model"
|
||||
dim = 0
|
||||
|
||||
def embed(self, texts: list) -> list:
|
||||
raise NotImplementedError
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Call counters. The DoD proves incremental mode with these numbers."""
|
||||
return {"calls": 0, "texts": 0}
|
||||
|
||||
def reset_stats(self) -> None:
|
||||
pass
|
||||
|
||||
def describe(self) -> dict:
|
||||
return {"provider": self.name, "model": self.model, "dim": self.dim}
|
||||
|
||||
|
||||
class LocalHashingProvider(Provider):
|
||||
"""Deterministic feature hashing. Offline, fast, not semantic.
|
||||
|
||||
Every token is hashed with sha256 (never Python's salted `hash()`), so the
|
||||
same text produces the same vector on every host and every run. Vectors are
|
||||
L2-normalised, which suits cosine distance.
|
||||
"""
|
||||
|
||||
name = "local-hashing"
|
||||
|
||||
def __init__(self, model: str = "local-hashing-v1", dim: int = 256) -> None:
|
||||
if dim <= 0:
|
||||
raise EmbeddingError(f"local-hashing needs a positive dimension, got {dim}")
|
||||
self.model = model
|
||||
self.dim = dim
|
||||
self._calls = 0
|
||||
self._texts = 0
|
||||
|
||||
def embed(self, texts: list) -> list:
|
||||
self._calls += 1
|
||||
self._texts += len(texts)
|
||||
return [self._vector(text) for text in texts]
|
||||
|
||||
def _vector(self, text: str) -> list:
|
||||
vector = [0.0] * self.dim
|
||||
for token in _tokens(text):
|
||||
digest = hashlib.sha256(token.encode("utf-8")).digest()
|
||||
index = int.from_bytes(digest[:8], "big") % self.dim
|
||||
sign = 1.0 if digest[8] & 1 else -1.0
|
||||
vector[index] += sign
|
||||
norm = math.sqrt(sum(value * value for value in vector))
|
||||
if norm == 0.0:
|
||||
# An empty text still needs a unit vector of the right width.
|
||||
vector[0] = 1.0
|
||||
return vector
|
||||
return [value / norm for value in vector]
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {"calls": self._calls, "texts": self._texts}
|
||||
|
||||
def reset_stats(self) -> None:
|
||||
self._calls = 0
|
||||
self._texts = 0
|
||||
|
||||
|
||||
class OmniRouteProvider(Provider):
|
||||
"""OpenAI-compatible `/v1/embeddings` through OmniRoute.
|
||||
|
||||
Retries on 429 and 5xx with exponential backoff (the risk table in
|
||||
`docs/phase-4-corpus.md` calls this out for bulk ingest). The key is never
|
||||
logged, never put in an error message, and never read from this repository.
|
||||
"""
|
||||
|
||||
name = "omniroute"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
dim: int,
|
||||
batch_size: int = 32,
|
||||
timeout: float = 30.0,
|
||||
attempts: int = 4,
|
||||
backoff: float = 1.5,
|
||||
) -> None:
|
||||
if not api_key:
|
||||
raise EmbeddingError(
|
||||
"OmniRoute needs OMNIROUTE_API_KEY in the environment "
|
||||
"(~/.config/environment.d/10-secrets.conf)"
|
||||
)
|
||||
self.api_url = api_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self.model = model
|
||||
self.dim = dim
|
||||
self.batch_size = max(1, batch_size)
|
||||
self.timeout = timeout
|
||||
self.attempts = max(1, attempts)
|
||||
self.backoff = backoff
|
||||
self._calls = 0
|
||||
self._texts = 0
|
||||
|
||||
def embed(self, texts: list) -> list:
|
||||
out = []
|
||||
for start in range(0, len(texts), self.batch_size):
|
||||
batch = texts[start : start + self.batch_size]
|
||||
out.extend(self._embed_batch(batch))
|
||||
return out
|
||||
|
||||
def _embed_batch(self, batch: list) -> list:
|
||||
body = json.dumps({"model": self.model, "input": batch}).encode("utf-8")
|
||||
last_error = None
|
||||
for attempt in range(self.attempts):
|
||||
request = urllib.request.Request(
|
||||
f"{self.api_url}/v1/embeddings",
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
},
|
||||
)
|
||||
self._calls += 1
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
vectors = _vectors_from_response(payload, self.model, self.dim)
|
||||
self._texts += len(batch)
|
||||
return vectors
|
||||
except urllib.error.HTTPError as exc:
|
||||
last_error = f"HTTP {exc.code} from {self.api_url}/v1/embeddings"
|
||||
exc.close()
|
||||
# 4xx other than 429 is our fault: do not hammer the gateway.
|
||||
if exc.code != 429 and exc.code < 500:
|
||||
raise EmbeddingError(f"{last_error} (not retryable)") from None
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
last_error = f"cannot reach {self.api_url}: {exc.reason}"
|
||||
except EmbeddingError:
|
||||
raise
|
||||
if attempt + 1 < self.attempts:
|
||||
time.sleep(self.backoff ** attempt)
|
||||
raise EmbeddingError(f"{last_error} after {self.attempts} attempts")
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {"calls": self._calls, "texts": self._texts}
|
||||
|
||||
def reset_stats(self) -> None:
|
||||
self._calls = 0
|
||||
self._texts = 0
|
||||
|
||||
|
||||
def _vectors_from_response(payload: dict, model: str, dim: int) -> list:
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, list) or not data:
|
||||
raise EmbeddingError("embedding response has no 'data' array")
|
||||
ordered = sorted(data, key=lambda item: item.get("index", 0))
|
||||
vectors = []
|
||||
for item in ordered:
|
||||
vector = item.get("embedding")
|
||||
if not isinstance(vector, list) or not vector:
|
||||
raise EmbeddingError("embedding response item has no 'embedding' array")
|
||||
if dim and len(vector) != dim:
|
||||
raise EmbeddingError(
|
||||
f"provider returned dimension {len(vector)} but the collection is "
|
||||
f"configured for {dim}; refusing to mix dimensions"
|
||||
)
|
||||
vectors.append([float(value) for value in vector])
|
||||
returned_model = payload.get("model")
|
||||
if returned_model and model and returned_model != model:
|
||||
raise EmbeddingError(
|
||||
f"provider answered as model {returned_model!r}, not {model!r}; "
|
||||
"a model change must be a governed migration"
|
||||
)
|
||||
return vectors
|
||||
|
||||
|
||||
def build_provider(cfg) -> Provider:
|
||||
"""Construct the provider named by the configuration."""
|
||||
if cfg.provider == "local-hashing":
|
||||
return LocalHashingProvider(model=cfg.model, dim=cfg.dim)
|
||||
if cfg.provider == "omniroute":
|
||||
return OmniRouteProvider(
|
||||
api_url=cfg.api_url,
|
||||
api_key=cfg.api_key,
|
||||
model=cfg.model,
|
||||
dim=cfg.dim,
|
||||
batch_size=cfg.batch_size,
|
||||
timeout=cfg.timeout,
|
||||
)
|
||||
raise EmbeddingError(f"unknown embedding provider {cfg.provider!r}")
|
||||
|
||||
|
||||
def _tokens(text: str) -> list:
|
||||
out = []
|
||||
current = []
|
||||
for char in text.lower():
|
||||
if char.isalnum():
|
||||
current.append(char)
|
||||
elif current:
|
||||
out.append("".join(current))
|
||||
current = []
|
||||
if current:
|
||||
out.append("".join(current))
|
||||
return out
|
||||
256
corpus/exclusions.py
Normal file
256
corpus/exclusions.py
Normal 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
279
corpus/freshness.py
Normal 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
89
corpus/globs.py
Normal 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
227
corpus/guides.py
Normal 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
180
corpus/loader.py
Normal 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
242
corpus/measure.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Qdrant memory at 500 / 5,000 / 20,000 chunks.
|
||||
|
||||
`docs/phase-4-corpus.md` requires the measurement and `reference/stack.md` is
|
||||
where the number is recorded. The corpus itself is small; the point of the
|
||||
measurement is to show the *shape* of the growth so the 400 m cap is a known
|
||||
quantity rather than a hope.
|
||||
|
||||
Method:
|
||||
|
||||
1. Create an empty collection and sample memory — the baseline.
|
||||
2. For each size: recreate the collection, upsert that many chunks with payloads
|
||||
shaped exactly like production payloads (same fields, same text lengths),
|
||||
wait for the write to settle, then sample.
|
||||
|
||||
Memory is read from Qdrant's own Prometheus `app_process_resident_memory_bytes`,
|
||||
which is the process that would be capped, not the container sandbox. When
|
||||
`--container` is also given the container's counter is recorded next to it.
|
||||
|
||||
The measurement collection is dropped afterwards unless `--keep` is passed, and
|
||||
it is never the production collection name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import statistics
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import chunker, embeddings, loader, payloads
|
||||
from .qdrant import Qdrant
|
||||
|
||||
# Qdrant 1.20 exposes memory_resident_bytes; older builds used the Prometheus
|
||||
# default app_process_resident_memory_bytes. Accept either.
|
||||
RSS_METRIC = re.compile(
|
||||
r"^(?:memory_resident_bytes|app_process_resident_memory_bytes)\s+([0-9.eE+]+)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
INGEST_BATCH = 250
|
||||
INGEST_SETTLE = 0.4
|
||||
MEMUSAGE = re.compile(r"([0-9.]+)\s*([KMG]i?B)", re.IGNORECASE)
|
||||
CAP_MIB = 400 # reference/stack.md memory budget for Qdrant
|
||||
|
||||
|
||||
def measure(cfg, sizes=(500, 5000, 20000), container: str = "", keep: bool = False, log=None) -> dict:
|
||||
log = log or (lambda _msg: None)
|
||||
collection = f"{cfg.collection}_measure"
|
||||
provider = embeddings.build_provider(cfg)
|
||||
qdrant = Qdrant(cfg.qdrant_url, timeout=max(cfg.timeout, 60.0))
|
||||
if not qdrant.health():
|
||||
raise RuntimeError(f"Qdrant is not answering at {cfg.qdrant_url}")
|
||||
|
||||
templates = _templates(cfg, provider)
|
||||
log(f"measure: {len(templates)} payload template(s), dim={cfg.dim}, collection={collection}")
|
||||
|
||||
qdrant.drop_collection(collection)
|
||||
_create(qdrant, cfg, collection)
|
||||
time.sleep(1.0)
|
||||
baseline = _sample(qdrant, container)
|
||||
|
||||
samples = []
|
||||
for size in sorted(int(value) for value in sizes):
|
||||
qdrant.drop_collection(collection)
|
||||
_create(qdrant, cfg, collection)
|
||||
points = _points(templates, size, provider)
|
||||
started = time.time()
|
||||
# Throttled ingest: the peak during a bulk write is what trips the
|
||||
# cgroup limit, not the settled size. One batch at a time, sampling as we
|
||||
# go, so the measurement reports the peak and not just the end state.
|
||||
sent = 0
|
||||
peak = baseline["rss_mib"]
|
||||
for start in range(0, len(points), INGEST_BATCH):
|
||||
sent += qdrant.upsert(collection, points[start : start + INGEST_BATCH], batch=INGEST_BATCH)
|
||||
time.sleep(INGEST_SETTLE)
|
||||
reading = _rss_mib(qdrant)
|
||||
if reading is not None:
|
||||
peak = max(peak, reading)
|
||||
elapsed = round(time.time() - started, 2)
|
||||
points_now = qdrant.point_count(collection)
|
||||
time.sleep(2.0)
|
||||
reading = _sample(qdrant, container)
|
||||
growth = reading["rss_mib"] - baseline["rss_mib"]
|
||||
samples.append(
|
||||
{
|
||||
"chunks_requested": size,
|
||||
"points": points_now,
|
||||
"sent": sent,
|
||||
"upsert_seconds": elapsed,
|
||||
"rss_mib": reading["rss_mib"],
|
||||
"peak_rss_mib": round(peak, 1),
|
||||
"container_mib": reading["container_mib"],
|
||||
"growth_mib": round(growth, 1),
|
||||
"bytes_per_chunk": int(growth * 1024 * 1024 / size) if size else None,
|
||||
}
|
||||
)
|
||||
log(
|
||||
f"measure: {size} chunk(s) -> {points_now} point(s), rss {reading['rss_mib']:.1f} MiB "
|
||||
f"(+{growth:.1f} MiB), upsert {elapsed}s"
|
||||
)
|
||||
|
||||
if not keep:
|
||||
qdrant.drop_collection(collection)
|
||||
|
||||
report = {
|
||||
"collection": collection,
|
||||
"dim": cfg.dim,
|
||||
"distance": "Cosine",
|
||||
"provider": cfg.provider,
|
||||
"model": cfg.model,
|
||||
"qdrant_version": qdrant.version(),
|
||||
"cap_mib": CAP_MIB,
|
||||
"baseline": baseline,
|
||||
"samples": samples,
|
||||
"within_cap": all(
|
||||
max(sample["rss_mib"], sample["peak_rss_mib"]) <= CAP_MIB for sample in samples
|
||||
),
|
||||
"measured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"kept": keep,
|
||||
}
|
||||
return report
|
||||
|
||||
|
||||
def _create(qdrant: Qdrant, cfg, collection: str) -> None:
|
||||
"""Same collection settings as production, so the memory reading means something."""
|
||||
qdrant.create_collection(
|
||||
collection, cfg.dim, vectors_on_disk=cfg.vectors_on_disk
|
||||
)
|
||||
|
||||
|
||||
def _templates(cfg, provider) -> list:
|
||||
"""Real payloads from content/, so field count and text length are realistic."""
|
||||
documents = loader.load_documents(cfg)
|
||||
out = []
|
||||
for doc in documents:
|
||||
for chunk in chunker.chunk_document(doc.body):
|
||||
payload = payloads.build_payload(doc, chunk, cfg)
|
||||
payload["id"] = "template"
|
||||
out.append(payload)
|
||||
if not out:
|
||||
raise RuntimeError("no content to build a measurement from")
|
||||
return out
|
||||
|
||||
|
||||
def _points(templates: list, size: int, provider) -> list:
|
||||
"""`size` points with unique ids, unique source paths and real payload shape."""
|
||||
points = []
|
||||
texts = []
|
||||
for index in range(size):
|
||||
template = templates[index % len(templates)]
|
||||
payload = dict(template)
|
||||
payload["id"] = str(uuid.UUID(hex=hashlib.sha256(f"measure:{index}".encode()).hexdigest()[:32]))
|
||||
payload["source_path"] = f"measure/{template['area']}/{index}.md"
|
||||
payload["chunk_index"] = index
|
||||
points.append({"id": payload["id"], "payload": payload})
|
||||
texts.append(f"{index} {payload['text']}")
|
||||
vectors = provider.embed(texts)
|
||||
for point, vector in zip(points, vectors):
|
||||
point["vector"] = vector
|
||||
return points
|
||||
|
||||
|
||||
def _sample(qdrant: Qdrant, container: str) -> dict:
|
||||
"""Three readings two seconds apart; report the median so a GC pause is not the number."""
|
||||
readings = []
|
||||
for attempt in range(3):
|
||||
readings.append(_rss_mib(qdrant))
|
||||
if attempt < 2:
|
||||
time.sleep(2.0)
|
||||
readings = [value for value in readings if value is not None]
|
||||
rss = round(statistics.median(readings), 1) if readings else 0.0
|
||||
return {"rss_mib": rss, "container_mib": _container_mib(container) if container else None}
|
||||
|
||||
|
||||
def _rss_mib(qdrant: Qdrant):
|
||||
text = qdrant.metrics()
|
||||
if not text:
|
||||
return None
|
||||
match = RSS_METRIC.search(text)
|
||||
if not match:
|
||||
return None
|
||||
return float(match.group(1)) / (1024 * 1024)
|
||||
|
||||
|
||||
def _container_mib(container: str):
|
||||
"""Container memory from `docker stats`. None when docker or the container is absent."""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["docker", "stats", "--no-stream", "--format", "{{.MemUsage}}", container],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
match = MEMUSAGE.search(completed.stdout)
|
||||
if not match:
|
||||
return None
|
||||
value = float(match.group(1))
|
||||
unit = match.group(2).upper()
|
||||
if unit.startswith("G"):
|
||||
value *= 1024
|
||||
elif unit.startswith("K"):
|
||||
value /= 1024
|
||||
return round(value, 1)
|
||||
|
||||
|
||||
def render_text(report: dict) -> str:
|
||||
lines = [
|
||||
f"Qdrant {report['qdrant_version']} · collection {report['collection']} · "
|
||||
f"dim {report['dim']} cosine · provider {report['provider']}/{report['model']}",
|
||||
f"baseline (empty collection): {report['baseline']['rss_mib']:.1f} MiB",
|
||||
"",
|
||||
f"{'chunks':>8} {'points':>8} {'rss MiB':>8} {'peak':>7} {'growth':>7} {'B/chunk':>8} {'upsert s':>8}",
|
||||
]
|
||||
for sample in report["samples"]:
|
||||
per_chunk = sample["bytes_per_chunk"] or 0
|
||||
lines.append(
|
||||
f"{sample['chunks_requested']:>8} {sample['points']:>8} {sample['rss_mib']:>8.1f} "
|
||||
f"{sample['peak_rss_mib']:>7.1f} {sample['growth_mib']:>+7.1f} {per_chunk:>8} "
|
||||
f"{sample['upsert_seconds']:>8}"
|
||||
)
|
||||
lines.append("")
|
||||
verdict = "within" if report["within_cap"] else "OVER"
|
||||
lines.append(f"cap {report['cap_mib']} MiB: {verdict} cap at every measured size")
|
||||
if report["kept"]:
|
||||
lines.append(f"collection {report['collection']} was kept for inspection")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .config import Config
|
||||
|
||||
cfg = Config.from_env(dim=int(sys.argv[1]) if len(sys.argv) > 1 else None)
|
||||
print(render_text(measure(cfg, container="", log=print)))
|
||||
202
corpus/payloads.py
Normal file
202
corpus/payloads.py
Normal 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
679
corpus/pipeline.py
Normal 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
277
corpus/qdrant.py
Normal 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
848
corpus/sources/estate.py
Normal 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
165
corpus/sources/gitea.py
Normal 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
335
corpus/sources/graph.py
Normal 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
479
corpus/store.py
Normal 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
0
corpus/tests/__init__.py
Normal file
112
corpus/tests/fake_qdrant.py
Normal file
112
corpus/tests/fake_qdrant.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""An in-memory stand-in for Qdrant, used by the pipeline tests.
|
||||
|
||||
It implements the routes the pipeline actually calls and nothing else, and it is
|
||||
deliberately dumb: brute-force cosine over a dict. If the pipeline passes
|
||||
against this, the pipeline's own logic is right; the real server is exercised
|
||||
separately in `docs/phase-4-notes.md` (rebuild against a live container).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from corpus.qdrant import Qdrant, QdrantError, QdrantNotFound
|
||||
|
||||
|
||||
class FakeQdrant(Qdrant):
|
||||
def __init__(self, dim: int):
|
||||
super().__init__(url="fake://qdrant", timeout=1.0, attempts=1)
|
||||
self.dim = dim
|
||||
self.collections = {} # name -> {id: {"vector": [...], "payload": {...}}}
|
||||
self.calls = []
|
||||
|
||||
# -- transport --------------------------------------------------------
|
||||
|
||||
def _request(self, method: str, path: str, body=None) -> dict:
|
||||
self.calls.append((method, path, body))
|
||||
query = ""
|
||||
if "?" in path:
|
||||
path, query = path.split("?", 1)
|
||||
parts = [part for part in path.split("/") if part]
|
||||
|
||||
if not parts:
|
||||
return {"version": "fake-1.0"}
|
||||
|
||||
if parts[0] != "collections":
|
||||
raise QdrantNotFound(f"no route {path}")
|
||||
if len(parts) == 2:
|
||||
name = parts[1]
|
||||
if method == "PUT":
|
||||
self.collections.setdefault(name, {})
|
||||
return {"result": True}
|
||||
if method == "DELETE":
|
||||
if name not in self.collections:
|
||||
raise QdrantNotFound("collection not found")
|
||||
del self.collections[name]
|
||||
return {"result": True}
|
||||
if name not in self.collections:
|
||||
raise QdrantNotFound("collection not found")
|
||||
return {
|
||||
"result": {
|
||||
"points_count": len(self.collections[name]),
|
||||
"config": {"params": {"vectors": {"size": self.dim, "distance": "Cosine"}}},
|
||||
}
|
||||
}
|
||||
name = parts[1]
|
||||
if name not in self.collections:
|
||||
raise QdrantNotFound("collection not found")
|
||||
points = self.collections[name]
|
||||
|
||||
if parts[2:4] == ["points", "count"]:
|
||||
return {"result": {"count": len(points)}}
|
||||
if len(parts) == 3 and parts[2] == "points" and method == "PUT":
|
||||
for point in (body or {}).get("points", []):
|
||||
if len(point["vector"]) != self.dim:
|
||||
raise QdrantError("wrong vector dimension")
|
||||
points[point["id"]] = {"vector": point["vector"], "payload": point["payload"]}
|
||||
return {"result": {"status": "completed"}}
|
||||
if parts[2:4] == ["points", "delete"]:
|
||||
must = (((body or {}).get("filter") or {}).get("must")) or []
|
||||
for point_id in list(points):
|
||||
if _matches(points[point_id]["payload"], must):
|
||||
del points[point_id]
|
||||
return {"result": {"status": "completed"}}
|
||||
if parts[2:4] == ["points", "query"]:
|
||||
vector = body["query"]
|
||||
must = (((body or {}).get("filter") or {}).get("must")) or []
|
||||
scored = []
|
||||
for point_id, point in points.items():
|
||||
if not _matches(point["payload"], must):
|
||||
continue
|
||||
scored.append(
|
||||
{
|
||||
"id": point_id,
|
||||
"score": _cosine(vector, point["vector"]),
|
||||
"payload": point["payload"],
|
||||
}
|
||||
)
|
||||
scored.sort(key=lambda row: row["score"], reverse=True)
|
||||
return {"result": {"points": scored[: int(body.get("limit", 10))]}}
|
||||
if parts[2:4] == ["points", "scroll"]:
|
||||
limit = int((body or {}).get("limit", 100))
|
||||
rows = [
|
||||
{"id": point_id, "payload": {"source_path": point["payload"].get("source_path")}}
|
||||
for point_id, point in list(points.items())[:limit]
|
||||
]
|
||||
return {"result": {"points": rows, "next_page_offset": None}}
|
||||
raise QdrantNotFound(f"no route {path}")
|
||||
|
||||
|
||||
def _matches(payload: dict, conditions: list) -> bool:
|
||||
for condition in conditions:
|
||||
key = condition.get("key")
|
||||
expected = (condition.get("match") or {}).get("value")
|
||||
if payload.get(key) != expected:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _cosine(left: list, right: list) -> float:
|
||||
dot = sum(a * b for a, b in zip(left, right))
|
||||
norm = math.sqrt(sum(a * a for a in left)) * math.sqrt(sum(b * b for b in right))
|
||||
return dot / norm if norm else 0.0
|
||||
188
corpus/tests/support.py
Normal file
188
corpus/tests/support.py
Normal 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()
|
||||
145
corpus/tests/test_chunker.py
Normal file
145
corpus/tests/test_chunker.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Chunking rules: size, overlap, determinism, and the hard one — no split inside
|
||||
a fence, a table or front-matter (docs/phase-4-corpus.md DoD item 3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from corpus import chunker, loader
|
||||
from corpus.config import Config
|
||||
from corpus.tests import support
|
||||
|
||||
REPO_ROOT = support.REPO_ROOT
|
||||
|
||||
|
||||
def content_documents():
|
||||
cfg = Config.from_env(root=REPO_ROOT, provider="local-hashing", dim=32)
|
||||
return loader.load_documents(cfg)
|
||||
|
||||
|
||||
class BlockParsingTest(unittest.TestCase):
|
||||
def test_fence_is_one_block(self):
|
||||
blocks = chunker.parse_blocks(support.FENCE)
|
||||
kinds = [block.kind for block in blocks]
|
||||
self.assertEqual(kinds, ["para", "fence", "para"])
|
||||
self.assertIn("chunk boundary must never land", blocks[1].text)
|
||||
|
||||
def test_table_is_one_block(self):
|
||||
blocks = chunker.parse_blocks(support.TABLE)
|
||||
table = [block for block in blocks if block.kind == "table"]
|
||||
self.assertEqual(len(table), 1)
|
||||
self.assertEqual(len(table[0].text.splitlines()), 5) # header, rule, 3 rows
|
||||
|
||||
def test_front_matter_in_body_is_refused(self):
|
||||
with self.assertRaises(chunker.ChunkError):
|
||||
chunker.parse_blocks("---\nid: x\n---\n\n# Heading\n")
|
||||
|
||||
def test_unterminated_fence_runs_to_end(self):
|
||||
blocks = chunker.parse_blocks("intro\n\n```go\nfunc main() {}\n")
|
||||
self.assertEqual(blocks[-1].kind, "fence")
|
||||
self.assertTrue(blocks[-1].text.rstrip().endswith("}"))
|
||||
|
||||
def test_headings_start_blocks(self):
|
||||
blocks = chunker.parse_blocks("# One\n\ntext\n\n## Two\n\nmore\n")
|
||||
self.assertEqual([block.kind for block in blocks], ["heading", "para", "heading", "para"])
|
||||
|
||||
def test_list_items_are_separate_blocks(self):
|
||||
blocks = chunker.parse_blocks("- one\n- two\n continued\n- three\n")
|
||||
self.assertEqual([block.kind for block in blocks], ["list", "list", "list"])
|
||||
self.assertIn("continued", blocks[1].text)
|
||||
|
||||
|
||||
class ChunkingTest(unittest.TestCase):
|
||||
def test_small_body_is_one_chunk(self):
|
||||
chunks = chunker.chunk_document("# Title\n\nA short body.\n")
|
||||
self.assertEqual(len(chunks), 1)
|
||||
self.assertEqual(chunks[0].index, 0)
|
||||
self.assertFalse(chunks[0].oversize)
|
||||
|
||||
def test_long_body_respects_maximum_and_overlaps(self):
|
||||
chunks = chunker.chunk_document(support.PROSE)
|
||||
self.assertGreater(len(chunks), 1)
|
||||
for chunk in chunks:
|
||||
self.assertLessEqual(chunk.tokens, chunker.TOKEN_MAX, f"chunk {chunk.index} too large")
|
||||
# Overlap: consecutive chunks share at least one block.
|
||||
for previous, current in zip(chunks, chunks[1:]):
|
||||
self.assertTrue(
|
||||
set(id(block) for block in previous.blocks) & set(id(block) for block in current.blocks),
|
||||
f"chunks {previous.index} and {current.index} do not overlap",
|
||||
)
|
||||
|
||||
def test_chunk_indices_are_sequential(self):
|
||||
chunks = chunker.chunk_document(support.PROSE)
|
||||
self.assertEqual([chunk.index for chunk in chunks], list(range(len(chunks))))
|
||||
|
||||
def test_deterministic(self):
|
||||
first = chunker.chunk_document(support.PROSE)
|
||||
second = chunker.chunk_document(support.PROSE)
|
||||
self.assertEqual([chunk.text for chunk in first], [chunk.text for chunk in second])
|
||||
|
||||
def test_code_fence_is_never_split(self):
|
||||
body = support.PROSE + "\n\n" + support.FENCE + "\n\n" + support.PROSE
|
||||
chunks = chunker.chunk_document(body)
|
||||
fences = [block for block in chunker.parse_blocks(body) if block.kind == "fence"]
|
||||
self.assertTrue(fences)
|
||||
for fence in fences:
|
||||
holders = [chunk for chunk in chunks if fence in chunk.blocks]
|
||||
self.assertTrue(holders, "the fenced block disappeared from every chunk")
|
||||
for holder in holders:
|
||||
self.assertIn(fence.text, holder.text, "a chunk holds part of a fenced block")
|
||||
|
||||
def test_oversize_single_block_is_flagged_not_split(self):
|
||||
huge = "```go\n" + "\n".join(f"line{i} := {i}" for i in range(1200)) + "\n```\n"
|
||||
chunks = chunker.chunk_document(huge)
|
||||
self.assertEqual(len(chunks), 1)
|
||||
self.assertTrue(chunks[0].oversize)
|
||||
self.assertGreater(chunks[0].tokens, chunker.TOKEN_MAX)
|
||||
|
||||
|
||||
class RealContentTest(unittest.TestCase):
|
||||
"""The same rules, over every document in content/."""
|
||||
|
||||
def test_no_boundary_splits_a_protected_region(self):
|
||||
for doc in content_documents():
|
||||
regions = chunker.protected_regions(doc.body)
|
||||
chunks = chunker.chunk_document(doc.body)
|
||||
chunker.verify_no_split(chunks, regions) # raises on failure
|
||||
|
||||
def test_every_chunk_is_whole_blocks(self):
|
||||
for doc in content_documents():
|
||||
expected = [block.text for block in chunker.parse_blocks(doc.body)]
|
||||
seen = []
|
||||
for chunk in chunker.chunk_document(doc.body):
|
||||
for block in chunk.blocks:
|
||||
self.assertIn(block.text, chunk.text)
|
||||
if not seen or seen[-1] != block.text:
|
||||
seen.append(block.text)
|
||||
# Every block appears, in order, exactly as parsed (overlap repeats a
|
||||
# block at the start of the next chunk, which is expected).
|
||||
position = 0
|
||||
for text in expected:
|
||||
while position < len(seen) and seen[position] != text:
|
||||
position += 1
|
||||
self.assertLess(position, len(seen), f"{doc.path}: block was dropped: {text[:40]!r}")
|
||||
position += 1
|
||||
|
||||
def test_chunks_are_within_the_size_band(self):
|
||||
for doc in content_documents():
|
||||
for chunk in chunker.chunk_document(doc.body):
|
||||
if chunk.oversize:
|
||||
self.assertEqual(len(chunk.blocks), 1)
|
||||
continue
|
||||
self.assertLessEqual(chunk.tokens, chunker.TOKEN_MAX, f"{doc.path} chunk {chunk.index}")
|
||||
|
||||
def test_content_tree_is_chunked_completely(self):
|
||||
total = 0
|
||||
for doc in content_documents():
|
||||
chunks = chunker.chunk_document(doc.body)
|
||||
self.assertTrue(chunks, f"{doc.path}: no chunks")
|
||||
total += len(chunks)
|
||||
self.assertGreater(total, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
125
corpus/tests/test_embeddings.py
Normal file
125
corpus/tests/test_embeddings.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Providers: determinism offline, and an OpenAI-compatible wire shape through OmniRoute.
|
||||
|
||||
There is no direct vendor SDK call anywhere (AGENTS.md §4.5), so the provider
|
||||
test asserts the HTTP request shape, not a client library's behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import unittest
|
||||
|
||||
from corpus import embeddings
|
||||
from corpus.tests import support
|
||||
|
||||
|
||||
class LocalHashingTest(unittest.TestCase):
|
||||
def test_deterministic_and_unit_length(self):
|
||||
provider = embeddings.LocalHashingProvider(dim=64)
|
||||
first = provider.embed(["zigbee mesh latency"])[0]
|
||||
second = embeddings.LocalHashingProvider(dim=64).embed(["zigbee mesh latency"])[0]
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(len(first), 64)
|
||||
self.assertAlmostEqual(math.sqrt(sum(value * value for value in first)), 1.0, places=9)
|
||||
|
||||
def test_different_text_differs(self):
|
||||
provider = embeddings.LocalHashingProvider(dim=64)
|
||||
a, b = provider.embed(["zigbee", "lora"])
|
||||
self.assertNotEqual(a, b)
|
||||
|
||||
def test_empty_text_still_returns_a_unit_vector(self):
|
||||
provider = embeddings.LocalHashingProvider(dim=16)
|
||||
vector = provider.embed([""])[0]
|
||||
self.assertEqual(len(vector), 16)
|
||||
self.assertAlmostEqual(math.sqrt(sum(value * value for value in vector)), 1.0, places=9)
|
||||
|
||||
def test_stats_count_calls_and_texts(self):
|
||||
provider = embeddings.LocalHashingProvider(dim=16)
|
||||
provider.embed(["a", "b", "c"])
|
||||
provider.embed(["d"])
|
||||
self.assertEqual(provider.stats(), {"calls": 2, "texts": 4})
|
||||
provider.reset_stats()
|
||||
self.assertEqual(provider.stats(), {"calls": 0, "texts": 0})
|
||||
|
||||
def test_rejects_a_zero_dimension(self):
|
||||
with self.assertRaises(embeddings.EmbeddingError):
|
||||
embeddings.LocalHashingProvider(dim=0)
|
||||
|
||||
|
||||
class OmniRouteTest(unittest.TestCase):
|
||||
def responder_factory(self, dim=8, status_plan=None):
|
||||
plan = list(status_plan or [])
|
||||
state = {"n": 0}
|
||||
|
||||
def responder(method, path, body):
|
||||
state["n"] += 1
|
||||
if plan:
|
||||
status = plan.pop(0)
|
||||
if status != 200:
|
||||
return status, {"error": "planned failure"}
|
||||
vectors = [
|
||||
{"index": index, "embedding": [0.1] * dim}
|
||||
for index, _text in enumerate(body.get("input") or [])
|
||||
]
|
||||
return 200, {"model": body.get("model"), "data": vectors}
|
||||
|
||||
return responder
|
||||
|
||||
def test_posts_to_v1_embeddings_with_the_configured_model(self):
|
||||
with support.StubServer(self.responder_factory()) as server:
|
||||
provider = embeddings.OmniRouteProvider(
|
||||
api_url=server.url, api_key="test-key", model="text-embedding-3-small", dim=8
|
||||
)
|
||||
vectors = provider.embed(["one", "two"])
|
||||
self.assertEqual(len(vectors), 2)
|
||||
request = server.requests[0]
|
||||
self.assertEqual(request["method"], "POST")
|
||||
self.assertEqual(request["path"], "/v1/embeddings")
|
||||
self.assertEqual(request["body"]["model"], "text-embedding-3-small")
|
||||
self.assertEqual(request["body"]["input"], ["one", "two"])
|
||||
self.assertEqual(request["headers"]["Authorization"], "Bearer test-key")
|
||||
self.assertEqual(provider.stats(), {"calls": 1, "texts": 2})
|
||||
|
||||
def test_batches_requests(self):
|
||||
with support.StubServer(self.responder_factory()) as server:
|
||||
provider = embeddings.OmniRouteProvider(
|
||||
api_url=server.url, api_key="k", model="m", dim=8, batch_size=2
|
||||
)
|
||||
provider.embed(["a", "b", "c", "d", "e"])
|
||||
self.assertEqual(len(server.requests), 3)
|
||||
self.assertEqual(provider.stats(), {"calls": 3, "texts": 5})
|
||||
|
||||
def test_dimension_mismatch_is_refused(self):
|
||||
with support.StubServer(self.responder_factory(dim=4)) as server:
|
||||
provider = embeddings.OmniRouteProvider(
|
||||
api_url=server.url, api_key="k", model="m", dim=8
|
||||
)
|
||||
with self.assertRaises(embeddings.EmbeddingError) as caught:
|
||||
provider.embed(["one"])
|
||||
self.assertIn("dimension", str(caught.exception))
|
||||
|
||||
def test_retries_then_succeeds(self):
|
||||
with support.StubServer(self.responder_factory(status_plan=[429, 200])) as server:
|
||||
provider = embeddings.OmniRouteProvider(
|
||||
api_url=server.url, api_key="k", model="m", dim=8, backoff=0.01
|
||||
)
|
||||
vectors = provider.embed(["one"])
|
||||
self.assertEqual(len(vectors), 1)
|
||||
self.assertEqual(len(server.requests), 2)
|
||||
|
||||
def test_client_error_is_not_retried(self):
|
||||
with support.StubServer(self.responder_factory(status_plan=[400])) as server:
|
||||
provider = embeddings.OmniRouteProvider(
|
||||
api_url=server.url, api_key="k", model="m", dim=8, backoff=0.01
|
||||
)
|
||||
with self.assertRaises(embeddings.EmbeddingError):
|
||||
provider.embed(["one"])
|
||||
self.assertEqual(len(server.requests), 1)
|
||||
|
||||
def test_missing_key_is_refused_before_any_call(self):
|
||||
with self.assertRaises(embeddings.EmbeddingError):
|
||||
embeddings.OmniRouteProvider(api_url="http://127.0.0.1:1", api_key="", model="m", dim=8)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
110
corpus/tests/test_freshness.py
Normal file
110
corpus/tests/test_freshness.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Freshness audit: stale documents, index drift and contract drift."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import unittest
|
||||
|
||||
from corpus import freshness, pipeline, store as store_module
|
||||
from corpus.tests import support
|
||||
|
||||
|
||||
class AuditTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = support.temp_repo()
|
||||
self.cfg = support.make_config(self.root)
|
||||
self.addCleanup(_cleanup, self.root)
|
||||
with support.fake_qdrant(self.cfg):
|
||||
pipeline.run(self.cfg, mode="rebuild")
|
||||
|
||||
def audit(self, today=None, cfg=None):
|
||||
with support.fake_qdrant(cfg or self.cfg):
|
||||
return freshness.audit(cfg or self.cfg, today=today)
|
||||
|
||||
def test_clean_soon_after_rebuild(self):
|
||||
report = self.audit(today=datetime.date(2026, 10, 5))
|
||||
self.assertTrue(report["ok"], report["findings"])
|
||||
self.assertEqual(report["corpus"]["documents"], 3)
|
||||
|
||||
def test_reports_stale_documents(self):
|
||||
report = self.audit(today=datetime.date(2027, 10, 5))
|
||||
stale = {row["source_path"] for row in report["findings"]["stale"]}
|
||||
self.assertEqual(
|
||||
stale,
|
||||
{
|
||||
"content/ai/ai-overview.md",
|
||||
"content/edge/l3/edge-zigbee.md",
|
||||
"content/platform/l3/platform-containers.md",
|
||||
},
|
||||
)
|
||||
self.assertFalse(report["ok"])
|
||||
text = freshness.render_text(report)
|
||||
self.assertIn("STALE", text)
|
||||
self.assertIn("platform-containers", text)
|
||||
|
||||
def test_warns_before_a_document_goes_stale(self):
|
||||
# platform-containers was verified 2026-08-01. Horizon 180 days, warning
|
||||
# 30, so on 2027-01-20 it is 172 days old: due soon, not yet stale.
|
||||
report = self.audit(today=datetime.date(2027, 1, 20))
|
||||
due = {row["source_path"] for row in report["findings"]["due_soon"]}
|
||||
self.assertEqual(due, {"content/platform/l3/platform-containers.md"})
|
||||
self.assertEqual(report["findings"]["stale"], [])
|
||||
self.assertTrue(report["ok"])
|
||||
|
||||
def test_reports_index_drift_when_a_source_changes(self):
|
||||
support.write_doc(
|
||||
self.root,
|
||||
"ai/ai-overview.md",
|
||||
id="ai-overview", area="ai", level=1, title="Agent Systems",
|
||||
claim="Edited but not re-indexed.", confidence="running",
|
||||
last_verified="2026-10-05", tags="agents", body="New words for the corpus.",
|
||||
)
|
||||
report = self.audit(today=datetime.date(2026, 10, 6))
|
||||
problems = [row["problem"] for row in report["findings"]["index_drift"]]
|
||||
self.assertIn("source changed since it was embedded", problems)
|
||||
self.assertFalse(report["ok"])
|
||||
|
||||
def test_reports_contract_drift_when_the_model_changes(self):
|
||||
other = support.make_config(self.root, model="different-model")
|
||||
report = self.audit(today=datetime.date(2026, 10, 5), cfg=other)
|
||||
self.assertTrue(report["findings"]["contract_drift"])
|
||||
|
||||
def test_report_is_json_serialisable(self):
|
||||
import json
|
||||
|
||||
report = self.audit(today=datetime.date(2026, 10, 5))
|
||||
json.dumps(report)
|
||||
|
||||
|
||||
class AlertTest(unittest.TestCase):
|
||||
def test_no_url_means_no_alert(self):
|
||||
report = {"ok": True, "findings": {"stale": []}, "corpus": {}, "horizon": {}, "summary": {}}
|
||||
self.assertFalse(freshness.alert(report, ""))
|
||||
|
||||
def test_posts_to_apprise(self):
|
||||
report = {
|
||||
"generated_at": "2026-10-26T00:00:00Z",
|
||||
"today": "2026-10-26",
|
||||
"horizon": {"max_age_days": 180, "warn_days": 30},
|
||||
"corpus": {
|
||||
"documents": 1, "chunks": 1, "sources": 1, "collection": "c",
|
||||
"collection_points": 1, "provider": "p", "model": "m", "dim": 8,
|
||||
"chunker_version": "v",
|
||||
},
|
||||
"findings": {"stale": [], "due_soon": [], "index_drift": [], "contract_drift": []},
|
||||
"summary": {"stale": 0},
|
||||
"ok": True,
|
||||
}
|
||||
with support.StubServer(lambda method, path, body: (200, "ok")) as server:
|
||||
self.assertTrue(freshness.alert(report, server.url))
|
||||
self.assertIn("title", server.requests[0]["body"])
|
||||
|
||||
|
||||
def _cleanup(root):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
346
corpus/tests/test_ingestion.py
Normal file
346
corpus/tests/test_ingestion.py
Normal 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()
|
||||
100
corpus/tests/test_payloads.py
Normal file
100
corpus/tests/test_payloads.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Payload rules: every field present, enums enforced, missing field fails the run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from corpus import chunker, loader, payloads
|
||||
from corpus.config import Config
|
||||
from corpus.pipeline import chunk_id
|
||||
from corpus.tests import support
|
||||
|
||||
|
||||
def first_payload():
|
||||
cfg = Config.from_env(root=support.REPO_ROOT, provider="local-hashing", dim=32)
|
||||
doc = loader.load_documents(cfg)[0]
|
||||
chunk = chunker.chunk_document(doc.body)[0]
|
||||
payload = payloads.build_payload(doc, chunk, cfg)
|
||||
payload["id"] = chunk_id(doc.path, chunk.index, doc.digest)
|
||||
return cfg, doc, payload
|
||||
|
||||
|
||||
class BuildTest(unittest.TestCase):
|
||||
def test_payload_has_every_required_field(self):
|
||||
cfg, doc, payload = first_payload()
|
||||
payloads.validate_payload(payload)
|
||||
for field in payloads.REQUIRED_FIELDS:
|
||||
self.assertIn(field, payload)
|
||||
self.assertEqual(payload["area"], doc.area)
|
||||
self.assertEqual(payload["confidence"], doc.confidence)
|
||||
self.assertEqual(payload["last_verified"], doc.last_verified)
|
||||
self.assertEqual(payload["embedding_model"], cfg.model)
|
||||
self.assertEqual(payload["embedding_dim"], cfg.dim)
|
||||
|
||||
def test_every_real_document_produces_valid_payloads(self):
|
||||
cfg = Config.from_env(root=support.REPO_ROOT, provider="local-hashing", dim=32)
|
||||
for doc in loader.load_documents(cfg):
|
||||
for chunk in chunker.chunk_document(doc.body):
|
||||
payload = payloads.build_payload(doc, chunk, cfg)
|
||||
payload["id"] = chunk_id(doc.path, chunk.index, doc.digest)
|
||||
payloads.validate_payload(payload, where=doc.path)
|
||||
|
||||
def test_source_type_is_doc_by_default(self):
|
||||
_cfg, doc, payload = first_payload()
|
||||
self.assertEqual(payload["source_type"], "doc")
|
||||
|
||||
def test_source_type_from_tags(self):
|
||||
doc = loader.Document(
|
||||
path="content/ai/adr-x.md", source_id="adr-x", area="ai", level=3, title="ADR",
|
||||
claim="c", confidence="delivered", period="2024", tech=[], metrics=[], artefacts=[],
|
||||
public=True, last_verified="2026-01-01", tags=["adr"], body="", digest="0" * 64,
|
||||
url="/ai/a/adr-x/",
|
||||
)
|
||||
self.assertEqual(payloads.source_type_for(doc), "adr")
|
||||
|
||||
|
||||
class ValidationTest(unittest.TestCase):
|
||||
def test_missing_field_raises(self):
|
||||
_cfg, _doc, payload = first_payload()
|
||||
for field in ("area", "level", "source_type", "confidence", "period", "artefacts", "last_verified", "text"):
|
||||
broken = dict(payload)
|
||||
del broken[field]
|
||||
with self.assertRaises(payloads.PayloadError, msg=f"missing {field} passed validation"):
|
||||
payloads.validate_payload(broken)
|
||||
|
||||
def test_missing_provenance_field_raises(self):
|
||||
_cfg, _doc, payload = first_payload()
|
||||
for field in payloads.PROVENANCE_FIELDS:
|
||||
broken = dict(payload)
|
||||
del broken[field]
|
||||
with self.assertRaises(payloads.PayloadError, msg=f"missing {field} passed validation"):
|
||||
payloads.validate_payload(broken)
|
||||
|
||||
def test_bad_enums_raise(self):
|
||||
_cfg, _doc, payload = first_payload()
|
||||
for field, value in (
|
||||
("area", "sales"),
|
||||
("level", 4),
|
||||
("source_type", "blog"),
|
||||
("confidence", "vibes"),
|
||||
):
|
||||
broken = dict(payload)
|
||||
broken[field] = value
|
||||
with self.assertRaises(payloads.PayloadError, msg=f"{field}={value!r} passed validation"):
|
||||
payloads.validate_payload(broken)
|
||||
|
||||
def test_empty_text_raises(self):
|
||||
_cfg, _doc, payload = first_payload()
|
||||
payload["text"] = " "
|
||||
with self.assertRaises(payloads.PayloadError):
|
||||
payloads.validate_payload(payload)
|
||||
|
||||
def test_bad_date_raises(self):
|
||||
_cfg, _doc, payload = first_payload()
|
||||
payload["last_verified"] = "last october"
|
||||
with self.assertRaises(payloads.PayloadError):
|
||||
payloads.validate_payload(payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
179
corpus/tests/test_pipeline.py
Normal file
179
corpus/tests/test_pipeline.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""The pipeline: deterministic ids, no duplicate vectors on re-run, incremental mode,
|
||||
incomplete payloads never reach the store (docs/phase-4-corpus.md DoD 1-6)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from corpus import chunker, loader, pipeline, store as store_module
|
||||
from corpus.tests import support
|
||||
|
||||
|
||||
class RebuildTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = support.temp_repo()
|
||||
self.cfg = support.make_config(self.root)
|
||||
self.addCleanup(_cleanup, self.root)
|
||||
# One fake for the whole test: a second run must see the first run's points.
|
||||
self.fake = support.FakeQdrant(self.cfg.dim)
|
||||
|
||||
def run_pipeline(self, mode="sync", cfg=None):
|
||||
cfg = cfg or self.cfg
|
||||
with support.fake_qdrant(cfg, self.fake):
|
||||
return pipeline.run(cfg, mode=mode)
|
||||
|
||||
def test_rebuild_from_empty_volume(self):
|
||||
stats = self.run_pipeline("rebuild")
|
||||
self.assertEqual(stats.documents, 3)
|
||||
self.assertEqual(stats.added, 3)
|
||||
self.assertGreater(stats.store_chunks, 0)
|
||||
self.assertEqual(stats.store_chunks, stats.collection_points)
|
||||
self.assertEqual(stats.collection_points, len(self.fake.collections[self.cfg.collection]))
|
||||
self.assertGreater(stats.embed_calls, 0)
|
||||
|
||||
def test_running_twice_does_not_duplicate_vectors(self):
|
||||
first = self.run_pipeline("rebuild")
|
||||
points_after_first = len(self.fake.collections[self.cfg.collection])
|
||||
second = self.run_pipeline("sync")
|
||||
points_after_second = len(self.fake.collections[self.cfg.collection])
|
||||
self.assertEqual(points_after_first, points_after_second)
|
||||
self.assertEqual(first.store_chunks, second.store_chunks)
|
||||
self.assertEqual(second.added, 0)
|
||||
self.assertEqual(second.updated, 0)
|
||||
self.assertEqual(second.chunks_written, 0)
|
||||
|
||||
def test_incremental_reembeds_only_changed_documents(self):
|
||||
self.run_pipeline("rebuild")
|
||||
|
||||
# Nothing changed: no embedding calls at all.
|
||||
unchanged = self.run_pipeline("sync")
|
||||
self.assertEqual(unchanged.embed_calls, 0)
|
||||
self.assertEqual(unchanged.texts_embedded, 0)
|
||||
|
||||
# One document changes: exactly one embedding call, for that document.
|
||||
support.write_doc(
|
||||
self.root,
|
||||
"edge/l3/edge-zigbee.md",
|
||||
id="edge-zigbee", area="edge", level=3, title="Low Power Mesh Sensing",
|
||||
claim="A constrained mesh that survives power loss.", confidence="delivered",
|
||||
last_verified="2026-09-15", tags="zigbee, mesh",
|
||||
body="The mesh now also reports battery voltage on every node.",
|
||||
)
|
||||
changed = self.run_pipeline("sync")
|
||||
self.assertEqual(changed.added + changed.updated, 1)
|
||||
self.assertEqual(changed.embed_calls, 1)
|
||||
expected_chunks = len(
|
||||
chunker.chunk_document(
|
||||
loader.load_documents(support.make_config(self.root))[1].body
|
||||
)
|
||||
)
|
||||
self.assertEqual(changed.texts_embedded, expected_chunks)
|
||||
self.assertEqual(changed.unchanged, 2)
|
||||
|
||||
def test_removed_document_is_removed_from_both_indexes(self):
|
||||
self.run_pipeline("rebuild")
|
||||
before = self.run_pipeline("sync").store_chunks
|
||||
os.remove(os.path.join(self.root, "content", "platform", "l3", "platform-containers.md"))
|
||||
after = self.run_pipeline("sync")
|
||||
self.assertEqual(after.removed, 1)
|
||||
self.assertLess(after.store_chunks, before)
|
||||
self.assertEqual(after.store_chunks, after.collection_points)
|
||||
with store_module.Store(self.cfg.db_path) as store:
|
||||
self.assertEqual(store.keyword_search("endpoint latency loss"), [])
|
||||
|
||||
def test_ids_are_deterministic(self):
|
||||
first = pipeline.chunk_id("content/ai/ai-overview.md", 0, "a" * 64)
|
||||
second = pipeline.chunk_id("content/ai/ai-overview.md", 0, "a" * 64)
|
||||
other = pipeline.chunk_id("content/ai/ai-overview.md", 1, "a" * 64)
|
||||
self.assertEqual(first, second)
|
||||
self.assertNotEqual(first, other)
|
||||
self.assertEqual(len(first), 36)
|
||||
|
||||
def test_invalid_payload_aborts_before_any_write(self):
|
||||
real_build = pipeline.payloads.build_payload
|
||||
|
||||
def broken(doc, chunk, cfg):
|
||||
payload = real_build(doc, chunk, cfg)
|
||||
payload.pop("confidence")
|
||||
return payload
|
||||
|
||||
with mock.patch("corpus.payloads.build_payload", side_effect=broken):
|
||||
with self.assertRaises(pipeline.PipelineError):
|
||||
self.run_pipeline("rebuild")
|
||||
self.assertEqual(self.fake.collections.get(self.cfg.collection, {}), {})
|
||||
|
||||
def test_provider_change_is_refused_without_rebuild(self):
|
||||
self.run_pipeline("rebuild")
|
||||
other = support.make_config(self.root, model="local-hashing-v2")
|
||||
with self.assertRaises(pipeline.PipelineError) as caught:
|
||||
self.run_pipeline("sync", cfg=other)
|
||||
self.assertIn("governed migration", str(caught.exception))
|
||||
|
||||
def test_contract_is_recorded(self):
|
||||
self.run_pipeline("rebuild")
|
||||
with store_module.Store(self.cfg.db_path) as store:
|
||||
contract = store.get_meta("collection")
|
||||
self.assertEqual(contract["model"], self.cfg.model)
|
||||
self.assertEqual(contract["dim"], self.cfg.dim)
|
||||
self.assertEqual(contract["chunker_version"], self.cfg.chunker_version)
|
||||
self.assertTrue(os.path.exists(self.cfg.meta_path))
|
||||
|
||||
|
||||
class VerifyTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = support.temp_repo()
|
||||
self.cfg = support.make_config(self.root)
|
||||
self.addCleanup(_cleanup, self.root)
|
||||
|
||||
def test_clean_after_rebuild(self):
|
||||
with support.fake_qdrant(self.cfg):
|
||||
pipeline.run(self.cfg, mode="rebuild")
|
||||
self.assertEqual(pipeline.verify(self.cfg), [])
|
||||
|
||||
def test_detects_source_changed_without_sync(self):
|
||||
with support.fake_qdrant(self.cfg):
|
||||
pipeline.run(self.cfg, mode="rebuild")
|
||||
support.write_doc(
|
||||
self.root,
|
||||
"ai/ai-overview.md",
|
||||
id="ai-overview", area="ai", level=1, title="Agent Systems",
|
||||
claim="Changed.", confidence="running", last_verified="2026-10-02",
|
||||
tags="agents", body="A different body entirely.",
|
||||
)
|
||||
problems = pipeline.verify(self.cfg)
|
||||
self.assertTrue(any("changed since it was embedded" in problem for problem in problems), problems)
|
||||
|
||||
def test_detects_missing_document(self):
|
||||
with support.fake_qdrant(self.cfg):
|
||||
pipeline.run(self.cfg, mode="rebuild")
|
||||
os.remove(os.path.join(self.root, "content", "ai", "ai-overview.md"))
|
||||
problems = pipeline.verify(self.cfg)
|
||||
self.assertTrue(any("no longer in content/" in problem for problem in problems), problems)
|
||||
|
||||
|
||||
class AreaFilterTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = support.temp_repo()
|
||||
self.cfg = support.make_config(self.root)
|
||||
self.addCleanup(_cleanup, self.root)
|
||||
|
||||
def test_filter_narrows_the_result_set(self):
|
||||
with support.fake_qdrant(self.cfg):
|
||||
pipeline.run(self.cfg, mode="rebuild")
|
||||
result = pipeline.demo_area_filter(self.cfg, "container estate limits", area="edge")
|
||||
self.assertTrue(result["unfiltered"])
|
||||
self.assertTrue(result["filtered"])
|
||||
self.assertEqual({row["area"] for row in result["filtered"]}, {"edge"})
|
||||
self.assertGreater(len({row["area"] for row in result["unfiltered"]}), 1)
|
||||
|
||||
|
||||
def _cleanup(root):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
104
corpus/tests/test_qdrant_client.py
Normal file
104
corpus/tests/test_qdrant_client.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Qdrant client: the wire shape the pipeline depends on, and the fallback route."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from corpus import qdrant as qdrant_module
|
||||
from corpus.tests import support
|
||||
|
||||
|
||||
class ClientTest(unittest.TestCase):
|
||||
def test_create_collection_sends_size_and_distance(self):
|
||||
with support.StubServer(lambda method, path, body: (200, {"result": True})) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=1)
|
||||
client.create_collection("resume_corpus", 1536)
|
||||
request = server.requests[0]
|
||||
self.assertEqual(request["method"], "PUT")
|
||||
self.assertEqual(request["path"], "/collections/resume_corpus")
|
||||
body = request["body"]
|
||||
self.assertEqual(body["vectors"]["size"], 1536)
|
||||
self.assertEqual(body["vectors"]["distance"], "Cosine")
|
||||
# A memory-bound host: payloads and the HNSW graph live on disk.
|
||||
self.assertTrue(body["on_disk_payload"])
|
||||
self.assertTrue(body["hnsw_config"]["on_disk"])
|
||||
self.assertFalse(body["vectors"]["on_disk"])
|
||||
self.assertGreater(body["optimizers_config"]["memmap_threshold"], 0)
|
||||
|
||||
def test_upsert_waits_and_batches(self):
|
||||
with support.StubServer(lambda method, path, body: (200, {"result": {}})) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=1)
|
||||
points = [{"id": f"id-{index}", "vector": [0.0], "payload": {}} for index in range(5)]
|
||||
sent = client.upsert("c", points, batch=2)
|
||||
self.assertEqual(sent, 5)
|
||||
self.assertEqual(len(server.requests), 3)
|
||||
self.assertTrue(all("wait=true" in request["path"] for request in server.requests))
|
||||
self.assertEqual(sum(len(r["body"]["points"]) for r in server.requests), 5)
|
||||
|
||||
def test_delete_by_source_sends_a_source_filter(self):
|
||||
with support.StubServer(lambda method, path, body: (200, {"result": {}})) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=1)
|
||||
client.delete_by_source("c", "content/edge/l3/edge-zigbee.md")
|
||||
request = server.requests[0]
|
||||
self.assertEqual(request["method"], "POST")
|
||||
self.assertIn("/collections/c/points/delete", request["path"])
|
||||
self.assertEqual(
|
||||
request["body"],
|
||||
{"filter": {"must": [{"key": "source_path", "match": {"value": "content/edge/l3/edge-zigbee.md"}}]}},
|
||||
)
|
||||
|
||||
def test_search_filters_before_searching(self):
|
||||
responder = lambda method, path, body: (200, {"result": {"points": []}}) # noqa: E731
|
||||
with support.StubServer(responder) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=1)
|
||||
client.search("c", [0.1, 0.2], limit=3, area="edge", confidence="delivered")
|
||||
body = server.requests[0]["body"]
|
||||
self.assertEqual(server.requests[0]["path"], "/collections/c/points/query")
|
||||
self.assertEqual(body["limit"], 3)
|
||||
self.assertEqual(
|
||||
body["filter"],
|
||||
{
|
||||
"must": [
|
||||
{"key": "area", "match": {"value": "edge"}},
|
||||
{"key": "confidence", "match": {"value": "delivered"}},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def test_search_falls_back_to_the_legacy_route(self):
|
||||
def responder(method, path, body):
|
||||
if path.endswith("/points/query"):
|
||||
return 404, {"status": "not found"}
|
||||
return 200, {"result": [{"id": "p1", "score": 0.9, "payload": {"area": "ai"}}]}
|
||||
|
||||
with support.StubServer(responder) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=1)
|
||||
hits = client.search("c", [0.1])
|
||||
self.assertEqual([hit["id"] for hit in hits], ["p1"])
|
||||
self.assertEqual([r["path"] for r in server.requests][-1], "/collections/c/points/search")
|
||||
|
||||
def test_point_count_is_exact(self):
|
||||
responder = lambda method, path, body: (200, {"result": {"count": 42}}) # noqa: E731
|
||||
with support.StubServer(responder) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=1)
|
||||
self.assertEqual(client.point_count("c"), 42)
|
||||
self.assertEqual(server.requests[0]["body"], {"exact": True})
|
||||
|
||||
def test_health_is_false_when_unreachable(self):
|
||||
client = qdrant_module.Qdrant("http://127.0.0.1:1", timeout=0.2, attempts=1)
|
||||
self.assertFalse(client.health())
|
||||
|
||||
def test_upsert_retries_a_server_error(self):
|
||||
plan = [500, 200]
|
||||
|
||||
def responder(method, path, body):
|
||||
return plan.pop(0), {"result": {}}
|
||||
|
||||
with support.StubServer(responder) as server:
|
||||
client = qdrant_module.Qdrant(server.url, attempts=2)
|
||||
client.upsert("c", [{"id": "x", "vector": [0.0], "payload": {}}])
|
||||
self.assertEqual(len(server.requests), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
106
corpus/tests/test_store.py
Normal file
106
corpus/tests/test_store.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""FTS5 chunk index, manifest and meta: the keyword half of hybrid retrieval."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from corpus import chunker, loader, payloads, store as store_module
|
||||
from corpus.config import Config
|
||||
from corpus.pipeline import chunk_id
|
||||
from corpus.tests import support
|
||||
|
||||
|
||||
def build_store(path, cfg, documents):
|
||||
store = store_module.Store(path)
|
||||
for doc in documents:
|
||||
payload_list = []
|
||||
for chunk in chunker.chunk_document(doc.body):
|
||||
payload = payloads.build_payload(doc, chunk, cfg)
|
||||
payload["id"] = chunk_id(doc.path, chunk.index, doc.digest)
|
||||
payload_list.append(payload)
|
||||
store.write_document(doc, payload_list)
|
||||
return store
|
||||
|
||||
|
||||
class StoreTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = support.temp_repo()
|
||||
self.cfg = support.make_config(self.root)
|
||||
self.addCleanup(_cleanup, self.root)
|
||||
self.documents = loader.load_documents(self.cfg)
|
||||
self.store = build_store(self.cfg.db_path, self.cfg, self.documents)
|
||||
self.addCleanup(self.store.close)
|
||||
|
||||
def test_counts_match_the_documents(self):
|
||||
self.assertEqual(self.store.source_count(), len(self.documents))
|
||||
expected = sum(len(chunker.chunk_document(doc.body)) for doc in self.documents)
|
||||
self.assertEqual(self.store.chunk_count(), expected)
|
||||
|
||||
def test_keyword_search_finds_exact_tokens(self):
|
||||
hits = self.store.keyword_search("zigbee")
|
||||
self.assertTrue(hits, "no hit for a token that is literally in the document")
|
||||
self.assertEqual(hits[0]["area"], "edge")
|
||||
|
||||
def test_keyword_search_respects_area_filter(self):
|
||||
all_hits = self.store.keyword_search("latency")
|
||||
edge_hits = self.store.keyword_search("latency", area="edge")
|
||||
self.assertTrue(all_hits)
|
||||
for hit in edge_hits:
|
||||
self.assertEqual(hit["area"], "edge")
|
||||
|
||||
def test_keyword_search_returns_snippets_and_scores(self):
|
||||
hits = self.store.keyword_search("fence")
|
||||
self.assertTrue(hits)
|
||||
self.assertIn("score", hits[0])
|
||||
self.assertIn("snippet", hits[0])
|
||||
|
||||
def test_operator_injection_is_not_honoured(self):
|
||||
# FTS5 operators must be treated as words, never as query syntax.
|
||||
hits = self.store.keyword_search('edge OR "NEAR"(a b)')
|
||||
self.assertIsInstance(hits, list)
|
||||
|
||||
def test_manifest_records_the_content_hash(self):
|
||||
manifest = self.store.manifest()
|
||||
self.assertEqual(set(manifest), {doc.path for doc in self.documents})
|
||||
for doc in self.documents:
|
||||
self.assertEqual(manifest[doc.path]["content_hash"], doc.digest)
|
||||
self.assertGreater(manifest[doc.path]["chunk_count"], 0)
|
||||
|
||||
def test_delete_removes_rows_from_both_tables(self):
|
||||
target = self.documents[1]
|
||||
self.store.delete_document(target.path)
|
||||
self.assertNotIn(target.path, self.store.manifest())
|
||||
for row in self.store.chunk_payloads():
|
||||
self.assertNotEqual(row["source_path"], target.path)
|
||||
|
||||
def test_meta_roundtrip(self):
|
||||
self.store.set_meta("collection", {"model": "x", "dim": 8})
|
||||
self.assertEqual(self.store.get_meta("collection"), {"model": "x", "dim": 8})
|
||||
self.assertIn("collection", self.store.meta())
|
||||
|
||||
def test_payloads_survive_the_roundtrip(self):
|
||||
rows = self.store.chunk_payloads()
|
||||
self.assertTrue(rows)
|
||||
for row in rows:
|
||||
payloads.validate_payload(row)
|
||||
|
||||
|
||||
class MatchExpressionTest(unittest.TestCase):
|
||||
def test_terms_become_prefix_queries(self):
|
||||
self.assertEqual(store_module.match_expression("Zigbee mesh"), '"zigbee"* AND "mesh"*')
|
||||
|
||||
def test_empty_query_is_empty(self):
|
||||
self.assertEqual(store_module.match_expression(" "), "")
|
||||
|
||||
def test_punctuation_is_dropped(self):
|
||||
self.assertEqual(store_module.match_expression("-edge- LoRaWAN, 2023"), '"edge"* AND "lorawan"* AND "2023"*')
|
||||
|
||||
|
||||
def _cleanup(root):
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
29
deploy/Dockerfile
Normal file
29
deploy/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
# Runtime image for the Mode A site (docs/phase-2-static-site.md §2.1).
|
||||
#
|
||||
# The image contains no toolchain: `make build dist` on the build host produces
|
||||
# bin/resume-server and dist/, and the image just carries them. That keeps the
|
||||
# runtime offline and small, and it means the deployed bytes are the bytes that
|
||||
# were built and tested, not a second compile.
|
||||
#
|
||||
# Build context is the repository root:
|
||||
#
|
||||
# make build dist
|
||||
# docker build -f deploy/Dockerfile -t ai-resume:local .
|
||||
#
|
||||
# Phase 3 (§3.1) owns the release directory, the compose invocation and the
|
||||
# rollback; this file only describes the process that runs.
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
COPY bin/resume-server /usr/bin/resume-server
|
||||
COPY dist/ /srv/site
|
||||
|
||||
# 0.0.0.0 is inside the container's own network namespace only. The host
|
||||
# publishes the port on 127.0.0.1 (see deploy/compose.yml), which is what keeps
|
||||
# Caddy the only public listener.
|
||||
ENV RESUME_ADDR=0.0.0.0:8096 \
|
||||
RESUME_DIST=/srv/site
|
||||
|
||||
EXPOSE 8096
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/usr/bin/resume-server"]
|
||||
71
deploy/compose.yml
Normal file
71
deploy/compose.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
# Container shape for the Mode A site (docs/phase-2-static-site.md §2.1).
|
||||
#
|
||||
# Values are fixed by the specification and must not drift:
|
||||
# mem_limit == memswap_limit == 120m so the app cannot eat the host's swap
|
||||
# ports published on 127.0.0.1 only Caddy on .35 is the only public listener
|
||||
# logging capped one bad day cannot fill the disk
|
||||
#
|
||||
# Run it (build the artefacts first — Phase 3's deploy.sh does both):
|
||||
#
|
||||
# make build dist
|
||||
# docker compose -f deploy/compose.yml up -d
|
||||
#
|
||||
# `curl http://127.0.0.1:8096/healthz` must answer with static ready.
|
||||
|
||||
# The compose project name is deliberately not `ai-resume`: /home/sam/deployment/
|
||||
# ai-resume on .13 already runs a compose project by that name (backend + db on
|
||||
# the ai-mesh network). Sharing the name would make this site's containers show
|
||||
# up as orphans in that project and vice versa, so anything running `compose
|
||||
# down --remove-orphans` under either project could stop the other. The image
|
||||
# tag stays ai-resume:local (Phase 3 fixes it); only the project namespace is
|
||||
# distinct.
|
||||
name: ai-resume-site
|
||||
|
||||
services:
|
||||
resume:
|
||||
image: ai-resume:local
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
restart: unless-stopped
|
||||
mem_limit: 120m
|
||||
memswap_limit: 120m
|
||||
# Published on this host's LAN address, not loopback. Caddy lives on .35 and
|
||||
# proxies to 192.168.20.13:8096, exactly as it does for offsite (:8095),
|
||||
# photo-filter (:8092), garage-ui (:3909) and prefect (:4200). The port is
|
||||
# reachable inside the house only; Caddy on .35 remains the sole public
|
||||
# listener, which is the intent of AGENTS.md section 4.
|
||||
ports:
|
||||
- "192.168.20.13:8096:8096"
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD", "/usr/bin/resume-server", "--health-check"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
# The project network gets an explicit, small subnet. The host's docker daemon
|
||||
# has every default address pool allocated by other projects ("all predefined
|
||||
# address pools have been fully subnetted"), so an implicit compose network
|
||||
# cannot be created. Pinning /28 of RFC1918 space that no other bridge or route
|
||||
# on .13 uses keeps this deploy independent of the host's pool exhaustion and
|
||||
# does not touch any other container. Single-service stack: two addresses is
|
||||
# plenty.
|
||||
networks:
|
||||
default:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 10.89.0.0/28
|
||||
gateway: 10.89.0.1
|
||||
|
||||
# Phase 4/5 add at most three more containers (Qdrant, Prefect worker, query
|
||||
# service) inside the 4-container and 1.0 GiB budget in AGENTS.md §4.3. Mode A
|
||||
# needs none of them and must keep working when they are stopped (test A1).
|
||||
390
deploy/deploy.sh
Executable file
390
deploy/deploy.sh
Executable 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
66
deploy/qdrant.yml
Normal 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:
|
||||
@@ -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
304
docs/phase-4-notes.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# Phase 4 notes — corpus and retrieval
|
||||
|
||||
Operator manual for the corpus pipeline, and the evidence for the Phase 4
|
||||
Definition of Done in `docs/phase-4-corpus.md`. Every number below was measured
|
||||
on 2026-10-26 against a live Qdrant 1.20.0 container with a 400 MiB cap.
|
||||
|
||||
---
|
||||
|
||||
## 1. What exists now
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `corpus/` | The pipeline: one Python package, standard library only |
|
||||
| `corpus/cli.py` | `python3 -m corpus.cli {rebuild,sync,audit,verify,demo-filter,hybrid,measure,info}` |
|
||||
| `corpus/chunker.py` | Code-aware chunking (fenced block, table and front-matter are atomic) |
|
||||
| `corpus/embeddings.py` | The only place a vector is made; two providers behind one interface |
|
||||
| `corpus/qdrant.py` | Qdrant REST client (no `qdrant-client` dependency) |
|
||||
| `corpus/store.py` | SQLite: FTS5 over chunks, the incremental manifest, the collection contract |
|
||||
| `corpus/pipeline.py` | validate → chunk → enrich → embed → upsert, in that order |
|
||||
| `corpus/freshness.py` | Nightly audit: stale documents, index drift, contract drift |
|
||||
| `corpus/measure.py` | Memory measurement at 500 / 5,000 / 20,000 chunks |
|
||||
| `corpus/tests/` | 76 unit tests, no container, no network |
|
||||
| `flows/corpus.py` | Prefect flows: `corpus-sync`, `corpus-rebuild`, `corpus-audit` |
|
||||
| `deploy/qdrant.yml` | Qdrant container: `mem_limit` = `memswap_limit` = 400 m, loopback only |
|
||||
|
||||
Two indexes are written from one pass over `content/`, so they cannot drift:
|
||||
|
||||
* **vectors** in Qdrant (`resume_corpus`), one point per chunk;
|
||||
* **keywords** in SQLite (`data/corpus/corpus.db`), one FTS5 row per chunk.
|
||||
|
||||
Retrieval uses both (`corpus.cli hybrid`, reciprocal rank fusion). Vectors miss
|
||||
exact tokens such as repo names, `LoRaWAN`, `MQTT` and dates; FTS5 alone misses
|
||||
paraphrase. Decision D10 requires the pair.
|
||||
|
||||
---
|
||||
|
||||
## 2. Commands
|
||||
|
||||
```sh
|
||||
make corpus # rebuild from content/ (drops the collection first)
|
||||
make corpus-sync # incremental: only changed documents are re-embedded
|
||||
make corpus-audit # freshness + drift audit; exit 1 on findings
|
||||
make corpus-verify # index vs content/ vs Qdrant, consistency report
|
||||
make corpus-demo # the same query with and without an area filter
|
||||
make corpus-test # 76 unit tests
|
||||
```
|
||||
|
||||
Direct form, when you need flags:
|
||||
|
||||
```sh
|
||||
python3 -m corpus.cli rebuild --qdrant-url http://127.0.0.1:6333 --dim 1024
|
||||
python3 -m corpus.cli audit --today 2027-01-20 --json --fail-on-findings
|
||||
python3 -m corpus.cli measure --sizes 500,5000,20000 --container ai-resume-qdrant
|
||||
```
|
||||
|
||||
Configuration is environment-first. Nothing is read from a file in this repo.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `RESUME_QDRANT_URL` | `http://127.0.0.1:6333` | Qdrant REST endpoint |
|
||||
| `RESUME_COLLECTION` | `resume_corpus` | collection name |
|
||||
| `RESUME_EMBED_PROVIDER` | `local-hashing` | `local-hashing` or `omniroute` |
|
||||
| `RESUME_EMBED_MODEL` | provider default | recorded per collection |
|
||||
| `RESUME_EMBED_DIM` | provider default | recorded per collection |
|
||||
| `RESUME_CORPUS_DB` | `data/corpus/corpus.db` | FTS5 + manifest |
|
||||
| `RESUME_MAX_AGE_DAYS` | `180` | freshness horizon |
|
||||
| `RESUME_WARN_DAYS` | `30` | how early "due soon" starts |
|
||||
| `RESUME_APPRISE_URL` | unset | existing Apprise endpoint for audit alerts |
|
||||
| `OMNIROUTE_API_KEY` | unset | only for the `omniroute` provider; never in the repo |
|
||||
|
||||
---
|
||||
|
||||
## 3. Pipeline
|
||||
|
||||
```
|
||||
content/ ─► make validate ─► chunk ─► enrich ─► embed ─► upsert Qdrant
|
||||
│
|
||||
└─► SQLite FTS5 + manifest
|
||||
```
|
||||
|
||||
* **Validate first.** The pipeline runs the Phase 1 validator
|
||||
(`tools/validate_content.py`). A broken content tree stops the run before a
|
||||
single vector is written.
|
||||
* **Chunking.** Target 600 tokens, hard ceiling 800, ~12 % overlap. The body is
|
||||
parsed into atomic blocks (fenced code, table, heading, list item, quote,
|
||||
paragraph, rule) and chunks are packed from whole blocks, so a boundary
|
||||
cannot fall inside a fence, a table or front-matter. A list item or a table
|
||||
that exceeds 800 tokens becomes one oversize chunk and is flagged, never cut.
|
||||
Tokens are counted as words plus punctuation, which is a conservative
|
||||
under-estimate of BPE tokens.
|
||||
* **Payload before vectors.** Every chunk payload is built and validated for
|
||||
every changed document *before* one point is written. A missing or invalid
|
||||
field aborts the run with the document and chunk named.
|
||||
* **Deterministic ids.** `sha256(source_path + chunk_index + content_hash)`,
|
||||
rendered as a UUID because Qdrant accepts only integers or UUIDs. Re-running
|
||||
updates points; a document whose chunk count shrinks has its old points
|
||||
deleted by `source_path` filter, so orphans cannot accumulate.
|
||||
* **Incremental.** One hash comparison against the manifest per document. The
|
||||
document is re-embedded when its bytes changed, or when the embedding model,
|
||||
dimension or chunker version changed.
|
||||
* **Consistency check.** At the end of every run the point count in Qdrant must
|
||||
equal the chunk count in SQLite, or the run fails.
|
||||
|
||||
Corpus today: **40 documents → 51 chunks**, token range 100–707 (median 225),
|
||||
8 protected regions (fenced blocks and tables), 0 boundary violations.
|
||||
|
||||
---
|
||||
|
||||
## 4. Payload
|
||||
|
||||
`docs/phase-4-corpus.md` §4.2 fields, plus provenance so an answer can cite:
|
||||
|
||||
```json
|
||||
{
|
||||
"area": "edge", "level": 3, "source_type": "doc", "confidence": "running",
|
||||
"period": "2020–", "artefacts": [], "last_verified": "2026-10-26",
|
||||
"text": "# Artefact — Distributed audio …",
|
||||
"id": "014d792f-28d9-e0a0-610e-7ffc04eea3ad",
|
||||
"source_path": "content/edge/l3/edge-snapcast.md", "source_id": "edge-snapcast",
|
||||
"chunk_index": 0, "title": "…", "claim": "…", "url": "/edge/a/edge-snapcast/",
|
||||
"content_hash": "12fc4fa9…", "embedding_model": "local-hashing-v1",
|
||||
"embedding_dim": 1024, "chunker_version": "blocks-v1", "chunk_tokens": 191,
|
||||
"public": true, "tech": ["Snapcast", "Mopidy", "…"], "tags": ["edge", "audio"]
|
||||
}
|
||||
```
|
||||
|
||||
`area` and `confidence` are the two that matter. The query path filters on them
|
||||
*before* it searches, and an answer can qualify unfinished work instead of
|
||||
presenting it as delivered.
|
||||
|
||||
---
|
||||
|
||||
## 5. Freshness audit
|
||||
|
||||
```sh
|
||||
make corpus-audit # human table, exit 1 on findings
|
||||
python3 -m corpus.cli audit --json # machine-readable, for Prefect
|
||||
```
|
||||
|
||||
Findings, in order of seriousness:
|
||||
|
||||
| Finding | Meaning |
|
||||
|---|---|
|
||||
| `stale` | `last_verified` older than `RESUME_MAX_AGE_DAYS` (180). The claim may no longer be true |
|
||||
| `due_soon` | inside the warning window (30 days by default). A warning, not a failure |
|
||||
| `index_drift` | the source changed, disappeared or was never indexed since it was embedded |
|
||||
| `contract_drift` | the collection was built with a different model, dimension or chunker |
|
||||
|
||||
`ok` is false when there is a `stale`, `index_drift` or `contract_drift`
|
||||
finding. `due_soon` alone keeps the audit green.
|
||||
|
||||
`--alert` posts the report to `RESUME_APPRISE_URL` when the audit is not ok.
|
||||
The Prefect flow does this by default.
|
||||
|
||||
---
|
||||
|
||||
## 6. Scheduling (Prefect, existing server, worker only)
|
||||
|
||||
`flows/corpus.py` defines three flows. It imports Prefect when present and runs
|
||||
as plain Python when it is not, so the CLI never depends on the orchestrator.
|
||||
|
||||
On `.13`, which already runs the Prefect server — **do not start a second one**:
|
||||
|
||||
```sh
|
||||
# once, per deployment
|
||||
export RESUME_QDRANT_URL=http://127.0.0.1:6333
|
||||
export RESUME_EMBED_PROVIDER=omniroute RESUME_EMBED_MODEL=<approved model> RESUME_EMBED_DIM=<dim>
|
||||
prefect deploy flows/corpus.py:sync_corpus --name corpus-sync
|
||||
prefect deploy flows/corpus.py:freshness_audit --name corpus-audit --cron "0 4 * * *"
|
||||
```
|
||||
|
||||
Triggers required by `docs/phase-4-corpus.md` §4.4:
|
||||
|
||||
* **on push touching `content/`** — a deployment trigger on the repository, or
|
||||
`make corpus-sync` from the same post-receive hook that runs `deploy.sh`;
|
||||
* **nightly audit** — cron `0 4 * * *`, which alerts through Apprise.
|
||||
|
||||
This deployment is **not applied yet**: it needs the runtime host and the
|
||||
provider decision in §8. Both are Sam's. The flows themselves are ready.
|
||||
|
||||
---
|
||||
|
||||
## 7. Qdrant memory at three corpus sizes
|
||||
|
||||
Measured with `python3 -m corpus.cli measure`, Qdrant 1.20.0 in a container
|
||||
capped at 400 MiB (`mem_limit` = `memswap_limit`), 1024-dimensional vectors,
|
||||
real payloads from `content/`, throttled ingest in batches of 250.
|
||||
|
||||
| Chunks | Points | Settled RSS | Peak during ingest | Growth | Bytes/chunk | Ingest time |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 0 | 0 | 88.6 MiB | — | — | — | — |
|
||||
| 500 | 500 | 128.3 MiB | 148.5 MiB | +39.7 MiB | 83 KiB | 1.1 s |
|
||||
| 5,000 | 5,000 | 137.7 MiB | 305.0 MiB | +49.1 MiB | 10 KiB | 10.9 s |
|
||||
| 20,000 | 20,000 | 150.9 MiB | 292.3 MiB | +62.3 MiB | 3 KiB | 43.7 s |
|
||||
|
||||
All three sizes are **within the 400 MiB cap**. The peak is the number that
|
||||
matters: the cgroup limit is tripped during a bulk write, not after it.
|
||||
|
||||
### The finding that changed the design
|
||||
|
||||
With Qdrant's **default** settings the container was OOM-killed at exactly the
|
||||
cap between 5,000 and 20,000 chunks (`anon-rss: 404204kB`, `exitCode 137`,
|
||||
`constraint=CONSTRAINT_MEMCG`). The default keeps the HNSW graph and the payload
|
||||
in RAM and holds several segments live during ingest.
|
||||
|
||||
The collection is now created memory-frugal, and the pipeline is the only code
|
||||
that creates it (`corpus/qdrant.py`):
|
||||
|
||||
```json
|
||||
{
|
||||
"vectors": {"size": 1024, "distance": "Cosine", "on_disk": false},
|
||||
"hnsw_config": {"m": 8, "ef_construct": 64, "on_disk": true},
|
||||
"on_disk_payload": true,
|
||||
"optimizers_config": {"default_segment_number": 2, "memmap_threshold": 5000, "indexing_threshold": 20000}
|
||||
}
|
||||
```
|
||||
|
||||
Vectors stay in RAM because search reads them on every query. The HNSW graph and
|
||||
the payloads go to disk, where the kernel can reclaim those pages instead of
|
||||
killing the process. `memmap_threshold` 5,000 makes segments that size mmap;
|
||||
`indexing_threshold` 20,000 stops a bulk ingest from building the graph while it
|
||||
is still writing. The cost is build time and a little search latency, both
|
||||
irrelevant for a corpus of this size and a public site with no query burst.
|
||||
|
||||
Practical ceiling: at 1024 dimensions the cap holds 20,000 chunks. If the corpus
|
||||
grows past that, set `RESUME_QDRANT_VECTORS_ON_DISK=1`, or reduce the dimension,
|
||||
or raise the cap — in that order of preference. Vector memory scales linearly
|
||||
with dimension: 1,536-dimension vectors need 50 % more than recorded here.
|
||||
|
||||
---
|
||||
|
||||
## 8. Embedding provider — still Sam's decision
|
||||
|
||||
`reference/stack.md` lists the open item: Gemini free tier, OpenRouter, or
|
||||
local. Phase 4 does not wait for it:
|
||||
|
||||
* **`omniroute`** is implemented and tested. OpenAI-compatible
|
||||
`/v1/embeddings`, batched, retry with backoff on 429 and 5xx, key from the
|
||||
environment only, no provider SDK, no direct vendor call (AGENTS.md §4.5).
|
||||
* **`local-hashing`** is the default for tests, for offline rebuilds and for the
|
||||
memory measurement. It is deterministic feature hashing, **not a semantic
|
||||
model**: it is a placeholder that lets the whole pipeline run without a
|
||||
provider decision, and it must not be used for answers.
|
||||
|
||||
The provider, model, dimension and chunker version are stored in the collection
|
||||
contract (`data/corpus/collection.json` and the `meta` table). A run whose
|
||||
configuration differs is refused:
|
||||
|
||||
```
|
||||
the collection contract does not match this configuration — refusing to mix
|
||||
vectors in one collection (docs/phase-4-corpus.md §4.3).
|
||||
```
|
||||
|
||||
### Changing the provider (governed migration)
|
||||
|
||||
1. Get Sam's decision and record it in `reference/decisions.md`.
|
||||
2. Set `RESUME_EMBED_PROVIDER`, `RESUME_EMBED_MODEL`, `RESUME_EMBED_DIM`, and
|
||||
`OMNIROUTE_API_KEY` in `~/.config/environment.d/`.
|
||||
3. `make corpus` — a rebuild, because a provider change invalidates every
|
||||
vector. It is one command and takes seconds at this corpus size.
|
||||
4. `make corpus-verify` and `make corpus-audit`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Recovery
|
||||
|
||||
Deleting the Qdrant volume or `data/corpus/` is **always recoverable**:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/qdrant.yml down -v
|
||||
docker compose -f deploy/qdrant.yml up -d
|
||||
make corpus # re-derives every vector and FTS row from content/
|
||||
make corpus-verify
|
||||
```
|
||||
|
||||
There is no state that exists only in the index. `content/` is the source of
|
||||
truth; the corpus is a derived artefact and lives in `data/`, which is
|
||||
gitignored.
|
||||
|
||||
---
|
||||
|
||||
## 10. Definition of Done — evidence
|
||||
|
||||
| DoD item | Evidence |
|
||||
|---|---|
|
||||
| One command rebuilds from an empty volume | `make corpus` on an empty Qdrant volume: 40 documents → 51 chunks, 51 points, 1.0 s |
|
||||
| Running it twice produces no duplicate vectors | rebuild ×2 → 51 points both times; `sync` with no changes → 0 embedding calls, 0 points sent |
|
||||
| No chunk boundary inside a fence, table or front-matter | `corpus/tests/test_chunker.py`; 51/51 stored chunks reproduce from `content/`, 8 protected regions, 0 violations |
|
||||
| Every chunk carries all payload fields; missing field fails the run | `corpus/tests/test_payloads.py` (each field removed in turn); 51/51 stored payloads validate; a broken payload aborts before any write (`test_invalid_payload_aborts_before_any_write`) |
|
||||
| Payload filtering demonstrably narrows results | `make corpus-demo` — query "distributed audio endpoints": unfiltered returns edge *and* ai documents; `--area edge` returns edge only, `--area ai` returns ai only |
|
||||
| Incremental re-embeds only changed documents | edit one of 40 documents → `updated=1 unchanged=39 embedding calls=1 texts=1`; re-run → `calls=0` |
|
||||
| Freshness audit runs and reports stale documents | `python3 -m corpus.cli audit` today: 0 findings. `--today 2027-10-05`: 40 stale documents, one row per document with age in days. `--today 2026-11-01`: 0 findings |
|
||||
| Qdrant memory at 500 / 5,000 / 20,000 chunks, within cap | §7 table; 128 / 138 / 151 MiB settled, 149 / 305 / 292 MiB peak, cap 400 MiB |
|
||||
|
||||
---
|
||||
|
||||
## 11. Not done here
|
||||
|
||||
* Qdrant is **not deployed to `.13`** and the Prefect deployment is not applied.
|
||||
`deploy/qdrant.yml` is ready; the runtime host is Phase 3's (`AUD-143`) and
|
||||
Sam's to apply.
|
||||
* No provider is chosen (§8).
|
||||
* Answering questions, the query graph, the streaming console and guardrails are
|
||||
Phase 5 and Phase 6. Phase 4 ends at "retrieval returns the right chunks".
|
||||
@@ -80,3 +80,43 @@ One hand-written TypeScript island, ES modules, no framework, no bundler.
|
||||
| Panel refactor needed | Phase 2 §2.4 contract; if it fails, fix the panel contract, do not fork the UI |
|
||||
| Streaming stalls behind Caddy | Explicit flush settings; p90 measurement, not a spot check |
|
||||
| Answers drift from the site | Citation verification is a node, not a hope |
|
||||
|
||||
---
|
||||
|
||||
## 5.6 Citations point at the original
|
||||
|
||||
An answer cites where it came from, not a curated page standing in for it.
|
||||
|
||||
| Source | Cited as |
|
||||
|---|---|
|
||||
| Gitea repo file | full URL to the file in Gitea |
|
||||
| Vault note | full URL to the note in `sam/obsidian-vault` |
|
||||
| Graph answer | the note title the node came from, plus its URL |
|
||||
| Curated claim | the site page |
|
||||
|
||||
A citation whose URL cannot be opened is dropped, not rendered. A claim with no
|
||||
traceable origin is not returned at all.
|
||||
|
||||
## 5.7 Curated outranks harvested
|
||||
|
||||
`content/` is tier 1; everything harvested is tier 2. When the two disagree, the
|
||||
tier 1 claim wins and the answer says so plainly: the curated description is
|
||||
current; the older text is background.
|
||||
|
||||
This is the rule that stops a stale README from quietly overwriting Sam's own
|
||||
description of his work.
|
||||
|
||||
## 5.8 The live source readout
|
||||
|
||||
While the answer streams, a panel shows what is being read, named as the visitor
|
||||
would recognise it:
|
||||
|
||||
```
|
||||
reading · gitea/photo-pipeline/README.md
|
||||
· obsidian/210 AI Resume/Graphify.md
|
||||
· graph · community 42 · vault infrastructure
|
||||
```
|
||||
|
||||
This is a feature, not a debug view. It answers the question every recruiter has
|
||||
and does not ask: is this making things up? Show the working — it is also the most
|
||||
convincing demonstration of the system on the page.
|
||||
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1792773339,
|
||||
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
27
flake.nix
Normal file
27
flake.nix
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
description = "Ai Resume — build environment (Go + templ + SQLite). Node, Vite and bundlers are deliberately absent.";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAll = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
in {
|
||||
devShells = forAll (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.go
|
||||
pkgs.templ
|
||||
pkgs.sqlite # the sqlite3 CLI, for inspecting the FTS5 index
|
||||
pkgs.gnumake
|
||||
pkgs.curl
|
||||
];
|
||||
shellHook = ''
|
||||
echo "ai-resume dev shell — go $(go version | cut -d' ' -f3), templ $(templ version)"
|
||||
echo "targets: make generate build test dev"
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
127
flows/corpus.py
Normal file
127
flows/corpus.py
Normal 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
25
go.mod
Normal file
@@ -0,0 +1,25 @@
|
||||
module gitea.lab.audasmedia.com.au/sam/ai-resume
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.1020
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/yuin/goldmark v1.8.6
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/sqlite v1.34.5 // indirect
|
||||
)
|
||||
46
go.sum
Normal file
46
go.sum
Normal file
@@ -0,0 +1,46 @@
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
456
internal/content/content.go
Normal file
456
internal/content/content.go
Normal 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)
|
||||
}
|
||||
}
|
||||
180
internal/content/markdown.go
Normal file
180
internal/content/markdown.go
Normal 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
187
internal/search/search.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// Package search builds the SQLite FTS5 index over the static pages.
|
||||
//
|
||||
// The index is written by the build step and opened read-only by the server, so
|
||||
// a search never touches the content tree and works with every AI service
|
||||
// stopped. The same index is the keyword half of Phase 4's hybrid retrieval:
|
||||
// its schema is documented in docs/phase-2-notes.md and must not change shape
|
||||
// without both phases agreeing.
|
||||
package search
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Doc is one indexable page.
|
||||
type Doc struct {
|
||||
ID string
|
||||
URL string
|
||||
Title string
|
||||
Area string
|
||||
Level int
|
||||
Confidence string
|
||||
Body string
|
||||
}
|
||||
|
||||
// Result is one hit.
|
||||
type Result struct {
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
Area string `json:"area"`
|
||||
Level int `json:"level"`
|
||||
Confidence string `json:"confidence"`
|
||||
Snippet string `json:"snippet"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// Schema is the FTS5 table. Columns marked UNINDEXED are still selectable.
|
||||
const schema = `
|
||||
CREATE VIRTUAL TABLE pages USING fts5(
|
||||
id UNINDEXED,
|
||||
url UNINDEXED,
|
||||
title,
|
||||
area UNINDEXED,
|
||||
level UNINDEXED,
|
||||
confidence UNINDEXED,
|
||||
body,
|
||||
tokenize = 'porter unicode61 remove_diacritics 2'
|
||||
);`
|
||||
|
||||
// Build writes a fresh index at path. Any existing file is replaced, so the
|
||||
// index always describes exactly one build.
|
||||
func Build(path string, docs []Doc) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open index %s: %w", path, err)
|
||||
}
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if _, err := db.Exec(`PRAGMA journal_mode = OFF; PRAGMA synchronous = OFF;`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return fmt.Errorf("create fts5 table: %w", err)
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO pages (id, url, title, area, level, confidence, body) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, d := range docs {
|
||||
if _, err := stmt.Exec(d.ID, d.URL, d.Title, d.Area, d.Level, d.Confidence, d.Body); err != nil {
|
||||
return fmt.Errorf("index %s: %w", d.ID, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO pages (pages) VALUES ('optimize')`); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Index is a read-only handle on a built index.
|
||||
type Index struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
}
|
||||
|
||||
// Open opens a built index read-only.
|
||||
func Open(path string) (*Index, error) {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, fmt.Errorf("search index %s: %w", path, err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Index{db: db, path: path}, nil
|
||||
}
|
||||
|
||||
// Close releases the handle.
|
||||
func (i *Index) Close() error { return i.db.Close() }
|
||||
|
||||
// Count returns the number of indexed pages.
|
||||
func (i *Index) Count() (int, error) {
|
||||
var n int
|
||||
err := i.db.QueryRow(`SELECT count(*) FROM pages`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Query runs a keyword search. Every term is matched as a prefix so "zigbee"
|
||||
// finds "Zigbee" and "routing" finds "routers".
|
||||
func (i *Index) Query(q string, limit int) ([]Result, error) {
|
||||
match := MatchExpression(q)
|
||||
if match == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := i.db.Query(`
|
||||
SELECT url, title, area, CAST(level AS INTEGER), confidence,
|
||||
snippet(pages, 6, '<mark>', '</mark>', '…', 18),
|
||||
bm25(pages)
|
||||
FROM pages
|
||||
WHERE pages MATCH ?
|
||||
ORDER BY bm25(pages)
|
||||
LIMIT ?`, match, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search %q: %w", q, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Result
|
||||
for rows.Next() {
|
||||
var r Result
|
||||
if err := rows.Scan(&r.URL, &r.Title, &r.Area, &r.Level, &r.Confidence, &r.Snippet, &r.Score); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MatchExpression turns free text into a safe FTS5 query: bare words only, each
|
||||
// as a prefix, all required. FTS5 operators in the input are never honoured, so
|
||||
// a visitor cannot shape the query beyond words.
|
||||
func MatchExpression(q string) string {
|
||||
terms := strings.FieldsFunc(strings.ToLower(q), func(r rune) bool {
|
||||
return !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-')
|
||||
})
|
||||
if len(terms) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := make([]string, 0, len(terms))
|
||||
for _, t := range terms {
|
||||
t = strings.Trim(t, "-")
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, `"`+t+`"*`)
|
||||
}
|
||||
return strings.Join(out, " AND ")
|
||||
}
|
||||
89
internal/search/search_test.go
Normal file
89
internal/search/search_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMatchExpressionIsWordsOnly is the security-relevant half of search: FTS5
|
||||
// syntax in visitor input is never honoured, so a query cannot shape the index
|
||||
// beyond words. It also means "zigbee)" is not an error page.
|
||||
func TestMatchExpressionIsWordsOnly(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"zigbee", `"zigbee"*`},
|
||||
{"Zigbee Routers", `"zigbee"* AND "routers"*`},
|
||||
{`zigbee" OR body:`, `"zigbee"* AND "or"* AND "body"*`},
|
||||
{`NEAR(a b)`, `"near"* AND "a"* AND "b"*`},
|
||||
{` `, ``},
|
||||
{`!!!`, ``},
|
||||
{"snapcast-audio", `"snapcast-audio"*`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := MatchExpression(tc.in); got != tc.want {
|
||||
t.Errorf("MatchExpression(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAndQuery is the §2.5 DoD check: the FTS5 index returns sensible
|
||||
// results for the three terms the phase document names.
|
||||
func TestBuildAndQuery(t *testing.T) {
|
||||
indexFile := filepath.Join(t.TempDir(), "search.db")
|
||||
docs := []Doc{
|
||||
{ID: "edge", URL: "/edge/", Title: "Edge & Devices", Area: "edge", Level: 1, Confidence: "running",
|
||||
Body: "Twenty nodes on constrained hardware. Zigbee routers and a LoRa link."},
|
||||
{ID: "platform", URL: "/platform/", Title: "Platform & Infrastructure", Area: "platform", Level: 1, Confidence: "running",
|
||||
Body: "Declarative hosts, networking and storage."},
|
||||
{ID: "products", URL: "/products/", Title: "Products", Area: "products", Level: 1, Confidence: "delivered",
|
||||
Body: "Commercial signage on commodity hardware."},
|
||||
{ID: "ai", URL: "/ai/", Title: "AI & Agentic Systems", Area: "ai", Level: 1, Confidence: "running",
|
||||
Body: "Model routing and cost control through a single gateway."},
|
||||
}
|
||||
if err := Build(indexFile, docs); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
idx, err := Open(indexFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer idx.Close()
|
||||
|
||||
if n, err := idx.Count(); err != nil || n != len(docs) {
|
||||
t.Fatalf("Count = %d, %v; want %d", n, err, len(docs))
|
||||
}
|
||||
|
||||
cases := []struct{ q, wantURL string }{
|
||||
{"zigbee", "/edge/"},
|
||||
{"signage", "/products/"},
|
||||
{"routing", "/ai/"},
|
||||
{"ZIGBEE", "/edge/"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
results, err := idx.Query(tc.q, 10)
|
||||
if err != nil {
|
||||
t.Errorf("Query(%q): %v", tc.q, err)
|
||||
continue
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Errorf("Query(%q) found nothing", tc.q)
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, r := range results {
|
||||
if r.URL == tc.wantURL {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Query(%q) did not return %s; got %+v", tc.q, tc.wantURL, results)
|
||||
}
|
||||
if !strings.Contains(results[0].Snippet, "<mark>") {
|
||||
t.Errorf("Query(%q) snippet has no highlight: %q", tc.q, results[0].Snippet)
|
||||
}
|
||||
}
|
||||
|
||||
if results, err := idx.Query("!!!", 10); err != nil || len(results) != 0 {
|
||||
t.Errorf("Query(no words) = %v, %v; want no results and no error", results, err)
|
||||
}
|
||||
}
|
||||
602
internal/server/server.go
Normal file
602
internal/server/server.go
Normal 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
|
||||
}
|
||||
371
internal/server/server_test.go
Normal file
371
internal/server/server_test.go
Normal 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
188
internal/server/stream.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
|
||||
)
|
||||
|
||||
// StreamEvent is the SSE event the panel listens for. The binding rendered by
|
||||
// stage.SSEDriver uses the same name, so a client needs no configuration.
|
||||
const StreamEvent = "stage"
|
||||
|
||||
// maxStreamKeys caps how many fragments one connection may ask for, so a
|
||||
// crafted query cannot turn the demonstration into a long-lived loop.
|
||||
const maxStreamKeys = 24
|
||||
|
||||
// handleStream points the stage panel at an event stream.
|
||||
//
|
||||
// This is the Phase 2 proof of the §2.4 contract: the panel markup does not
|
||||
// change, only the binding does. The frames come from the fragments the build
|
||||
// already rendered, so nothing calls a model and the site still works with
|
||||
// every AI service stopped. Phase 5 replaces the source behind the same
|
||||
// interface (stage.Source) and this handler does not change.
|
||||
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming is not supported by this server", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if s.manifest == nil || len(s.manifest.Entries) == 0 {
|
||||
msg := "the stage manifest is not part of this build"
|
||||
if s.manifestErr != nil {
|
||||
s.log.Warn("stream unavailable", "error", s.manifestErr)
|
||||
}
|
||||
http.Error(w, msg, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
keys := s.streamKeys(r.URL.Query())
|
||||
if len(keys) == 0 {
|
||||
keys = s.defaultStreamKeys()
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
http.Error(w, "no fragments to stream", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-store")
|
||||
h.Set("Connection", "keep-alive")
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// A client that reconnects gets a short pause rather than an immediate retry.
|
||||
if _, err := io.WriteString(w, "retry: 2000\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
|
||||
ctx := r.Context()
|
||||
source := s.newStreamSource(ctx)
|
||||
err := source.Stream(ctx, keys, func(f stage.Fragment) error {
|
||||
var buf bytes.Buffer
|
||||
if f.Rendered {
|
||||
// The build already rendered the whole article; send those bytes.
|
||||
if err := f.Body.Render(ctx, &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// A structured source (Phase 5) hands over parts, so the panel
|
||||
// renders them here with the same component the route driver uses.
|
||||
if err := templates.FragmentView(f).Render(ctx, &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeSSE(w, StreamEvent, buf.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
flusher.Flush()
|
||||
return nil
|
||||
})
|
||||
if err != nil && ctx.Err() == nil {
|
||||
// Headers are already sent, so there is no status to change. Log it and
|
||||
// let the panel keep the last fragment it received.
|
||||
s.log.Warn("stage stream ended early", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// streamKeys reads the fragment ids a caller asked for. Both `?k=a&k=b` and
|
||||
// `?k=a,b` work, because both are natural to write by hand.
|
||||
func (s *Server) streamKeys(q url.Values) []string {
|
||||
var keys []string
|
||||
for _, raw := range q["k"] {
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
id := strings.TrimSpace(part)
|
||||
if id != "" && validFragmentID(id) {
|
||||
keys = append(keys, id)
|
||||
}
|
||||
if len(keys) >= maxStreamKeys {
|
||||
return keys
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// defaultStreamKeys is the demonstration stream: the six area landing
|
||||
// fragments, in navigation order. The build writes those first, so the manifest
|
||||
// alone is enough — the server never reads the content tree.
|
||||
func (s *Server) defaultStreamKeys() []string {
|
||||
if s.manifest == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, 6)
|
||||
for _, e := range s.manifest.Entries {
|
||||
if !strings.HasPrefix(e.ID, "doc-") {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, e.ID)
|
||||
if len(keys) == 6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
return keys
|
||||
}
|
||||
for _, e := range s.manifest.Entries {
|
||||
keys = append(keys, e.ID)
|
||||
if len(keys) == 6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// newStreamSource wires the manifest to the files on disk and paces the frames.
|
||||
func (s *Server) newStreamSource(ctx context.Context) *stage.ManifestSource {
|
||||
read := func(e stage.Entry) (string, error) {
|
||||
file := filepath.Join(s.dist, filepath.FromSlash(e.File))
|
||||
if !within(s.dist, file) {
|
||||
return "", fmt.Errorf("fragment %s is outside the build directory", e.ID)
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fragment %s: %w", e.ID, err)
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
delay := s.cfg.StreamDelay
|
||||
frame := func(i int) {
|
||||
if i == 0 || delay <= 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
return stage.NewManifestSource(s.manifest, read, frame)
|
||||
}
|
||||
|
||||
// writeSSE writes one event. A payload with newlines is split across data
|
||||
// lines, which is what the server-sent events format expects.
|
||||
func writeSSE(w io.Writer, event, data string) error {
|
||||
var b strings.Builder
|
||||
b.WriteString("event: ")
|
||||
b.WriteString(event)
|
||||
b.WriteByte('\n')
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
b.WriteString("data: ")
|
||||
b.WriteString(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
_, err := io.WriteString(w, b.String())
|
||||
return err
|
||||
}
|
||||
318
internal/site/build.go
Normal file
318
internal/site/build.go
Normal 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
200
internal/site/fragments.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/content"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
|
||||
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
|
||||
)
|
||||
|
||||
// buildFragments renders every stage fragment once, in manifest order:
|
||||
//
|
||||
// 1. every document's own fragment, L1 first in area order
|
||||
// 2. every artefact fragment, in the same document order
|
||||
//
|
||||
// The order matters: the first len(content.Areas) entries are the six area
|
||||
// landing fragments, which is the default stream, and the server reads that
|
||||
// order out of the manifest without loading the content tree. The panel is a
|
||||
// content sink, so this is the only place that decides what a fragment is.
|
||||
// Neither driver renders anything itself.
|
||||
func (s *Site) buildFragments() error {
|
||||
order := make([]*content.Document, 0, len(s.Content.Docs))
|
||||
for _, a := range content.Areas {
|
||||
for _, d := range s.PublicInArea(a.Key) {
|
||||
if d.Level == 1 {
|
||||
order = append(order, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
rest := make([]*content.Document, 0, len(s.Content.Docs))
|
||||
for _, a := range content.Areas {
|
||||
for _, d := range s.PublicInArea(a.Key) {
|
||||
if d.Level != 1 {
|
||||
rest = append(rest, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
order = append(order, rest...)
|
||||
|
||||
for _, d := range order {
|
||||
if err := s.addFragment(s.docFragment(d)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, d := range order {
|
||||
for i, a := range d.Artefacts {
|
||||
f, err := s.artefactFragment(d, a, stage.ArtefactFragmentID(d.ID, i))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.addFragment(f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Site) addFragment(f *Fragment) error {
|
||||
if _, dup := s.fragByID[f.ID]; dup {
|
||||
return fmt.Errorf("stage fragment id %q is not unique", f.ID)
|
||||
}
|
||||
html, err := renderFragment(f.component())
|
||||
if err != nil {
|
||||
return fmt.Errorf("render stage fragment %s: %w", f.ID, err)
|
||||
}
|
||||
f.HTML = html
|
||||
s.Fragments = append(s.Fragments, f)
|
||||
s.fragByID[f.ID] = f
|
||||
s.FragmentsByDoc[f.DocID] = append(s.FragmentsByDoc[f.DocID], f.ID)
|
||||
s.Manifest.Entries = append(s.Manifest.Entries, stage.Entry{
|
||||
ID: f.ID,
|
||||
DocID: f.DocID,
|
||||
Title: f.Title,
|
||||
Kind: f.Kind,
|
||||
File: f.File,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// fragment builds the stage value this fragment renders from.
|
||||
func (f *Fragment) component() stage.Fragment {
|
||||
out := stage.Fragment{
|
||||
ID: f.ID,
|
||||
Kind: f.Kind,
|
||||
Title: f.Title,
|
||||
}
|
||||
out.Subtitle = f.subtitle
|
||||
out.Body = f.body
|
||||
out.Code = f.code
|
||||
out.Src = f.src
|
||||
out.Alt = f.alt
|
||||
out.Links = f.links
|
||||
out.Confidence = f.confidence
|
||||
out.DocURL = f.docURL
|
||||
out.DocTitle = f.docTitle
|
||||
return out
|
||||
}
|
||||
|
||||
func renderFragment(f stage.Fragment) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := templates.FragmentView(f).Render(context.Background(), &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// docFragment is the document's own body, shown in the panel when a reader
|
||||
// asks for the page itself rather than one of its artefacts.
|
||||
func (s *Site) docFragment(d *content.Document) *Fragment {
|
||||
area, _ := content.AreaByKey(d.Area)
|
||||
return &Fragment{
|
||||
ID: stage.DocFragmentID(d.ID),
|
||||
DocID: d.ID,
|
||||
Kind: stage.KindText,
|
||||
Title: d.Title,
|
||||
subtitle: area.Title + " · " + 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
509
internal/site/site.go
Normal 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
174
internal/site/site_test.go
Normal 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
317
internal/stage/stage.go
Normal 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
|
||||
}
|
||||
148
internal/stage/stage_test.go
Normal file
148
internal/stage/stage_test.go
Normal 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
12
internal/web/assets.go
Normal 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
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
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
290
internal/web/static/sse.js
Normal 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
|
||||
}
|
||||
})()
|
||||
343
internal/web/templates/components.templ
Normal file
343
internal/web/templates/components.templ
Normal 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"
|
||||
}
|
||||
254
internal/web/templates/pages.templ
Normal file
254
internal/web/templates/pages.templ
Normal 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"
|
||||
}
|
||||
229
internal/web/templates/view.go
Normal file
229
internal/web/templates/view.go
Normal 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, "<mark>", "<mark>")
|
||||
s = strings.ReplaceAll(s, "</mark>", "</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
46
platforms/gitea.yml
Normal 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
23
platforms/graphify.yml
Normal 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
44
platforms/obsidian.yml
Normal 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
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user