# DEVLOG — build notes, decisions & challenges A running record of how the triage copilot was built, the decisions made, and the challenges hit along the way. Written to feed the application's video walkthrough and writeup (the JD grades "clear communication of reasoning" and "trade-offs considered"). --- ## Working method Everything is backed by an **offline-first smoke harness** (`scripts/smoke_test.py`) that runs in four layers and skips what it can't reach (heavy deps / no API key) rather than failing: 1. data layer (pydantic only) · 2. index + retrieval (chromadb) · 3. agent wiring (Pydantic AI `TestModel`, no API call) · 4. live triage (needs a key). This let almost everything be verified **for free** — no API spend, no Ollama required for the deterministic checks. Live calls are reserved for explicit confirmation. --- ## Phase 2 — agent + tools (summary + challenges) Built `schemas.py` (typed `TriageDecision`), `campaigns.py` (loads a campaign and **strips every `_`-prefixed key** so private `_design_note`/`_expected` never reach the model), `policy.py` (parses `policy.md` into 26 citable rules), `tools.py` (RAG + mock sanctions + a deterministic risk scanner), and `agent.py` (Pydantic AI agent → structured `TriageDecision`). **Challenges hit and resolved:** - **`RunContext` forward-ref crash.** Tools were annotated `RunContext[Deps]`, but `RunContext` was imported *inside* `build_agent()`. Pydantic AI resolves a tool's type hints against the **module globals** at registration time, so the annotation failed with `NameError: RunContext`. Fix: import `Agent`/`RunContext` at module top. - **ChromaDB client-lifecycle flakiness (the nastiest).** `store._client()` created a **new** `PersistentClient` on every call, and the agent makes many tool calls per triage. Re-instantiating Chroma's client against the same path corrupts its shared system client, surfacing as *intermittent* `Could not connect to tenant default_tenant` / `'RustBindingsAPI' object has no attribute 'bindings'` errors — passed once, failed the next run. Fix: cache the client + collections as per-process singletons (`lru_cache`); added a 12× repeat guard to the smoke test so it can't regress. - **Windows console encoding (cp1252).** A summary `print` used `→`, which isn't in cp1252 → `UnicodeEncodeError` (em-dashes happen to be in cp1252, so they slipped by; the arrow didn't). Fix: ASCII in CLI prints + a UTF-8 `stdout.reconfigure`. Also silenced HF/transformers progress bars that were corrupting piped output and tripping `grep`'s binary detection. - **Index citation duplication.** The policy parser already keeps the rule ID inside `rule.text`, but `build_index` prepended it again (`ELIG-2 — ELIG-2 — …`). Fix: index `rule.text` directly. ## Phase 2.5 — free local LLM toggle Added a provider seam so triage can run on a **free local Ollama** model for dev, Claude for the demo. `agent._resolve_model()` reads `CONFIG.llm_provider` (anthropic default; `--provider ollama` per-run). **Challenges:** pydantic-ai's `OllamaProvider` imports `openai` (not pulled by the `[anthropic]` extra) → added it. And `OllamaProvider` does **not** auto-append `/v1` to the base URL — it passes it straight to the OpenAI client, so the code appends `/v1` to `OLLAMA_HOST`. Verified end-to-end on `qwen2.5:7b-instruct`: camp-005 → REJECT (cited PROH-3), camp-017 → APPROVE. Quality is below Claude (occasionally an empty rationale), so local is the dev loop and the demo stays Claude. --- ## Phase 3 — moderator review queue (this phase) Goal: make the **human/AI boundary** visible. The AI recommends; a human decides and can override; every decision is logged. No auto-decide path. ### Steps taken 1. **`src/audit.py`** — append-only JSON-Lines decision log: `append_decision()` (auto-stamps a UTC timestamp), `load_decisions()` (tolerates a missing/half-written file), `decided_campaign_ids()` (latest decision per campaign, to mark the queue). Flat file chosen over a DB: right-sized for the demo, trivially greppable, easy to show on video, clean seam to swap later. 2. **`src/policy.py`** — added `policy_index()` / `get_rule(rule_id)` (reusing `parse_policy_rules`) so the UI can expand a cited rule to its full policy text. 3. **`app.py`** — full rewrite from the placeholder into the queue: sidebar (provider toggle + index counts + audit history), a campaign selector marking decided items, a left/right split (campaign | AI recommendation), the decision card, and the human decision controls. 4. **`scripts/smoke_test.py`** — added deterministic checks for the audit round-trip and the rule lookup. Now **10 pass / 1 skip** (live triage) with no key. 5. Verified: app parses, imports, both render functions execute (incl. nested columns + expanders), and a real headless `streamlit run` serves (`/_stcore/health` → `ok`). ### Key decisions (and why) - **"What the AI couldn't verify" without a schema change.** The card wants a "needs verification" section, but `TriageDecision` has no such field. Rather than change the schema (which ripples into the agent prompt + eval + local-model reliability), it maps to the existing `questions_for_submitter`, falling back to a low-confidence note. Reuse over new surface area. - **Triage cached in `st.session_state`, not `st.cache_data`.** Streamlit reruns the whole script on every interaction; without caching, clicking any button would re-invoke (and re-bill) the agent. Storing the `TriageDecision` per campaign id in session state means **one triage per campaign** until an explicit "Re-run". Chose session state over `@st.cache_data` to avoid hashing a pydantic model and a (possibly network-bound) model object. - **Override gating is the governance story.** The human decision maps to a recommendation (Approve↔APPROVE, Reject↔REJECT, Request-info↔ESCALATE). If it **contradicts** the AI, a written reason is **required** before the decision logs — the audit record carries `is_override` + `reason`. This is the most direct expression of "where AI should not replace human judgment." - **Provider toggle in the sidebar.** Lets the whole queue be exercised for free on Ollama during dev, then flipped to Claude live for the recording, without editing `.env`. ### Challenges hit - **Streamlit rerun model.** Buttons trigger a top-to-bottom rerun, so widget *read order* matters: the reason `text_area` is defined **before** the decision buttons so its current value is available when a button handler runs in the same pass. Widgets are keyed per campaign id (`reason_{cid}`, `ap_{cid}`, …) so switching campaigns doesn't bleed state. - **Nested columns.** The decision controls put `st.columns(3)` inside the right-hand column (one level of nesting). Older Streamlit forbade this; 1.40 allows a single level. Verified by executing the render functions directly in bare mode (no exception) before trusting it in the live app. - **Verifying a UI without clicking.** Button clicks can't be automated here. Worked around it by (a) bare-importing `app.py` (runs the whole top-level script), (b) calling `render_decision` / `render_human_controls` directly with a sample `TriageDecision` to exercise the dynamic paths, (c) unit-testing the audit round-trip (incl. an override record), and (d) a real headless server boot. The only step left to a human is the final interactive click-through. - **Not spending the user's credits during tests.** The full smoke test's live layer reads `ANTHROPIC_API_KEY` from `.env`; ran it with the key blanked (`ANTHROPIC_API_KEY=`) so the live check **skips** while everything else (10 checks) runs. ### Verification status `scripts/smoke_test.py` → **10 pass, 1 skip** (live triage). App boots headless and serves. End-to-end data flow (triage → audit record incl. override) covered by the sum of the live-Ollama triage runs and the audit round-trip test. Remaining: human interactive click-through, then Phase 4 (eval) + Phase 5 (deploy). --- ## Phase 3.5 — deterministic policy gate (the "not-a-wrapper" layer) **Why this phase exists.** A review of the pipeline exposed the real weakness: despite the RAG, the risk scanner, the sanctions matcher and the typed schemas, **nothing in code constrained the final decision** — `triage()` returned the model's `result.output` verbatim. Every deterministic signal was fed to the model as *advice*; the policy invariants ("sanctions → escalate", "REJECT needs a cited hard rule", "low confidence on money → escalate", "injection → escalate") lived only as prose in the system prompt. So the adjudication was 100% the model's free judgment — which is both why it *felt* like a wrapper and why a weak local model (Ollama) and Claude diverged so much: swap the brain, swap the behavior, because the brain was the whole system. **The fix — `src/gate.py`.** A deterministic gate sits between the model's `TriageDecision` and the final output. It recomputes the deterministic facts itself (it does **not** trust the model's self-report) — `scan_risk_signals`, `check_sanctions`, `valid_rule_ids` — and reconciles them against the model's recommendation. The envelope has exactly one rule: **the gate may only route toward the human (→ ESCALATE).** It never produces a more confident decision than the model (never APPROVE→REJECT, never ESCALATE→APPROVE, never invents a REJECT) and never overrides the human. The AI component can only ever defer harder to a person, never seize more authority. Enforced invariants (each override cites the rule it enforces): sanctions match → ESCALATE (`COMP-1`); prompt injection → ESCALATE + the manipulation flag is corrected even if the model missed it, because the deterministic scanner is authoritative (`DEC-6`); a high-severity risk signal blocks an APPROVE (`DEC-3`, or `COMP-2` for a high-value goal); low-confidence APPROVE → ESCALATE (`DEC-5`); and a REJECT without a *valid hard* citation → ESCALATE (`DEC-2`). **Calibration that matters.** Only **high**-severity signals block an APPROVE. That keeps the showcase intact: camp-017 (debt principal) fires only `interest_mentioned`/`unverified_organizer` (both low) → APPROVE survives, while camp-005 (riba investment) fires `investment_return` (high) → an erroneous APPROVE would be caught. A REJECT resting on a real hard `PROH-3` citation stands. **Payoff.** The safety-critical behavior no longer depends on model IQ — Ollama and Claude converge on the cases that matter — and the gate's invariants are pure-Python, zero-API, so they double as the deterministic seed of the Phase 4 eval. The UI shows a gate banner whenever it adjusts a recommendation, and the audit log now records `ai_llm_recommendation` (raw) + `gate_overrides` alongside the final decision. ### Challenges hit - **Model nondeterminism on the live showcase.** Haiku sometimes returns a REJECT for camp-005 without a properly-typed hard citation; the gate then (correctly) routes it to ESCALATE per DEC-2. The live smoke assertion was relaxed from `== "REJECT"` to the model-independent invariant "**never APPROVE**" (REJECT *or* ESCALATE), since the deterministic gate test already proves the transition logic exactly. - **Schema confusion crashed whole runs.** Haiku intermittently emitted `severity="hard"` on a `RiskSignal` (confusing it with `RuleViolation`'s scale), exhausting Pydantic AI's retries and aborting the triage. Added a lenient `field_validator` on `RiskSignal.severity` that maps the common confusions (`hard→high`, `soft→medium`, …) — same "don't depend on the model being perfect" philosophy as the gate. (Separately observed, still open: Haiku occasionally passes phantom args to the zero-arg `check_sanctions` tool, and the documented intermittent Chroma RustBindings flake on Windows — both pre-existing, neither caused by the gate.) ### Verification status `scripts/smoke_test.py` → **12 pass** under the venv (incl. a new 7-assertion deterministic gate test, the TestModel wiring through the gate, and live camp-005). Live CLI confirmed: camp-005 → REJECT preserved (gate agrees, no override); camp-009 → ESCALATE. --- ## Phase 3.6 — React + FastAPI moderator console (replacing Streamlit as the shipped UI) **Why.** Streamlit looks like a script UI; the demo video wanted something that reads as a real ops console. The triage/policy/audit code was already UI-agnostic (`triage()` returns a typed `GatedDecision`; audit/campaigns/policy are pure functions), so the move cost no changes to the AI layer — only a thin HTTP wrapper + a frontend. **Backend — `api.py` (FastAPI).** Wraps `src/` as `/api/*`. Pydantic models serialize for free. Two pieces of logic live here rather than in the client, on purpose: - **Override governance is server-enforced.** `POST /api/decisions` looks up the cached triage, computes `is_override`, and **returns 400 if a contradiction has no written reason**. A UI cannot bypass the human/AI boundary — a stronger story than UI-only validation. - **Per-campaign triage cache** (last-run-wins) so reruns/interactions don't re-bill; `force=true` re-runs. The endpoint also enriches each cited `rule_violation`/override with its policy rule text so the frontend needs no extra round-trips. In production FastAPI also serves the built SPA (`StaticFiles` mounted at `/` *after* the API routes), so the whole app is one origin in one container. **Frontend — `frontend/` (Vite + React + TS + Tailwind).** Deliberately lean for low-risk/fast: no router, no state library — `useState`/`useEffect` + a small typed `fetch` wrapper (`api.ts`), and hand-written TS mirrors of the schema (`types.ts`) instead of an OpenAPI codegen step. Single-screen console: queue → campaign detail → triage → human controls + audit drawer. The **gate banner** is the visual headline (indigo, "Policy gate adjusted {llm} → {final}" + cited rules). Tailwind class maps use full literal strings (`lib.ts`) so the content scanner keeps them. **Deploy — Docker on HF Spaces.** Multi-stage `Dockerfile` (node builds the SPA → python serves). The Chroma index is **rebuilt at image-build time** (`python -m scripts.build_index`, local embeddings, no key) rather than committed, so there's no binary blob in git and no ingestion step on the Space. `data/` made world-writable for the non-root HF user (audit log + Chroma sqlite). README metadata flipped to `sdk: docker`, port 7860. ### Challenges / decisions - **`npm --prefix` on Windows** read the repo-root `package.json` (which doesn't exist) instead of `frontend/` — switched to running from the `frontend/` dir. - **Build script is `vite build` (no `tsc`)** so a stray type nit can't block the production build; types still guide the editor. - **Kept `app.py`.** The Streamlit queue stays as a zero-dependency local fallback; the shipped UI is React, but if Node/Docker is unavailable the demo still runs. ### Verification status `python -m scripts.test_api` → **7/7** offline (incl. override-governance 400 + rule-text enrichment). `npm run build` clean (≈165 kB JS gzip 52 kB). A real uvicorn server serves `/`→200 (html), `/assets/*`→200, `/api/*`→200 from one origin; live `/api/triage` camp-017 → APPROVE (gate agrees). Remaining: `docker build` (daemon was off locally) + the in-browser click-through. ## Phase 3.7 — policy reference drawer + clickable citations **Why.** The decision card cites bare rule IDs (`COMP-1`, `PROH-3`). The five prefixes (ELIG / PROH / COMP / CONT / DEC) are internal jargon, and the only way to read a rule was to triage a campaign and expand that one citation — no way to learn the taxonomy or browse the policy. A moderator shouldn't have to guess what a code means. **What.** A **Policy** button in the header opens a side drawer (mirrors the audit-history drawer) listing all 26 rules grouped by section, each section led by a plain-English name + one-line blurb. Every cited rule ID — in both the rule list and the gate banner — is now a **clickable chip** (`RuleIdChip`) that opens the drawer scrolled to and highlighting that exact rule (`focusRule` + `scrollIntoView` + a teal ring). **How / decisions.** - Backend stayed thin: `src/policy.py` gained `SECTION_META` (the prefix → name/blurb map — the headings in `policy.md` only carry names, so the blurbs live in code as the single source) and `policy_sections()`, which groups `parse_policy_rules()` by `rule_id.split("-")[0]`. One new route `GET /api/policy`. No AI/triage/gate code touched; zero new LLM spend. - `RuleRow` was a single `