How it works — end to end

The Living Portfolio, explained from the ground up

A plain-English tour of every part of the system — the website, the AI that answers questions about Samuel, and the infrastructure underneath. Written for someone newer to software, but precise enough to defend to an engineer.

Every fact here was read straight from the source code, with exact function names, constants, and values. Where the roadmap holds something back until it's verified — like the eval judge's numbers, kept uncalibrated until checked against hand labels — this guide says the same. Nothing is claimed that the code doesn't back up.
00 The big picture

Two apps, two clouds, one push

The portfolio is two separate applications that live on two separate clouds, tied together by GitHub:

  • The site (living-portfolio) — a single hand-written index.html of plain HTML/CSS/JavaScript, served by Vercel.
  • The API (samuel-ai-api) — a Python FastAPI backend hosted on Render, which calls the Anthropic (Claude) API for answers, the OpenAI API for embeddings, and a Neon Postgres database for its knowledge store.
%%{init:{'theme':'base','flowchart':{'htmlLabels':false},'themeVariables':{'fontFamily':'ui-monospace, monospace','fontSize':'13px','primaryColor':'#e9f0ff','primaryBorderColor':'#3f74f5','primaryTextColor':'#17223a','lineColor':'#8794ab','secondaryColor':'#e4f6f2','tertiaryColor':'#f2f5fb'}}}%%
flowchart LR
  Dev([You: git push]) --> GH[GitHub
source of truth] GH -- rebuilds --> V[Vercel
the website] GH -- rebuilds --> R[Render
the API] User([Visitor's browser]) -- loads page --> V User -- asks a question --> R R -- embeds text --> O[OpenAI
embeddings] R -- writes/reads vectors --> N[(Neon Postgres
+ pgvector)] R -- generates answer --> A[Anthropic
Claude]
One push to GitHub rebuilds both clouds in parallel. The browser talks to Vercel for the page and to Render for the AI.
Why split
Separating a static site from an API is standard, and it creates the security story: because the browser (on Vercel's domain) calls an API on a different domain (Render), the browser enforces CORS; and because the API holds secret keys the browser must never see, those keys live only on Render. The two-origin split is deliberate.
In an interview"A static frontend on Vercel and a FastAPI backend on Render, one GitHub push deploying both. The two-origin split forces the CORS boundary and keeps every secret server-side."
Concepts in 60 seconds

The words you'll need

The rest of the guide uses these terms constantly. Here's each one in a sentence — the full glossary is at the bottom.

API
A server that answers requests over the web. The browser sends JSON, the API sends JSON back.
LLM
A large language model (here, Anthropic's Claude) — the thing that writes the answers.
RAG
Retrieval-Augmented Generation: look up the relevant real documents first, then make the model answer using only those.
Embedding
Turning a piece of text into a list of numbers (a vector) that captures its meaning, so similar meanings land close together.
Vector search
Finding the stored text whose meaning-vector is closest to the question's — search by meaning, not keywords.
Grounding
Forcing the model to answer only from the retrieved sources, and to refuse when they don't cover the question.
Agent
An AI that works in a loop — it decides which tools to call, reads the results, and repeats until it can answer.
MCP
Model Context Protocol: a shared standard for offering those tools to any AI app (Claude Desktop, an IDE, another agent).
Streaming (SSE)
Sending a reply in pieces as it's written, so words appear live instead of all at once.
Observability
Being able to tell what the system is doing from the outside — request ids, timings, and live health numbers.
The whole thing at once

The journey of one question

When you type a question into the chat and hit send, here is every real step it takes — from the browser to the answer and back. Each later section zooms into one part of this.

%%{init:{'theme':'base','flowchart':{'htmlLabels':false},'themeVariables':{'fontFamily':'ui-monospace, monospace','fontSize':'13px','primaryColor':'#e9f0ff','primaryBorderColor':'#3f74f5','primaryTextColor':'#17223a','lineColor':'#8794ab','secondaryColor':'#e4f6f2','tertiaryColor':'#f2f5fb'}}}%%
flowchart TD
  B([Browser POSTs the conversation]) --> C{CORS
allowed origin?} C -- no --> X[Browser blocks it] C -- yes --> M[Middleware: give it an ID,
start a timer] M --> RL{Rate limit:
under 20 / 60s?} RL -- no --> E429[429 Too Many Requests] RL -- yes --> Q[Build query from
last 2 user turns] Q --> EMB[Embed the question
OpenAI 1536-dim] EMB --> VS[Top-5 vector search
Neon + pgvector] VS --> G{Best match
≥ 0.35?} G -- no --> REF[Refuse — no model call, $0] G -- yes --> P[Ground Claude on the
numbered sources] P --> CL[Claude Haiku 4.5
writes the answer] CL --> FIN[Renumber citations,
strip markdown, price it] FIN --> RESP([Reply + citations + cost + trace]) REF --> RESP RESP --> LOG[Middleware: log JSON line,
record metrics, set X-Request-ID]
One request end to end. The refusal path (left) spends nothing; the grounded path (right) calls the model.

The two-gate shape is the heart of it: retrieval decides whether to answer at all, and only a strong enough match reaches the model. Everything else — the middleware, the rate limit, the trace — wraps around that core.

01 The RAG assistant

The centerpiece: answers grounded in real documents

The chat assistant doesn't answer from the model's general memory. It answers only from Samuel's own documents, cites its sources, and refuses when the documents don't cover the question. That is RAG, and it's the most important part to understand.

Why RAG beats one big prompt. The earlier version stuffed every fact into one hand-written prompt. RAG wins three ways: (1) it doesn't hallucinate — ask "Samuel's favorite pizza?" and it refuses, because no document says; (2) it can cite, because it pulled specific chunks; (3) it's editable — the knowledge is markdown files in a database, so editing a file and re-ingesting changes the live bot with no redeploy.
%%{init:{'theme':'base','flowchart':{'htmlLabels':false},'themeVariables':{'fontFamily':'ui-monospace, monospace','fontSize':'13px','primaryColor':'#e9f0ff','primaryBorderColor':'#3f74f5','primaryTextColor':'#17223a','lineColor':'#8794ab','secondaryColor':'#e4f6f2','tertiaryColor':'#f2f5fb'}}}%%
flowchart LR
  subgraph Ingest [ Ingestion — run once, offline ]
    direction TB
    MD[corpus/*.md
6 markdown files] --> CH[Chunk: ~1000 chars,
150 overlap] CH --> EM1[Embed each chunk
OpenAI 1536-dim] EM1 --> DB[(Neon: documents
+ chunks + HNSW index)] end subgraph Ask [ Every question ] direction TB QQ[Question] --> EM2[Embed question] EM2 --> SR[Cosine top-5
vs the HNSW index] SR --> DB end DB --> GR{≥ 0.35?} GR -- no --> RF[Refuse] GR -- yes --> AN[Ground Claude → cited answer]
Documents are embedded once and stored; each question is embedded and matched against them.

The corpus — knowledge as documents

What
A /corpus folder of markdown: profile.md, skills.md, and four project files (cadence, consulting-platform, meridian, living-portfolio). These six files are the only things the bot can say. README.md files are skipped.
Why
Versioned, transparent, re-ingestable — and a claims surface. Every sentence is something the bot will state to a recruiter as fact, so it gets the honesty rule: shipped vs. in-progress stays explicit.
In an interview"The corpus is markdown in the repo — one editable, versioned source of truth. I treat it as a claims surface: if the bot can say it, it has to be true and defensible."

Chunking — cutting documents into pieces

What
Each document is split into overlapping windows of 1000 characters (CHUNK_SIZE) with 150 characters of overlap (CHUNK_OVERLAP). The cut is nudged to the nearest newline or space so words aren't split mid-way.
Why
You retrieve chunks, not whole files, so the model gets just the relevant slice. The overlap keeps a sentence that straddles a cut from being lost to both sides. It's a deliberate simple baseline for a small corpus.
In an interview"Fixed-size character chunks with overlap so a boundary sentence isn't orphaned — a simple baseline; structure-aware chunking is the next step if the corpus grows."

Embeddings — turning text into meaning

What
Each chunk (and every question) becomes an embedding: a list of 1536 numbers from OpenAI's text-embedding-3-small, representing its meaning as a point in 1536-dimensional space where similar meanings land close together.
Why
This is what lets the system search by meaning, not keywords. "What did he do before engineering?" lands near a chunk about his PM career even with few shared words. Anthropic has no embeddings API — that's the real reason OpenAI is a second provider.
In an interview"Embeddings map text to a 1536-dim vector where semantic similarity is geometric distance. Anthropic has no embeddings API, so embeddings run on OpenAI — a real architecture fact, not an accident."

Vector search — finding the closest chunks

What
The chunks live in Neon Postgres with the pgvector extension. For each question, it finds the top 5 (RETRIEVAL_K = 5) most similar chunks by cosine similarity — the <=> operator gives cosine distance, and the SQL turns it into similarity with 1 - (embedding <=> query).
How
An HNSW index (vector_cosine_ops) makes "find the nearest vectors" fast without scanning every row. The query orders by the raw <=> operator specifically so that index stays usable.
Why
Postgres + pgvector over a dedicated vector DB because the app already needs a real database (for the contact form), Neon has a free serverless tier, and "vectors next to relational data in one Postgres" is a clean story.
In an interview"Cosine-similarity top-k over pgvector in Neon, with an HNSW index. One Postgres holds both the vectors and the app's relational data — no separate vector database."

Grounding & the refusal threshold — the anti-hallucination gate

What
If the best chunk's similarity is below 0.35 (MIN_SIMILARITY), the bot refuses — "I don't have that in Samuel's documents" — without calling the model, so a refusal costs $0. Otherwise the retrieved chunks are handed to Claude with strict rules: answer only from these numbered sources, cite each claim with [n], never present in-progress work as finished.
Why
The threshold is the anti-hallucination gate. Too high (say 0.6) → real questions get refused. Too low (say 0.1) → junk retrievals slip through and the model guesses. 0.35 sits in the measured gap between in-corpus (~0.46–0.53) and out-of-corpus (~0.33) questions.
In an interview"Below a cosine-similarity threshold I refuse instead of answering. Too high refuses real questions; too low invites hallucination. I set 0.35 in the measured gap — and calibrating it is exactly what the evals are for."

Citations you can touch

What
Each answer's [n] markers are clickable; hovering shows the source's title and a snippet. Under the hood finalize_citations renumbers the model's markers to a clean 1..N that matches the deduplicated source list (several markers to one file collapse to one), attaches a 200-char snippet, and drops any marker pointing at a chunk that wasn't retrieved.
Why
The model numbers by retrieval order (it saw 5 chunks), but the displayed chips are deduped — show the raw numbers and [4] points at a chip that doesn't exist. Renumbering makes the visible numbers a real reference list.
In an interview"Markers index the retrieved chunks, but I dedupe sources, so I renumber to a 1..N that matches the source list — otherwise a marker points at a citation that isn't shown."

Conversation-aware retrieval

A follow-up like "what's it built with?" has no topic alone, so the query is built from the last two user turns (conversation_query, capped at 500 chars) — restoring the subject from the previous message.

Cost metering

Every answer reports real token counts × Haiku 4.5's price ($1/M input, $5/M output) — about $0.002 an answer, $0 for a refusal. Measured, never estimated.

Plain-text backstop

to_plain_text strips any ## or ** the model emits, because the widget renders plain text. The prompt asks for it; this guarantees it.

Persona vs. facts

about_me.py is now just voice and tone — no facts. All facts come from the corpus, so there is exactly one place to keep honest.

02 Evals Shipped

Measuring quality instead of guessing

"It seems to work when I try it" is not evidence. The evals system scores the assistant on a fixed set of questions with known-correct behaviour, so quality is a number, not a vibe.

What
A 108-case golden dataset (answer vs. refuse, across six categories). A deterministic harness scores the objective parts — retrieval recall and citation validity. An LLM-as-judge (a stronger Claude model) grades the actual replies for groundedness and behaviour.
Why
The judge caught a real bug: asked whether Samuel had "shipped streaming and Terraform," the live bot said yes — both were in progress at the time. The grounding rule was tightened, redeployed, and re-verified (both have since shipped for real). That loop — measure → find a bug → fix → re-measure → deploy → verify — is the point.
Open
Judge calibration is the one honest gap: a blind slice of the judge's verdicts still needs a human's pass/fail labels to prove the judge agrees with a person. Until then, judge numbers are shown as "calibration pending," not as fact.
In an interview"A golden dataset, deterministic metrics, and an LLM judge — and I hold the judge's numbers as uncalibrated until they're checked against my own hand labels, because a judge you haven't validated is just another opinion."
03 Observability Shipped

The system reporting on itself

Every request gets a short id, is timed, and is logged as one line of JSON to stdout (which Render captures). An in-memory metrics registry tallies request counts, status classes, per-path counts, latency percentiles, and chat economics — always labelled with the process start time so the numbers are an honest "since this deploy" window, never a fake lifetime total.

PieceWhat it records
X-Request-IDA 12-char id on every response, matching the log line and the trace — so a report ("it broke at 2pm") ties to exact logs.
JSON logsOne object per line (ts, level, path, status, latency_ms, …) — greppable by machine, not human prose.
/metricssince uptime total_requests status_counts requests_by_path p50 / p95 chat: answers / refusals / tokens / cost
The TracePer answer: grounded, sources, top_similarity, and real timings — retrieval_ms split into embed_ms + db_ms, plus model_ms. Operational data only — no message text, no secrets.
The instrument found its own next target. Splitting retrieval_ms into embed_ms + db_ms revealed that, warm, the database connection (~1.4s) dominated the query — not the model. The fix: a connection pool that reuses a warm connection and validates it on checkout (so a connection Neon dropped while idle is replaced, not handed out dead). Measured live afterward: warm db time fell from ~1.7s to ~0.3s across a conversation. Reliability preserved, speed gained. That is the whole loop: measure → localize → fix → re-measure.
In an interview"I instrumented every request, split retrieval timing into embed vs. db, found the per-request Neon connection was the cost, and pooled it with checkout validation — warm retrieval dropped ~5×, and I re-measured live to prove it."
04 The agent Shipped

An AI that uses tools in a loop

The chat answers one question in one shot. The agent is different: it's given a task plus a menu of tools, and it decides which to call, reads the results, and iterates until it can answer. That autonomous, multi-step tool use is the actual draw of agents.

%%{init:{'theme':'base','flowchart':{'htmlLabels':false},'themeVariables':{'fontFamily':'ui-monospace, monospace','fontSize':'13px','primaryColor':'#e9f0ff','primaryBorderColor':'#3f74f5','primaryTextColor':'#17223a','lineColor':'#8794ab','secondaryColor':'#e4f6f2','tertiaryColor':'#f2f5fb'}}}%%
flowchart TD
  T([Task: 'Is he a fit for this role?']) --> LLM[Claude + the 4 tool schemas]
  LLM --> D{Asked for
a tool?} D -- yes --> RUN[Run it: search_portfolio /
list_skills / list_projects / list_services] RUN --> FEED[Feed the result back] FEED --> LLM D -- no --> ANS([Final answer + every step it took]) LLM -. hard cap: 6 loops .-> ANS
The hand-written loop: call the model, run whatever tools it asks for, feed results back, repeat — up to 6 times.
Tools
Four, all read-only: search_portfolio (semantic search of the corpus), list_skills (shipped vs. building), list_projects, list_services (the real starting prices). The agent can look things up — it can never write, send, or buy anything.
How
One generator, run_agent_stream, keeps calling Claude (model claude-haiku-4-5-20251001, up to MAX_ITERATIONS = 6). It records every thought and tool call and yields them as they happen; the non-streaming run_agent is a thin consumer of the same loop, so there's one behaviour to maintain.
Honesty
The system prompt forbids answering from memory and forbids presenting "currently building" work as shipped. For a job-fit question it must break the role into requirements and mark each covered / partial / gap with evidence — including what he can't yet do.
In an interview"A hand-written tool-use loop over four read-only tools, capped at six iterations, that returns every step it took — so the autonomy is visible, not a black box. Both the streaming and non-streaming surfaces run one loop."
05 MCP server Shipped

Publishing the tools to any AI client

The agent's four tools were useful but locked inside one endpoint. MCP (Model Context Protocol) is a shared standard — a universal plug — for offering tools to any AI app. mcp_server.py wraps the same four functions and publishes them over MCP with FastMCP, so Claude Desktop, an IDE, or another agent can use them.

One source
The MCP tools are thin wrappers over the exact agent.py implementations — two surfaces, one set of tools, so a change to a tool changes it everywhere.
Transport
Ships over stdio — an MCP client launches python mcp_server.py as a local subprocess and talks over its input/output pipes. A hosted HTTP version can also mount at /mcp on the API, but it's off by default behind the ENABLE_MCP_HTTP flag so it can't affect the live API unless deliberately turned on.
In an interview"The same tools the agent uses, published over MCP via FastMCP — one implementation behind two surfaces — verified end-to-end against a real MCP client, not just imported."
06 Streaming Shipped

Watching the answer type itself

Instead of waiting for the whole answer, /chat/stream sends it in pieces using Server-Sent Events — one long HTTP response where each message is data: {…}. The model's text arrives as token events as it's written, then a final done event carries the finished reply, citations, cost, and trace. /agent/stream does the same with step events (each thought or tool call) then a done.

Two buffering traps, both checked — not assumed. The observability middleware and Render's proxy could each have quietly buffered the whole stream and defeated it. Adding Cache-Control: no-cache and X-Accel-Buffering: no and then measuring live confirmed tokens arrive incrementally (spread over the model's writing time), not in one blob.
In an interview"Token-by-token over SSE, with the finalized citations and cost sent in a closing event. I proved it isn't buffered by the middleware or the proxy by measuring the inter-token timing live."
The plumbing

The backend — a typed API

A Python FastAPI app. Every request and response is a typed Pydantic model, so a malformed request is auto-rejected with a 422 and the exact field errors before any handler runs — and the interactive docs at /docs generate from those same types. It exposes eight routes (plus the optional /mcp):

RouteDoes
GET /healthLiveness check — returns {"status":"ok"}.
GET /versionThe deployed commit SHA (from Render's RENDER_GIT_COMMIT) + when the process started — so "is my change live?" is a fact, not a guess.
GET /metricsThe live metrics snapshot (counts, latency, running cost) since this deploy.
POST /inquiryThe contact form — validated, honeypot-guarded, stored in Neon. (details below)
POST /chatThe RAG assistant. rate-limited
POST /chat/streamThe RAG assistant, streamed token-by-token. rate-limited
POST /agentThe tool-using agent. rate-limited
POST /agent/streamThe agent, streaming each step. rate-limited

CORS — the origin gate

The API only accepts browser calls from an allowlist (CORS_ORIGINS, defaulting to just the production site). A non-matching origin gets no access-control header and the browser blocks it. It's why saving the page to your desktop breaks the chat: file:// isn't on the list.

/inquiry — validation + honeypot

Pydantic's EmailStr rejects a bad email with a 422. A hidden website field (a honeypot) that humans never see: if it's filled, a bot did it, so the API returns success without storing. No database configured → 503, never a fake success, and it never leaks the driver error.

In an interview"FastAPI with Pydantic on every boundary — validation before the handler, malformed input is an automatic 422, and the OpenAPI docs generate from the types. It's like Zod, wired into the framework and the docs."
The plumbing

Rate limiting — protecting the paid endpoints

The chat and agent endpoints each cost real money per call and are open to the internet, so a script could run up a bill. A thread-safe sliding-window limiter caps each client IP at 20 requests per 60 seconds (both tunable by env var). Over the cap, the server returns 429 Too Many Requests with a Retry-After header. A blocked request isn't recorded, so a persistent caller recovers as its window slides rather than locking itself out. A normal visitor stays far under the cap; a script hits it.

In an interview"A per-IP sliding-window limiter on the paid endpoints — 429 with Retry-After. It's in-memory and process-local, which is the honest shape for one instance and a clean seam to swap in Redis if it ever scales out."
The plumbing

The frontend — no framework, on purpose

The whole site is one hand-written index.html: HTML for structure, CSS custom properties (--accent, --bg, …) as a design-token system, and vanilla JavaScript for behaviour. No React, no build step. The only heavy library is three.js, lazy-loaded and only for optional 3D layers that are flagged off in production — so a normal visitor downloads none of it.

Sections
build (the roadmap + a deployment topology), xray (an architecture x-ray of one request), evals, metrics (reads /metrics live), agent (a live agent you can give a task), services, inquiry. Four of them — xray, evals, metrics, and agent — are each gated by a feature flag so a capability can flip on the week its module ships; build, services, and inquiry always render.
The chat widget
Calls /chat/stream and reads the Server-Sent Events, typing the answer in live, then rendering the touchable citation chips and the cost footer from the closing event. It picks its API base automatically — localhost:8000 in dev, the Render URL in production.
Why no framework
For a portfolio, "I can build the fundamentals React sits on top of" is a stronger signal than reaching for a framework. It also loads fast and has almost nothing to break.
In an interview"Vanilla HTML/CSS/JS with a CSS-variable design system — the fundamentals under React, not a dependency on it. The only library is three.js, lazy-loaded and off by default."

Two interactive explainers on the site are worth calling out: the Architecture X-Ray traces one request through its real layers (the system in time), and the Deployment Topology shows the six services and their connections (the system in space) — including the one push that fans out to two clouds, which a linear request trace can't express.

The plumbing

Deployment — one push, two clouds

There are no manual deploy steps. A git push to main triggers continuous deployment: Vercel rebuilds the site and Render rebuilds the API, in parallel, independently. Each half can ship or roll back on its own.

%%{init:{'theme':'base','flowchart':{'htmlLabels':false},'themeVariables':{'fontFamily':'ui-monospace, monospace','fontSize':'13px','primaryColor':'#e9f0ff','primaryBorderColor':'#3f74f5','primaryTextColor':'#17223a','lineColor':'#8794ab','secondaryColor':'#e4f6f2','tertiaryColor':'#f2f5fb'}}}%%
flowchart LR
  P([git push main]) --> GH[GitHub]
  GH --> V[Vercel
rebuilds the site] GH --> R[Render
rebuilds the API] R -.->|reads secrets at runtime| S[[ANTHROPIC / OPENAI /
DATABASE_URL — never baked in]]
Push-to-deploy on both clouds. Secrets are injected at runtime as environment variables, never committed or built into an image.

The precise wording matters and is deliberate: the site says continuous deployment (automated deploy on push), not "CI/CD" — the automated test-gate in CI is a later module and isn't claimed yet. That's an honesty distinction, not a nitpick.

In an interview"Push-to-deploy on both clouds, independently. I call it continuous deployment, not CI/CD, because the test-gate in CI is a later module and I don't claim it yet."
07 / 08 Docker & Terraform

The last two — both shipped, each earned differently

Two deployment artifacts live in the repo. The honesty rule is that a green "Shipped" badge means built and verified — and both have now earned it, but the verification looked different for each.

Docker Shipped

A production Dockerfile (single python:3.12-slim stage, dependency-layer caching, a non-root appuser, a /health HEALTHCHECK) plus docker-compose.yml. Verified end to end: docker compose up --build builds the image, the container comes up healthy (its HEALTHCHECK passes), and GET /health returns {"status":"ok"}.

Terraform Shipped

A terraform/ config codifying the whole stack (Render + Neon + Vercel) across the three real providers, with secrets as sensitive variables. Verified: terraform validate passes (and caught a real attribute bug), all three live resources were terraform imported into state, and terraform plan round-trips clean (“No changes.”) against the running infra — so Terraform manages the real stack. No apply touched production: the pre-existing resources were adopted via import, not created.

The honesty system, working in both directions. Docker went green the moment a real docker build proved it — not a step before. Terraform stayed amber (“authored & validated”) until the live stack was actually imported and terraform plan round-tripped clean — only then did it earn green. "Shipped" is earned by verification, and only by verification.
The spine

The test suite — where honesty becomes a test

62 tests across seven files, covering the pure logic without needing the network or a paid API: chunking, the grounding/refusal decision, citation renumbering, the plain-text strip, the conversation query, cost math, the metrics registry, the rate limiter, the agent's tools and loop (driven by a fake client), and the MCP server (over a real in-memory client↔server session).

The standout: honesty guards. Some tests fail the build if the corpus ever lists in-progress work as shipped — they make "don't overclaim" a regression test, not a good intention. And splitting the database search from the embedding call is what let retrieval be tested before an embedding key existed — which is how a real vector-binding bug got caught before it ever ran.
In an interview"62 tests, including honesty guards that fail if the corpus overclaims — I made 'don't lie' a regression test. Retrieval is split so the database path is testable without the paid embedding API."
The spine

The honesty system — the thesis of the whole thing

One principle runs through every part: shipped work and in-progress work are always explicit, and nothing is claimed that can't be defended. It shows up everywhere — the corpus is audited before ingestion; the bot refuses rather than guesses; every metric names its window; the agent's prompt forbids calling building work "shipped"; the roadmap distinguishes three states; and tests fail if a claim outruns reality.

Shipped built & verified live Authored written & committed, not yet built/applied Planned not started
In an interview"The through-line is honesty as an engineering constraint — the corpus is a claims surface, the bot refuses when unsure, every metric names its source, and tests fail if a claim outruns reality. That rigor is what separates a real system from a demo."
The spine

The module roadmap

The system was built in public, one module at a time. Where each stands today:

#ModuleWhat it addedStatus
01RAG with citationsAnswers grounded in real documents, with sourcesShipped
02Evals108-case golden dataset, harness + LLM judge (calibration pending)Shipped
03ObservabilityRequest ids, JSON logs, /metrics, the trace, connection poolingShipped
04AgentsA tool-using agent loop over the portfolio dataShipped
05MCP serverThe same tools over the Model Context ProtocolShipped
06StreamingToken-by-token responses over SSEShipped
07DockerA production Dockerfile + compose — image builds & runs healthyShipped
08TerraformThe stack as infrastructure-as-code — imported; terraform plan round-trips clean against liveShipped

Plus hardening that isn't a numbered module: rate limiting (shipped) and an opt-in hosted MCP endpoint (deployed dormant behind a flag).

Reference

Glossary — say these correctly

RAG
Retrieval-Augmented Generation: retrieve relevant documents, then have the model answer using only them.
Embedding
A list of numbers (a vector) representing a piece of text's meaning. Here, 1536 numbers from OpenAI's text-embedding-3-small.
Vector / dimensions
Each text becomes a point in 1536-dimensional space; nearby points mean similar things.
Cosine similarity
How aligned two vectors' directions are. pgvector's <=> gives cosine distance; similarity is 1 − distance.
Chunk
A ~1000-character slice of a document that gets embedded and retrieved (with 150 chars of overlap).
pgvector
A Postgres extension adding a VECTOR type and similarity search.
HNSW
The index that makes nearest-vector search fast without scanning every row; builds incrementally, no training.
Grounding
Constraining the model to answer only from provided sources.
Hallucination
The model confidently making something up; grounding + a refusal threshold is the defense.
Top-k
Retrieving the k best matches (here, k = 5).
Threshold
The minimum similarity (0.35) below which the bot refuses.
Agent
An AI that loops — decides a tool, runs it, reads the result, repeats — up to 6 times here.
Tool
A specific action the AI can trigger; here four read-only lookups over the corpus and service list.
MCP
Model Context Protocol — a standard way to offer tools to any AI client.
stdio
Talking over a program's standard input/output pipes instead of a network port.
SSE
Server-Sent Events — a one-way stream of data: {…} messages, used to send tokens live.
Middleware
Code that wraps every request — here it ids, times, logs, and records each one.
CORS
A browser rule about which sites may call an API; only the portfolio's origin is allowed by default.
Rate limiting
Capping requests per client IP in a window (20 / 60s), returning 429 when exceeded.
Connection pool
Reusing warm database connections instead of reconnecting per request; here validated on checkout.
Continuous deployment
A push to main auto-deploys — no manual step. (CI/CD adds an automated test-gate, not yet claimed.)
Infrastructure-as-code
Describing servers/databases in text files (Terraform) instead of clicking dashboards.