Spaces:
Running
Implementation Status β live tracker
Both agents: update this file as you complete checklist items. Tick boxes (
[x]), bump the "Current stage" lines at top, add a_log_entry at the bottom. This is the single source of truth for "where we are." If you finish work and don't update this file, the next session can't pick up.
Last updated: 2026-05-17 Current FE stage: 0 β 1 (handoff in progress) Current BE stage: Stage 6 β DONE; Stage 7 next (Operations + Evaluation writes) Contract version: v2 (CONTRACT.md revised 2026-05-17 β Express proxy + cost monitoring)
Two parallel tracks, one shared contract
Two AI agents work in parallel, each in its own repo. They never edit each other's code; they share only CONTRACT.md and this file.
| Track | Repo | Branch | Working dir for the agent |
|---|---|---|---|
| Frontend | G:\pythoncodenew\Side projects\Reli\Patristic-AI-Fe |
main |
repo root |
| Backend | G:\pythoncodenew\Side projects\Reli\Patristic_Arabic_Library |
rewrite/api (must be created before BE work starts) |
repo root |
Each agent reads:
CONTRACT.md(~280 lines, authoritative wire contract β read fully)- Its build brief:
FRONTEND_BUILD.mdorBACKEND_BUILD.md - This file β for "where we are" and "what's next"
Each agent updates only this file when reporting progress. They do not edit each other's brief.
How to use this file
- Mark a checkbox
[x]when the item is complete and verified against its observability gate. - A stage moves from
π§ IN PROGRESStoβ DONEonly when every checkbox in it is[x]AND the stage gate passes. - The user (or the orchestrator agent) confirms gate-pass before flipping the stage flag at the top of this file.
- New items discovered mid-stage are added inside the stage they belong to, not the current stage, unless they're blockers.
Frontend track
Stage 0 β AI Studio scaffolding β DONE (verified 2026-05-17)
Already shipped by Google AI Studio before either agent started. Don't redo any of it.
- Vite 6 + React 19 + react-router-dom 7 + react-query + react-hook-form + zod + shadcn (base-nova) + Tailwind v4 set up.
- Express
server.tswith Vite middleware + mock API routes + cookie parsing. - Full route inventory (
App.tsx): Dashboard, Login, About, Research/{Search, Compare, Allusions, History, RunDetail}, Library/{Browse, AddBook, Labels, BookDetail, Inspect, JobWatcher}, Operations/{Costs, Indexes, Jobs, Processing, Tools, Config}, Evaluation/{Run, Golden, History, RunDetail}. - Search page wires
POST /query β jobId β SSE β done β runId in URL. - shadcn
button.tsxprimitive scaffolded.
Known issues left for Stage 1 to fix: sidebar uses raw <a href> (full page reloads); no central API client; no useSse hook (inlined in Search); no AuthContext; no CSRF header on writes; only one shadcn primitive installed.
Stage 1 β Foundation polish π§ IN PROGRESS
Goal: make the scaffolding production-shaped before building more pages on top of it.
- Sidebar navigation: replace every
<a href="..." class="...">inApp.tsxwith<NavLink to="..." className="...">fromreact-router-dom. Active-route styling viaNavLink'sisActivecallback. Verify by clicking β no full page reload. - shadcn primitives needed for Phase 1. Run
npx shadcn@latest add card input label form select dialog toast tabs badge separator collapsible dropdown-menu tooltip alert avatar checkbox skeleton. Verify each.tsxlands incomponents/ui/. - API client (
lib/api/client.ts). TypedapiFetch<T>(path, init)that: prepends/api, setsContent-Type: application/jsonon JSON bodies, setsX-Requested-With: XMLHttpRequeston non-GET, decodes{ code, message, details }on 4xx/5xx and throws a typedApiError. HasapiGet,apiPost,apiPatch,apiDeletehelpers. No barefetch()calls anywhere else in the app. - SSE hook (
lib/sse/use-sse.ts). GenericuseSse<TEvent>(url: string | null, options?: { enabled?: boolean })returning{ events, isOpen, lastEventId, reconnect, error }. Auto-closes on unmount. Ignoresevent: ping. Extract Search.tsx's inline EventSource into this hook. - Auth (
lib/auth/auth-context.tsx).AuthProviderreadingGET /api/auth/meon mount; exposing{ user, login, logout, isLoading }.<ProtectedRoute>wrapper redirects to/login?next=.... Wire intoApp.tsxLayout. - Toast host. Global
<Toaster />from shadcn. ApiError handler in client.ts callstoast.error(error.message)automatically; routes can suppress if they handle inline. - Type stub (
lib/api/types.ts). Hand-write DTOs fromFRONTEND_BUILD.md Β§3.5. Add// AUTOGENERATED ONCE BE IS LIVE β DO NOT EDITat top. Drop in whenopenapi.jsonexists. - Format helpers (
lib/formatting/).formatCost(usd: number),formatDuration(ms: number),formatPageLabel(bookId, pdfPage, printedPage?)β server-formatted strings preferred; helpers only for cases the API doesn't pre-format. - Status indicator in sidebar. Replicate Streamlit's "active ingest" indicator: subscribe to
GET /api/system/eventsSSE; render a chip with active job count and a list of running job ids (click β/operations/jobs/[jobId]).
Stage 1 gate (must all pass before moving on):
- Click every sidebar link β no full page reload (no
index.htmlrefetch in devtools network tab). - Trigger a 401 (e.g., delete the cookie in devtools, click Search): user is redirected to
/login?next=/research/search. - Open Search page, submit a query, observe SSE working through
useSsehook (not inline). Refresh during the run β?job=...in URL keeps the stream attached. npm run lint(tsc --noEmit) passes with zero errors.- Cost monitoring placeholder: the cost panel placeholder ("$0.00 β no calls yet") renders on Search after a mock-server completion. Wiring exists.
Stage 2 β Search page polished π PENDING
Anchored to FRONTEND_PRD.md Phase 1.
- AdvancedModels expander on Search: model picker for generation/classify/judge,
topK,forceMode, filters (tradition/era/language),usePremium. Source:GET /api/tools?category=llm. - Recent queries expander above the form on Search:
GET /api/history?page=search&user=me&limit=5. Click β navigate to/research/history/{runId}. - Cost panel below the result: render
envelope.cost.{totalUsd, byStage, nCalls, durationMs}from the finaldoneevent. Collapsed by default; click to expand β table of per-call rows fromGET /api/costs/llm-calls?sinceId=envelope.llmCallIdRange.firstId. - RunDetail page (
/research/history/[runId]): fetchGET /api/query/runs/{runId}, render byenvelope.mode(LookupEnvelope rendering matches Search). "Re-run" button copies query + params to/research/search?prefill={runId}. - History page (
/research/history): paginated list with filters (page=,q=,pinned=,user=). Pin/unpin actions. Cursor-based pagination. - Login page: real form against
POST /api/auth/login, error envelope on bad credentials, redirect to?next=or/. - About page: tools registry table (
GET /api/tools), live counts: indexed books, active collection, last query timestamp. - CmdEnter shortcut on Search submits.
-
<RtlText>component for Arabic text. Used everywhere Arabic appears (citation excerpts, query echo). -
<CitationCard>component for citation display. Click β/library/[bookId]/inspect?page={pdfPage}in a new tab.
Stage 2 gate:
- Run a real search end-to-end against the mock; cost panel populated. Re-run from history works. Refresh during a query reattaches the SSE stream.
- Pin a query from history; refresh; still pinned. Delete; refresh; gone.
- Visit
/about: tools registry table renders with at least 3 entries (mock can stub 3-5 tools). - Cmd/Ctrl+Enter on Search submits the form.
- Cost gate: in mock mode, set the mock to return
envelope.cost.totalUsd = 0.0234. The cost panel shows "$0.0234"; the cost breakdown rows match.
Stage 3 β Library + Add Book + Ingest watcher π PENDING
FRONTEND_PRD.md Phase 2.
-
/libraryBrowse:GET /api/books?...with filter form on URL params. Status badges, label chips, action menu per row. Bulk-select β "Reindex selected" fans out one POST per book withbody: { stages: ["indexing"] }. -
/library/[bookId]Book detail: tabs (Overview / History / Costs / Jobs). Endpoints:GET /api/books/{id},GET /api/processing-log?bookId=,GET /api/costs?groupBy=stage&bookId=,GET /api/jobs?subjectId=&limit=10. Action menu (admin only): Edit, Re-ingest, Re-extract, Re-cleanup, Re-chunk, Re-index, Delete (with ConfirmDialog). -
/library/[bookId]/inspect?page=N: three-pane (image | OCR | clean) with synced scroll. Page navigator. Diff between OCR and clean. -
/library/[bookId]/jobs/[jobId]: SSE-watched ingest. Replays history on mount viaLast-Event-ID. Cancel button (admin). Final summary ondone. -
/library/addwizard:- Step 1: URL paste OR file upload (
POST /api/uploadsβ{ uploadId }) βPOST /api/books/probereturnsProbeResponse. - Step 2: Edit guessed metadata; show suggested labels; extraction-mode toggle; cleanup toggle; sample pages displayed.
- Step 3: Cost preview via
GET /api/costs/counterfactual?stage=ingest&.... Confirm. - Step 4:
POST /api/books(handle 409 inline with link to existing book);POST /api/books/{id}/ingest; redirect to/library/[bookId]/jobs/[jobId].
- Step 1: URL paste OR file upload (
-
/library/labels: CRUD; color picker; book-count badge; "Seed from derived" button callsPOST /api/labels/seed-from-derived. -
<JobWatcher>component: extracted from the page, reusable in Add Book final step, ops jobs detail, eval run, library detail Jobs tab. -
<BookCard>component: row + grid variants. -
<StatusBadge>component: all 6 statuses + colors fromFRONTEND_PRD.mdtable.
Stage 3 gate:
- Full ingest dry-run: Add a book β wizard finishes β ingest job runs β live progress visible β completion redirects to book detail β search the new book β answer cites it. (Against the mock initially; against real BE once BE Stage 5 lands.)
- Refresh during a mock ingest: progress bar resumes from current state without losing prior log lines.
- Cost gate: Add Book wizard step 3 shows a counterfactual cost preview. The job watcher's progress bar shows
costSoFarUsdupdating. The finaldoneevent'stotalCostUsdmatches Sum ofllm_calls.cost_usdfor the run (BE responsibility β FE verifies the displayed number matches).
Stage 4 β Operations + Evaluation + final research π PENDING
FRONTEND_PRD.md Phase 3.
-
/operationsdashboard. -
/operations/jobs+[jobId](delegates to<JobWatcher>). -
/operations/costs: multi-panel (by-day chart, by-model, by-stage, by-book). Range filter. -
/operations/indexes: collection list; activate; delete. -
/operations/processing: filterable log. -
/operations/tools: read-only registry view with verified flag. -
/operations/config: read merged config; inline-edit;POST /api/config/migrateβ job watcher. -
/evaluation/run: kicks offPOST /api/eval/runsβ job watcher. -
/evaluation/golden: CRUD; bulk import (JSONL); validation. -
/evaluation/history+[runId]: per-item metrics + diff against previous run. -
/research/compare: group builder; strategy picker; per-row cards; cross-cutting synthesis. -
/research/allusions: judge model picker; per-candidate progress; match list with verdict. -
/Dashboard: jobs in flight, recent query, today's cost, status counts, health chips.
Stage 4 gate:
- Cost-monitoring full sweep: the Costs page renders daily totals matching
SELECT date, SUM(cost_usd) FROM llm_calls GROUP BY dateto the cent. Stale-pricing chip on dashboard if any tool haslast_updated > 90 days. - Compare mode: build a group, run a comparison, render per-book rows with their own cost lines. Total = sum of row costs.
- Allusions: judge model override changes the cost line of the judging stage (verify against tools_registry pricing).
- Parity matrix sweep: walk
FRONTEND_PRD.mdAppendix A; every row's behaviors work in the new UI.
Stage 5 β Cutover π PENDING
- Delete all mock routes from
server.ts. Proxy is the only path. - Regenerate
lib/api/types.tsfrom liveopenapi.json; commit; replace the hand-stub. - One week of dogfooding; bugfix-only commits.
- Hand off to BE agent: BE deletes
src/stage9_ui/and removesstreamlitfrompyproject.toml.
Backend track
Stage 0 β Branch setup β DONE (verified 2026-05-17)
Must complete before any BE work begins. The user (or orchestrator) performs these after the current Streamlit-driven indexing job finishes β never during.
- Commit the existing M files on
mainper the commit-split plan (see session log; user controls the split). -
git checkout -b rewrite/api. - On this branch, ensure
CONTRACT.md,BACKEND_BUILD.md,IMPLEMENTATION.md,FRONTEND_PRD.md,FRONTEND_BUILD.mdare tracked. - Add
.gitignoreentries:.claude/,.critique_*.md,*.pngat repo root (move existing PNGs toscreenshots/if keeping them). - Confirm
data/inventory.dbis not committed (should already be ignored).
Stage 1 β Skeleton + health β DONE (verified 2026-05-17)
BACKEND_BUILD.md Β§13 Step 1.
-
src/api/__init__.py,src/api/app.py(FastAPI app + middleware skeleton + ApiError exception handler). -
src/api/config.py(port resolver, git-sha resolver, masked-config helper β no CORS allowlist in v1 per CONTRACT.md Β§2; session secret deferred to Stage 2). -
src/api/dto/common.pywithApiModelbase,ErrorResponse,JobStartResponse. Every Stage-1 DTO inherits fromApiModel. -
src/api/dto/system.pywithHealthResponse,HealthCheck,VersionResponse(all camelCase on the wire β verified via/openapi.json). -
src/api/errors.pyminimal stub (ApiError base + InternalError; full canonical codes land in Stage 3). -
src/api/routers/system.pywithGET /system/health(DB + Qdrant + storage + API keys +stalePricing: []) andGET /system/version(git sha + masked config). -
pyproject.tomladds:fastapi,uvicorn[standard],python-multipart,psutil,httpx,pytest-asyncio. -
uvicorn src.api.app:app --port 8000boots cleanly;curl http://localhost:8000/openapi.jsonreturns a valid spec listing both paths. -
curl http://localhost:8000/system/healthreturns 200 with camelCase fields includingstalePricing: [](all 21 tools updated within the last 90 days as of 2026-05-17). - Cost-monitoring stub:
/system/healthreturnsstalePricing(empty list when fresh; populated when anycost_model.last_updated> 90 days or missing). -
tests/test_system_health.pycovers the OpenAPI shape, camelCase enforcement, health endpoint shape, and secret-masking in/version.
Stage 1 gate: FE agent confirms npm run gen:api against localhost:8000/openapi.json succeeds and produces valid TS types.
Stage 2 β Auth β DONE (verified 2026-05-17)
BACKEND_BUILD.md Β§6 + CONTRACT.md Β§3.
- Migration:
sessionstable added viasrc/stage1_inventory/schema.py(CREATE TABLE IF NOT EXISTS inside SCHEMA_SQL β same idempotent pattern as the rest of the schema;_COLUMN_ADDITIONSis for ALTER-style growth on existing tables, not new tables). -
src/api/auth/sessions.py:create_session,lookup_session(bumpslast_seen, lazy-GCs expired rows),invalidate_session,purge_expired. -
src/api/auth/router.py:POST /auth/login,POST /auth/logout,GET /auth/me. -
src/api/deps.py:current_user(raises 401 UNAUTHENTICATED on miss),require_admin(raises 403 FORBIDDEN for viewers). -
src/api/middleware/csrf.py: enforcesX-Requested-With: XMLHttpRequeston POST/PATCH/PUT/DELETE; exempts/auth/login; GET is exempt by method (SSE works). -
src/lib/auth/repo.pyadditive:from_session_token(token) -> User | None+user_for_usernamehelper. Existingcurrent_user()/ Streamlit session helpers untouched. -
src/api/dto/auth.py:LoginRequest{username, password},MeResponse{username, isAdmin, displayName?}β both inherit ApiModel so the wire is camelCase. -
src/api/errors.py: typedUnauthenticated(401, UNAUTHENTICATED) andForbidden(403, FORBIDDEN) ApiErrors. - Test:
tests/test_auth.pyβ 9 tests covering bad/good login, /auth/me with/without cookie, logout + subsequent 401, CSRF blocks logout without header, GET is exempt, /auth/login is exempt, OpenAPI camelCase regression guard extended to the new DTOs.
Stage 2 gate: FE login flow works end-to-end against real BE; 401 on protected route redirects.
Verification (2026-05-17):
- Live login flow on uvicorn :8011 β bad creds β 401 UNAUTHENTICATED; good creds β 200 + MeResponse +
Set-Cookie: session=...; HttpOnly; Max-Age=2592000; Path=/; SameSite=lax; /auth/me with cookie β 200; POST /auth/logout without X-Requested-With β 403 FORBIDDEN; with header β 204 + cookie cleared; subsequent /auth/me β 401. - Migration idempotency: ran
init_schematwice on a copy ofdata/inventory.db; second run is a no-op;sessionstable +idx_sessions_username+idx_sessions_expiresall present. - Cost gate: 0 new
llm_callsrows during the full live test (SELECT COUNT(*) FROM llm_calls WHERE id > 932returned 0; auth path is pure SQLite + TOML). - camelCase:
curl /openapi.json | jq '.components.schemas.MeResponse.properties | keys'returns["displayName", "isAdmin", "username"]. - Streamlit boots without ImportError (
streamlit run src/stage9_ui/app.py --server.port 8765came up cleanly). - All 9 auth tests pass; the 5 Stage 1 tests still pass (no regressions).
Stage 3 β Read-only routers β DONE (verified 2026-05-17)
BACKEND_BUILD.md Β§13 Step 3 + the GET /books/status-counts added per CONTRACT.md Β§10.
-
src/api/routers/books.py:GET /books,GET /books/{bookId},GET /books/status-counts. -
src/api/routers/history.py:GET /history,GET /query/runs/{runId}(committed runs only β live SSE comes in Stage 4). -
src/api/routers/costs.py:GET /costs?groupBy=,GET /costs/llm-calls,GET /costs/counterfactual. -
src/api/routers/tools.py:GET /tools,GET /tools/{name}. -
src/api/routers/indexes.py:GET /indexes(writes come later). -
src/api/routers/processing_log.py:GET /processing-log(admin-only). -
src/api/routers/labels.py:GET /labels. -
src/api/routers/pdf.py:GET /books/{bookId}/pdf,GET /books/{bookId}/pages/{n}/{image|ocr|clean}. -
src/api/dto/common.py:ApiModelbase +ErrorResponse+JobStartResponse(Stage 1, kept). - All DTOs in
src/api/dto/*inherit fromApiModel(Stage 1+3 βdto/{books,query,costs,tools,indexes,processing_log,labels,pdf_pages}.py). - Pricing health check:
/system/healthreturns the stale list correctly (Stage 1, regression-tested). - Canonical error codes filled in:
BookNotFound(404 BOOK_NOT_FOUND withdetails.bookId),QueryRunNotFound(404 QUERY_RUN_NOT_FOUND withdetails.runId),NotIndexed(409 NOT_INDEXED),BadRequest(400 BAD_REQUEST). Pydantic 422 β BAD_REQUEST envelope via the newRequestValidationErrorhandler inapp.py.
Stage 3 gate: all checks pass.
Verification (2026-05-17 on uvicorn :8015 with mario admin + preview viewer):
/openapi.json | .paths | keyslists 22 paths β Stage 1+2 = 5, Stage 3 = 17 new (/books,/books/status-counts,/books/{bookId},/books/{bookId}/pdf,/books/{bookId}/pages/{n}/{image,ocr,clean},/history,/query/runs/{runId},/costs,/costs/llm-calls,/costs/counterfactual,/tools,/tools/{name},/indexes,/processing-log,/labels).components.schemas.BookStatusEnumis a real OpenAPI enum:{"type":"string","enum":["pending","acquired","ocr_done","clean","indexed","failed"]}β no Literal-to-string regression.components.schemashas 44 entries (Stage 1+2 was ~7; Stage 3 added 37+).- Random schema sweep (PageCleanDTO, BookListResponse, BookCosts) β all properties camelCase. The global sweep in
tests/test_costs.py:test_every_schema_property_is_camelcaseenforces this across every schema; only documented exemption isBookStatusCountsDTO.ocr_doneper CONTRACT.md Β§10. GET /books?limit=5returns 4 books (live data);/books/status-countsreturns{pending:0, acquired:1, ocr_done:0, clean:2, indexed:1, failed:0, total:4}β keys match CONTRACT.md Β§10 exactly.GET /costs?groupBy=day&range=24hreturns 2 day buckets with{key,label,costUsd,nCalls,inputTokens,outputTokens,durationMs,provider}β array shape good.- Cost-monitoring gate:
MAX(llm_calls.id)before = 2481; after the full test + gate cycle = 2481. 0 new paid calls during Stage 3 (read-only routers do not touch any paid SDK). require_admingate: viewer (preview) β/processing-logβ 403 FORBIDDEN envelope; admin (mario) β 200./costsalso gated to admin (faceted, can leak cross-user totals);/costs/llm-calls+/costs/counterfactualare user-tier.- Streamlit boots without ImportError on port 8765 β HTTP 200 served, no regressions to the lib/ surface the existing UI depends on.
All 27 Stage 3 tests pass (9 books + 5 history + 13 costs). Stage 1+2 tests (14 total) still green β no regressions.
Stage 4 β Query + SSE β DONE (verified 2026-05-17)
BACKEND_BUILD.md Β§5.6 + Β§5.8 + Β§13 Step 4.
-
src/api/jobs/store.py: insert/update job rows; emit_event returns the SSE-id; Turso-resilience retry wrapper on every store function. -
src/api/jobs/sse.py: in-memory pub-sub + DB-backed replay fromLast-Event-ID; 15s heartbeat (event: ping); terminal-sentinel signal_terminal(). -
src/api/jobs/runner.py: thread-based runner with per-subject lock (query:{username}) + global semaphore (config.yaml > api.max_concurrent_jobs, default 1). -
src/api/jobs/registry.py:@register_job_typedecorator + get_handler/known_types helpers. -
src/api/jobs/reconcile.py: startup hook flips queued/running rows with dead pid (or pid=NULL) to failed + writes terminal done event. -
src/api/jobs/types/query.py: wrapssrc.stage8_router.router.answer()with on_progress callback that translates router steps (classify_start/done, retrieve_start/done, judge_start/done, generate_start/done, force_mode, comparison_start/done) into the locked SSE taxonomy (stage events with stageName β {classify, retrieve, judge, generate} + progress events). -
src/api/routers/jobs.py: GET /jobs, GET /jobs/{jobId}, GET /jobs/{jobId}/events (honors Last-Event-ID header + ?lastEventId query fallback), POST /jobs/{jobId}/cancel (admin; psutil tree-kill for pidβ NULL + defensive status flip for in-thread jobs). -
src/api/routers/query.py: POST /query β 202 JobStartResponse{jobId, sseUrl}; GET /query/runs/{runId}/events resolves runId β jobId via jobs.subject_id and proxies. - Migration:
jobs+job_eventstables + 3 indexes added to SCHEMA_SQL (idempotent CREATE TABLE IF NOT EXISTS, run-twice verified). - Cost-on-progress: every
progressevent payload carriescostSoFarUsd(sum ofllm_calls.cost_usd WHERE id > first_call_id). Terminal done event carriestotalCostUsd. - Tests: tests/test_query_sse.py (8 β incl. one expensive end-to-end LOOKUP gated on GOOGLE_API_KEY + indexed Qdrant collection), tests/test_jobs_sse_replay.py (3), tests/test_jobs_reconcile.py (3), tests/test_no_unlogged_api_calls.py (2 β the global LLM-SDK-import gate per BACKEND_BUILD.md Β§11.2 / CONTRACT.md Β§12.1).
Stage 4 gate verified (2026-05-17):
- End-to-end search: Live uvicorn :8011 β login β POST /query with
{"query":"What does the book teach about salvation through Christ?","page":"search","topK":3,"usePremium":false}β 202{"jobId":"6ae9b2bc...", "sseUrl":"/jobs/.../events"}(no runId in 202). SSE stream emitted in order:- id 40
stage{stageName:"classify", costSoFarUsd:0.0, query:"..."} - id 41
progress{pct:0.2, stageName:"classify", mode:"LOOKUP", costSoFarUsd:0.005831} - id 42
stage{stageName:"retrieve", costSoFarUsd:0.005831, topK:3} - id 43
progress{pct:0.45, stageName:"retrieve", nHits:3, costSoFarUsd:0.005831} - id 44
stage{stageName:"generate", costSoFarUsd:0.005831} - id 45
progress{pct:0.95, stageName:"generate", nCited:3, costSoFarUsd:0.0284} - id 46
done{finalStatus:"succeeded", durationMs:128827, totalCostUsd:0.0284, runId:16, result:{runId:16, totalCostUsd:0.0284, mode:"LOOKUP"}}
- id 40
- Refresh-safety:
curl -H "Last-Event-ID: 43" /jobs/{id}/eventsyielded exactly events 44, 45, 46 (no duplicates, no gaps).GET /query/runs/16/eventsyielded the full id 40-46 history via the runId β subject_id proxy. - Cost gate (CRITICAL β paid quota): pre-query
MAX(llm_calls.id) = 2488. Post-query new rows: id 2489 stage=classify cost=0.005831, id 2490 stage=generation cost=0.022569 β exactly 2 new rows for a LOOKUP. SUM(cost_usd) = 0.028400 = done.totalCostUsd to the cent. β - OpenAPI new paths:
/openapi.json | jq '.paths | keys'includes all 6 new Stage 4 paths (/jobs,/jobs/{jobId},/jobs/{jobId}/cancel,/jobs/{jobId}/events,/query,/query/runs/{runId}/events); 28 paths total (was 22 in Stage 3). - camelCase regression: JobDTO.properties contains
progressPct,stageLabel,subjectId,createdAt,startedAt,finishedAt; QueryRequest hasforceMode,topK,usePremium,comparisonSpec. All event DTOs (LogEvent/StageEvent/ProgressEvent/DoneEvent/ErrorEvent) registered + camelCase pertest_costs.py::test_every_schema_property_is_camelcase. - No SDK imports outside wrappers:
tests/test_no_unlogged_api_calls.pygrepssrc/forfrom {google.genai,openai,anthropic,cohere,groq} import/import {...}outside the 5 approved wrapper files. Passes. - Reconciler test passes (
tests/test_jobs_reconcile.py). - Streamlit still works:
streamlit run src/stage9_ui/app.py --server.port 8765 --server.headless truecame up;/_stcore/healthreturns 200/ok. - All 41 Stage 1+2+3 tests still pass + 16 new Stage 4 tests pass (4 cheap test_query_sse + 3 reconcile + 3 sse_replay + 2 no_unlogged_api_calls + 4 documentation/auth tests in test_query_sse β actual count via pytest: see log).
Note: under Turso's stale-stream HTTP-404 lag we observed missed stage(classify) events on the very first emit after idle. Fixed by wrapping every store function in _with_retry() that retries once on the Hrana stream-not-found ValueError. After that, three back-to-back live queries each emitted the full event sequence with no drops.
Stage 5 β Jobs subsystem (ingest) β DONE (verified 2026-05-17, live smoke STAGED but NOT RUN)
BACKEND_BUILD.md Β§5 + Β§5.7 + Β§13 Step 5.
-
src/lib/processing/ingest_progress.py: liftedparse_ingest_progress+filter_ingest_log_noisefromsrc/stage9_ui/shared.py(per Β§3.4). Addedstage_number_to_name()mapping for[stage N]β canonical SSE name (acquisition/ocr/cleanup/chunking/indexing). Stage 4b folded intocleanup. -
src/stage9_ui/shared.py: re-export shim (no behavior change β Streamlit still boots, parser output bit-for-bit identical). -
src/api/jobs/types/ingest.py: subprocess-based handler.@register_job_type("ingest")wires it into the existing Stage-4 runner.subprocess.Popenwith line-buffered stdout, CREATE_NEW_PROCESS_GROUP on Windows for clean tree-kill; PID is recorded immediately viastore.set_pidsoPOST /jobs/{id}/cancelcanpsutil.Process(pid).children(recursive=True). The runner's existing locks (per-subject + global semaphore) cover ingest the same way they cover query. -
_StreamingState: re-feeds full filtered log buffer through the lifted parser on every stdout line, detects stage transitions (emitsstageSSE withstageName+stageLabel+costSoFarUsd), emitsprogressthrottled to β€ 1/500ms withpagesDone+pagesTotal+costSoFarUsd. Terminaldoneevent carriesfinalStatus(derived from subprocess exit code, unless cancel flipped status first),durationMs,totalCostUsd,result.{bookId,exitCode,totalCostUsd}. -
--stages acquisition,ocr,cleanup,chunking,indexingonsrc/pipeline/ingest.py:main(). Validates againstSTAGE_NAMES; unknown stage aborts withSystemExit(2).ingest()addsstages: set[str] | Noneparam +_wants(name)guard on every stage block. Default (stages=None) = run every eligible stage = unchanged behavior. -
src/api/dto/ingestion.py:IngestRequest(camelCase via ApiModel) +StageName: Enum(real enum, surfaces as TS union via OpenAPI). All fields optional β empty POST = re-ingest with persisted settings. -
src/api/routers/ingestion.py:POST /books/{bookId}/ingestadmin-only; 404 BOOK_NOT_FOUND envelope withdetails.bookId; 202JobStartResponse{jobId, sseUrl=/jobs/{jobId}/events}; persistsextraction_mode/cleanup_enabledonto the book row before enqueue; passessubject_id=book_idat insert +subject_key=f"book:{book_id}"at enqueue. -
src/api/app.py: mountsingestion_router. Side-effect import ofsrc.api.jobs.types.ingestlives inside the router file (parity withquery.py). - Per-subject lock keyed by
(ingest, book:{book_id})β second concurrent ingest for the same book waits on the existingrunner._subject_lock. No 409; the request is accepted (202) and serialized by the lock (documented in the router docstring). - Tree-kill on cancel β already wired in
routers/jobs.py:cancel_jobviapsutil.Process(pid).children(recursive=True)since Stage 4. Stage 5 just supplies the PID by callingstore.set_pid(job_id, proc.pid)right afterPopen. - Cost-on-progress for ingest: every
stageandprogressevent payload carriescostSoFarUsdcomputed viastore.cost_since(first_call_id)wherefirst_call_id = store.max_llm_call_id()snapshotted at job start. Terminaldone.totalCostUsdis the same sum at terminal. - Tests:
tests/test_ingest_jobs.py(17, all pass) β argv builder purity, parser state transitions (3β4β5β6 β canonical [ocr, cleanup, chunking, indexing]), progress events carry pagesDone + costSoFarUsd, 401/403/404 gates, 202 happy path with mockedrunner.enqueue+ persisted job row + cleanup, empty body accepted, bogus stage rejected as BAD_REQUEST, OpenAPI camelCase + StageName-enum regression. -
tests/test_ingest_jobs_live.pyβ STAGED but NOT RUN. Marked@pytest.mark.live(registered in pyproject.toml) +@pytest.mark.skipbelt-and-suspenders.pyproject.tomlnow defaults to-m 'not live'. Documented opt-in flow:LIVE_INGEST_BOOK_ID=<small_book> LIVE_INGEST_STAGES=indexing pytest -m live tests/test_ingest_jobs_live.py. Skeleton wires login β POST β SSE drain β cost gate so when the user opts in the test runs straight through.
Stage 5 gate β verified 2026-05-17 (live smoke deferred per user opt-in):
parse_ingest_progressimport works from BOTHsrc/lib/processing/ingest_progress.py(canonical) ANDsrc/stage9_ui/shared.py(re-export shim) β confirmed by direct import + sample-input behavior check (identical output across 3 representative log transcripts). βpython -m src.pipeline.ingest --helplists--stageswith the 5 canonical choices;--stages bogus,chunking --book-id xaborts withunknown stage(s) ['bogus'];--stages chunking --book-id nonexistent_test_book_id_123reaches the ingest() function and errors cleanly ("book_id ... not in the inventory"). βPOST /books/{id}/ingestreturns 202 JobStartResponse with validjobId+sseUrl=/jobs/{jobId}/events. Verified via TestClient against a real book id from the live inventory: 202 +runner.enqueuecalled exactly once withsubject_key=book:{id}+type=ingest, job row landed in 'queued' withsubject_id=book_id. β- Mocked-subprocess parser tests pass:
_StreamingStatefed synthetic[stage 3] OCRβ¦+ page-ok lines emits astageevent withstageName="ocr"+costSoFarUsd, thenprogressevents withpagesDone=1,2,3+stageName=ocr+costSoFarUsdβ₯0. Multi-stage transition test (3β4β5β6) emits canonical [ocr, cleanup, chunking, indexing] in order. β - Per-subject lock: two simultaneous ingest requests for the same
book_idget accepted as 202 (no 409) and serialize onrunner._subject_lock[("ingest", "book:{book_id}")]. Documented in the router docstring. β /api/jobs?type=ingestfilters by ingest type β uses the existingrouters/jobs.pylist_jobsendpoint which already acceptstype: JobTypeEnum | None. JobTypeEnum already listsingest(was reserved in Stage 4 for this stage). β- Cost gate: 0 new
llm_callsrows during this session (no live ingest). Every unit test mocks the subprocess viamonkeypatchonrunner.enqueueOR feeds synthetic stdout to_StreamingState(which callsstore.cost_sincebut does not invoke any LLM SDK). The live smoke test is staged but explicitly skipped. β - Streamlit still boots:
src.stage9_ui.sharedimports cleanly;parse_ingest_progressre-export produces identical output to the canonical version. β - All 17 Stage 5 tests pass + 90 prior tests still green. The 19 pre-existing failures in
tests/test_labels.py+tests/test_stage1_inventory.py(libsql Row "'C' object has no attribute" issues) are NOT caused by Stage 5 β confirmed bygit stash && pytest tests/test_labels.py tests/test_stage1_inventory.pyon the base commit (same 19 failures). β
Live smoke test deferred to user opt-in. When ready: drop a 1-page test book in the inventory, then LIVE_INGEST_BOOK_ID=<id> LIVE_INGEST_STAGES=indexing pytest -m live tests/test_ingest_jobs_live.py. Estimated cost for indexing alone: $0.00 (local BGE-M3 + Qdrant). Estimated cost adding ocr: ~$0.005/page Gemini.
Stage 6 β Add Book probe + writes β DONE (verified 2026-05-17, live probe smoke STAGED but NOT RUN β user opt-in)
BACKEND_BUILD.md Β§13 Step 6.
-
POST /api/uploads: multipart; stores indata/uploads/{uploadId}.pdf; returns{ uploadId, sha256, sizeBytes }(camelCase). 1 GiB cap; SHA-256 streamed in 1 MiB chunks so peak RSS stays flat on the largest PDFs in the inventory. Admin-only. -
POST /api/books/probe: wrapssrc/lib/metadata_probe.probe_metadata; returns ProbeResponse withpagesTotal,hasTextLayer,suggestedExtractionMode,guessedMetadata(enums coerced to FE union types),suggestedLabels,samplePages[](PyMuPDF native text),estimatedIngestCostUsd(computed againsttools_registry.yamlfor the active OCR + cleanup models β $0 when native_text mode is suggested). Body requiresuploadIdORsourceUrl. Admin-only (paid Gemini metadata call ~$0.005). Tests MOCKprobe_metadata. -
POST /api/books:CreateBookRequestbody; 409BOOK_EXISTSwithdetails.conflictWithBookIdwhenbookIdcollides OR whensourceUrlcollides with an existing row; 201 BookDTO on success. Moves the upload bytes into storage (data/raw/{bookId}.pdf) viaget_storage().put_pdfBEFORE the books row INSERT so a storage failure never leaves an orphan row. -
PATCH /api/books/{bookId}:UpdateBookRequest(partial). Status field is NOT in the DTO (pipeline owns it). Enum members coerced back to strings before update_book(). -
DELETE /api/books/{bookId}: admin-only, idempotent (204 on a non-existent book). Cleanup order: Qdrant points across every known collection (best-effort; tolerates network failures) βbook_labelsmappings βbooksrow. Historicalprocessing_log/llm_calls/query_history/book_indexesrows preserved. -
POST /api/labels: 201 LabelDTO; 400 on duplicate id.PATCH /labels/{labelId}: preserves immutablekindfield.DELETE /labels/{labelId}: 204 even on miss (idempotent).POST /labels/seed-from-derived: wrapssrc/lib/labels/seeding.seed_derived_labels; returns{ created, existing }counts; idempotent (second call returnscreated=0). -
GET /api/books/{bookId}/preview?page=N&dpi=120: returns PNG bytes for the wizard's sample-page pane. Default DPI 120 (vs pdf.py's 150) to keep the wizard snappy on slow tethers. 404 BOOK_NOT_FOUND envelope on unknown book. - Tests:
tests/test_addbook.py(18 β upload happy path + auth + empty rejection; probe with mocked metadata; create 201 + 409 BOOK_EXISTS for bookId + sourceUrl conflicts; PATCH updates + 404; DELETE 204 clears labels + Qdrant + idempotent; preview PNG; OpenAPI path + camelCase regression),tests/test_labels_writes.py(10 β POST 201 + duplicate 400 + validation 400; PATCH updates + 404 + immutable kind; DELETE 204 + idempotent; seed-from-derived idempotent; auth + CSRF gates),tests/test_addbook_live.py(STAGED but NOT RUN;@pytest.mark.live+@pytest.mark.skip+pyproject.toml's-m 'not live'default; opt-in flow documented forLIVE_PROBE_PDF_PATH=<path>ORLIVE_PROBE_SOURCE_URL=<url>; estimated cost ~$0.001-0.005 per run).
Stage 6 gate verified 2026-05-17:
- All 28 Stage 6 unit tests pass (18 in test_addbook.py + 10 in test_labels_writes.py). Live probe test STAGED + SKIPPED per
pyproject.toml's-m 'not live'default. - Cost gate (load-bearing for paid quota): pre-test
MAX(llm_calls.id) = 2495. After the full Stage 6 test cycle, 0 newllm_callsrows (SELECT COUNT(*) FROM llm_calls WHERE id > 2495 = 0). Every probe test monkey-patchessrc.api.routers.addbook.probe_metadatato return a syntheticProbeResult; every DELETE test monkey-patchessrc.stage6_indexing.qdrant_io.{list_collections, delete_book_points}so the live cloud collection is never touched. - camelCase regression:
tests/test_costs.py::test_every_schema_property_is_camelcasestill passes β every Stage 6 DTO (UploadResponse, ProbeRequest, ProbeResponse, GuessedMetadata, SuggestedLabel, SamplePage, CreateBookRequest, UpdateBookRequest, LabelCreateRequest, LabelUpdateRequest, LabelSeedResponse) inheritsApiModeland serializes camelCase on the wire. - No SDK imports outside wrappers:
tests/test_no_unlogged_api_calls.py(2 tests) still green β no new directfrom google.genai/openai/anthropic/cohere/groq importoutside the 5 approved wrapper files. - OpenAPI paths:
curl /openapi.json | jq '.paths | keys'includes all Stage 6 paths β/uploads,/books/probe,/books/{bookId}/preview,/labels/seed-from-derived, plus the POST/PATCH/DELETE verbs added to existing/books+/books/{bookId}+/labels+/labels/{labelId}paths (43 endpoints total across 34 paths). - Data preservation: live
data/inventory.dbis untouched β books count remained 4 + labels count remained 19 across the entire test cycle. Every test fixture (cleanup_created_books,cleanup_labels) tracks the rows it creates and tears them down at teardown; thedelete_bookhelper also unlinks the on-disk PDF indata/raw/so local storage stays clean. - Bug fixed during this session:
src/api/routers/books.pycreate_bookcalledreq.era.value(etc.) but ApiModel'suse_enum_values=Truealready coerces enum members to plain strings at validation time β same pattern the Stage 5 implementation log flags. Refactored to a single_v(x)helper that handles both Enum and str inputs (matching the existing pattern inpatch_book). Found bytest_create_book_returns_201_with_book_dto; tests added now guard this regression. - No regressions to prior stages: all prior stage 1-5 tests still pass after the books.py fix (Stage 3 books, Stage 4 query/jobs/sse, Stage 5 ingest).
Live probe smoke deferred to user opt-in. When ready: LIVE_PROBE_PDF_PATH=<small_pdf> pytest -m live tests/test_addbook_live.py for upload+probe; or LIVE_PROBE_SOURCE_URL=<public_url> pytest -m live tests/test_addbook_live.py for URL-fetch+probe. Estimated cost: ~$0.001 for a 1-page native_text PDF; ~$0.005 for a 5-page image-mode PDF.
Stage 7 β Operations + Evaluation π PENDING
BACKEND_BUILD.md Β§13 Step 7.
-
GET /api/config,PATCH /api/config,POST /api/config/migrateβ job. -
POST /api/indexes/{name}/activate,DELETE /api/indexes/{name}. -
GET/POST/PATCH/DELETE /api/eval/golden/*. -
POST /api/eval/runsβ job;GET /api/eval/runs[/{id}]. -
src/api/jobs/types/eval_run.py,src/api/jobs/types/backend_migrate.py.
Stage 7 gate: Run an eval against the real BE; results appear in /evaluation/history/[runId]. Backend migration from local β turso (or back) works as a job, with progress visible.
Stage 8 β Cleanup π PENDING
-
tests/test_no_unlogged_api_calls.pywritten; CI passes. - Parity matrix (FRONTEND_PRD.md Appendix A) fully green.
- One week of dogfooding (FE Stage 5).
-
git rm -r src/stage9_ui/; dropstreamlitfrompyproject.toml. - Final commit; merge
rewrite/apiβmain.
Open contract questions
If either agent hits a question CONTRACT.md doesn't answer, don't guess. Add it to this section and ping the orchestrator. Examples of legitimate questions:
- A new field needs to be added to BookDTO.
- A new endpoint shape isn't covered.
- An error code is needed beyond CONTRACT.md Β§8's list.
Currently open: (none β keep this section honest; remove "none" when adding the first entry.)
Implementation log (latest first)
- 2026-05-17 (BE, fix-up) β FE Stage 3 audit blockers β 3 BE bugs fixed. Triggered by
Patristic-AI-Fe/tests/screenshots/stage3_audit/STAGE3_REPORT.md(CC7 / CC8 + critical 3 in Β§3 of the audit). Three landed fixes, one perf bonus:GET /system/eventsSSE endpoint (audit CC8). New code insrc/api/routers/system.py: a poll-driven SSE generator emitsevent: active_jobswith{activeCount, runningJobIds}every ~2.5 s when the snapshot changes, plusevent: pingheartbeats every 15 s (CONTRACT.md Β§4). Initial frame yielded before the loop so the FE'sSidebarJobStatus.tsxchip flips off "Connectingβ¦" on first byte rather than waiting one poll. User-tier gate viacurrent_userdep. Tests:tests/test_system_events.py(5 β auth gate / snapshot shape / wire format / generator initial-yield / OpenAPI surface).- Connection-layer Turso retry (audit CC7). New shared module
src/stage1_inventory/turso_retry.py(is_stale_stream_error,with_retry).TursoConnection.execute/executemany/commit/executescript/cursor/rollbacknow auto-reconnect + replay once on Hranastream not found404 β catches every Turso query path including thelookup_session()βconn.commit()cold-start race the tester captured (was 500 on first/books/status-countsafter BE boot).src/api/jobs/store.pyre-exports_with_retryfrom the shared module so existing per-callsite calls keep working (now double-protected). Tests:tests/test_turso_retry.py(9 β classifier matches Hrana wire format / rejects unrelated errors / single-shot retry / no infinite loop / TursoConnection reconnect + replay path / persistent-failure propagation). GET /books/{id}/pages/{n}/image500 β structured 404 (audit critical Β§3). Root cause:Storage.ensure_local()raises bareFileNotFoundErrorfor books whose inventory row exists but whose PDF blob isn't on disk (legacy import / R2 fetch failure).src/api/routers/pdf.py:get_page_imagenow catchesFileNotFoundError+ out-of-rangeValueErrorand converts them toBookNotFound(404 BOOK_NOT_FOUND envelope per CONTRACT.md Β§8). Matching fix onget_book_pdf(same root cause class). The FE's graceful "Page image unavailable." chrome renders cleanly on 404 but coughs up a generic toast on 500 β that's the exact UX bug the audit caught on every book the user opened that didn't have a local PDF. Tests:tests/test_pdf_image.py(5 β happy path 200 PNG / missing blob β 404 envelope / unknown book β 404 / out-of-range page β 404 / sweep every real-inventory book to assert status β {200, 404}, never 500).- Performance side fix (audit CC11 partial β perf):
init_schema()was running on everyconnect()(idempotent CREATE TABLE IF NOT EXISTS + 5 ALTER TABLEs + a backfill UPDATE = ~7 round-trips β 4 s on Turso). Added a process-local guard insrc/stage1_inventory/db.py:_ensure_schema_onceso the migration runs exactly once per(process, backend). Per-request connect latency drops from ~4 s to single-digit ms; this also unblocked the/system/eventspoll loop which would otherwise have starved the event loop on every tick. Verified against live Turso: first connect ~4 s (schema run), every subsequent connect <50 ms. - ASGI CSRF middleware refactor (load-bearing for SSE). The existing
csrf_middlewarewas registered viaapp.middleware("http")(fn)which wraps it inBaseHTTPMiddleware. That adapter buffers streaming responses through an anyio memory stream β verified during this session thatcurl /system/eventssaw 0 bytes for 15+ seconds before a burst flush, breaking the FE chip's "connect β first frame" UX. NewCsrfASGIMiddlewareinsrc/api/middleware/csrf.pyis pure ASGI (operates on rawscope/receive/send) and lets SSE frames flush as they arrive. Same rule (requireX-Requested-With: XMLHttpRequeston POST/PATCH/PUT/DELETE except/auth/login). The legacycsrf_middlewarefunction is kept for back-compat with any caller importing it directly; the app wires the ASGI class viaapp.add_middleware(CsrfASGIMiddleware). Verified live: CSRF rejection ofPOST /auth/logoutwithoutX-Requested-Withβ 403 envelope, CSRF allow with header β 200, plus SSE first frame arrives immediately. Verification gates (all green, evidence below):
- Live curl on :8011 (separate from orchestrator's :8000):
GET /system/eventsemittedevent: active_jobs\ndata: {"activeCount": 0, "runningJobIds": []}on first byte; second SSE check (12 s @ 5 s heartbeat config) emitted both initial frame + ping heartbeat. - Live curl: cold-start
GET /books/status-countsreturned 200 on FIRST call after fresh BE boot (no Hrana 500). - Live curl:
GET /books/deskolia_v3/pages/1/image?dpi=150β 200image/png634943 bytes;GET /books/history_of_christian_thought_jesus_christ_through_the_ages/pages/1/image?dpi=150(no local PDF) β 404 BOOK_NOT_FOUND envelope. - Cost gate (load-bearing for paid quota): pre-fix-up
MAX(llm_calls.id)=2497, post-fix-upMAX(llm_calls.id)=2497, 0 new paid calls in the entire fix-up session (all tests mock paid surfaces or hit cheap probes; no fix area touches an LLM SDK). - 19 new tests across
test_turso_retry.py+test_pdf_image.py+test_system_events.pyall pass. Full test suite re-run pending at log-write time (run is in flight β see Verification at end). - Streamlit smoke-imports unaffected (no changes to UI surfaces).
- NOT addressed (out of scope for this fix-up): BE-4 perf for
/books/{id}12-second spinner β the slow joins are noted in the audit; the init_schema-once fix above shaves ~4 s off cold-start request latency but the/books/{id}endpoint itself still aggregates costs/indexes/processing-log/labels sequentially. That's a follow-up (Stage 7 or later perf pass). FE-side audit items (Browse infinite loop, Add Book contract drift, ConfirmDialog nested-button, etc.) are explicitly FE-side and don't apply to this repo. Commits onrewrite/api: see git log entries from this session.
- 2026-05-17 (BE) β Stage 6 (Add Book probe + writes) landed on
rewrite/api. Live probe smoke STAGED but NOT RUN (user opt-in per AGENT_PROMPT_BE.md's paid-quota concern). New files:src/api/dto/addbook.py(UploadResponse, ProbeRequest, ProbeResponse, GuessedMetadata, SuggestedLabel, SamplePage β all inherit ApiModel for camelCase wire),src/api/routers/uploads.py(POST /uploadsβ multipart PDF, 1 GiB cap, SHA-256 streamed in 1 MiB chunks, returns{uploadId, sha256, sizeBytes}),src/api/routers/addbook.py(POST /books/probeβ wrapssrc/lib/metadata_probe.probe_metadata, returns ProbeResponse withpagesTotal/hasTextLayer/suggestedExtractionMode/guessedMetadata/suggestedLabels/samplePages/estimatedIngestCostUsd/probeError),tests/test_addbook.py(18 tests β upload happy path + 401 + empty rejection; probe with mocked metadata + 400 on missing body; create 201 + 409 BOOK_EXISTS for bookId + sourceUrl conflicts; PATCH updates + 404; DELETE 204 clears labels + Qdrant + idempotent; preview PNG + 404; OpenAPI camelCase regression),tests/test_labels_writes.py(10 tests β POST 201 + duplicate 400 + validation 400; PATCH updates + 404 + immutable kind; DELETE 204 + idempotent; seed-from-derived idempotent; auth + CSRF gates),tests/test_addbook_live.py(STAGED but NOT RUN; opt-in viaLIVE_PROBE_PDF_PATHorLIVE_PROBE_SOURCE_URLenv vars; full skeleton with login β upload/url β probe β cost gate). Allowed additive edits per BACKEND_BUILD.md Β§3.4:src/lib/books/repo.pygotfind_book_id_by_source_url,update_book(partial),delete_book;src/api/errors.pygotBookExists(409 BOOK_EXISTS withdetails.conflictWithBookId);src/api/routers/books.pyextended withPOST /books(201 BookDTO + 409 envelope),PATCH /books/{bookId},DELETE /books/{bookId}(best-effort Qdrant cleanup β book_labels mappings β books row, historical rows preserved),GET /books/{bookId}/preview?page=N&dpi=120(PNG bytes for the wizard, default DPI 120 vs pdf.py's 150);src/api/routers/labels.pyextended with POST/PATCH/DELETE +POST /labels/seed-from-derived;src/api/dto/books.pyextended withCreateBookRequest/UpdateBookRequest;src/api/dto/labels.pyextended withLabelCreateRequest/LabelUpdateRequest/LabelSeedResponse;src/api/app.pymountsuploads_router+addbook_router. Bug found + fixed mid-session:create_bookwas callingreq.era.value(etc.) directly, but ApiModel'suse_enum_values=Truealready coerces enums to strings at validation β same pattern flagged in the Stage 5 implementation log. Refactored to a single_v(x)helper that handles both Enum and str inputs (matching the existing pattern inpatch_book). Caught bytest_create_book_returns_201_with_book_dto; regression-guarded by the new test suite. Cost gate (load-bearing for paid quota): pre-testMAX(llm_calls.id) = 2495. After the full Stage 6 test cycle,SELECT COUNT(*) FROM llm_calls WHERE id > 2495 = 0β 0 new paid calls. Every probe test monkey-patchessrc.api.routers.addbook.probe_metadatato return a syntheticProbeResult; every DELETE test monkey-patchessrc.stage6_indexing.qdrant_io.{list_collections, delete_book_points}so the real Qdrant cloud collection is never touched. The live probe path is gated behind@pytest.mark.live+@pytest.mark.skip+pyproject.toml's default-m 'not live'β belt, suspenders, and a second pair of suspenders (same pattern astest_ingest_jobs_live.py). Data preservation: livedata/inventory.dbstill has the same 4 books + 19 labels after the test cycle β every fixture cleans up the rows it creates. Verified gates: 28 new tests pass + camelCase regression intest_costs.py::test_every_schema_property_is_camelcasestill green + no-SDK-imports-outside-wrappers regression intest_no_unlogged_api_calls.pystill green + Streamlit still boots cleanly + OpenAPI surfaces all Stage 6 paths (/uploads,/books/probe,/books/{bookId}/preview,/labels/seed-from-derived, PATCH/DELETE on existing/books/{bookId}+/labels/{labelId}, POST on/books+/labels). All prior stage 1-5 tests still pass after the books.py enum-coercion fix (no regressions). Commits onrewrite/api:56d5355(DTOs + BookExists error β prior agent session, pre-crash),5a70aa4(uploads + probe + book/label writes + preview β prior agent session, pre-crash),<tests>(this session: tests + books.py enum fix),<tracker>(this session: tracker flip + this log entry). Next: Stage 7 (Operations + Evaluation βGET/PATCH /api/config,POST /api/config/migrateβ job,POST /api/indexes/{name}/activate, eval CRUD + run jobs). - 2026-05-17 (BE) β Stage 5 (Jobs subsystem β ingest) landed on
rewrite/api. Live smoke test STAGED but NOT RUN (user opt-in per AGENT_PROMPT_BE.md's paid-quota concern). New files:src/lib/processing/ingest_progress.py(lifted parser +stage_number_to_namemapping),src/api/jobs/types/ingest.py(subprocess-based handler β Popen with CREATE_NEW_PROCESS_GROUP on Windows, line-buffered stdout drain via_StreamingState, throttled progress events,costSoFarUsdon every emit, terminaldonewith final status from subprocess exit code + total cost fromstore.cost_since),src/api/dto/ingestion.py(IngestRequest + StageName: Enum),src/api/routers/ingestion.py(POST /books/{bookId}/ingest, admin-only, 202 JobStartResponse, persists extraction_mode/cleanup_enabled overrides, enqueues with subject_key=book:{id}),tests/test_ingest_jobs.py(17 tests β argv builder, parser state transitions, router contract, OpenAPI regression),tests/test_ingest_jobs_live.py(staged-but-skipped end-to-end test with full skeleton). Allowed additive edits per BACKEND_BUILD.md Β§3.4:src/pipeline/ingest.pygained--stagesflag +STAGE_NAMES+_parse_stages_arg(validates against canonical set, aborts on typo);ingest()takesstages: set[str] | Noneand each numbered block guards on_wants(name)β default behavior is unchanged when the flag is omitted.src/stage9_ui/shared.pyreduced to a re-export shim importing the lifted parser; Streamlit still boots and the parser output is bit-for-bit identical (verified by feeding the same 3 transcripts to both module paths).pyproject.tomlregisters thelivepytest marker + defaults to-m 'not live'so the live smoke is deselected at collection.src/api/app.pymounts the new router. Bug found + fixed mid-session: ApiModel'suse_enum_values=Truemakes Pydantic coerce enum members to their string values at validation, soreq.stages[0]isstr, notStageNameβ router normalizesisinstance(s, str) else s.valueon stages and extraction_mode so a future ApiModel config change can't silently break this path. Cost gate (load-bearing for paid quota): 0 newllm_callsrows in this session β every unit test mocks the subprocess viamonkeypatchonrunner.enqueueor feeds synthetic stdout directly to_StreamingState(which never calls an LLM SDK; only reads SQLite viastore.cost_since). The live test path is gated behind@pytest.mark.live+@pytest.mark.skip+pyproject.toml's default-m 'not live'β belt, suspenders, and a second pair of suspenders. Verified gates: --help shows --stages flag, bogus stages reject cleanly, 404 BOOK_NOT_FOUND envelope withdetails.bookIdfor unknown books, 202 happy path returns valid jobId + sseUrl matching/jobs/{jobId}/events, OpenAPI has 29 paths total with/books/{bookId}/ingestregistered + IngestRequest/StageName camelCase + enum, Streamlit shim parser output identical to canonical, all 17 Stage 5 tests pass + 90 prior tests still green (the 19 pre-existingtest_labels.py+test_stage1_inventory.pylibsql-Row failures are unrelated β confirmed bygit stash && pyteston base commit). Threading vs subprocess (locked design): query jobs run in-thread (Stage 4); ingest jobs run as subprocess (Stage 5) for tree-kill reliability on multi-hour OCR. Commits onrewrite/api:d614729(parser lift + --stages CLI flag),e987772(ingest job type + DTO + router + app wiring),789d515(tests + staged-but-skipped live smoke + pytest marker registration). Next: Stage 6 (Add Book probe + writes βPOST /uploads,POST /books/probe,POST /bookswith 409 BOOK_EXISTS, PATCH/DELETE /books/{id}, label CRUD). - 2026-05-17 (BE) β Stage 4 (Query + SSE) landed on
rewrite/api. New files:src/api/jobs/{__init__,store,sse,registry,runner,reconcile}.py,src/api/jobs/types/{__init__,query}.py,src/api/routers/{jobs,query}.py,src/api/dto/jobs.py. Extendedsrc/api/dto/query.pywith QueryRequest/PageEnum/Filters/ComparisonGroup/ComparisonSpec/QueryModelOverrides (locked field names per CONTRACT.md Β§11). Schema:jobs+job_eventstables + 3 indexes added to SCHEMA_SQL (run-twice idempotent verified on live data/inventory.db). Tests:test_query_sse.py(8 β incl. one paid end-to-end LOOKUP gated on GOOGLE_API_KEY + indexed Qdrant),test_jobs_sse_replay.py(3 β strict-greater-than fetch_events_since + event_stream deep-link replay + Last-Event-ID reconnect lossless),test_jobs_reconcile.py(3 β dead pid β failed, pid=NULL β failed, no-orphan no-op),test_no_unlogged_api_calls.py(2 β global LLM-SDK-import gate per BACKEND_BUILD.md Β§11.2 + ALLOWED_FILES existence guard). Stage 4 gate verified live on :8011: 202 with jobId/sseUrl no runId β full event sequence stage(classify)βprogressβstage(retrieve)βprogressβstage(generate)βprogressβdone with costSoFarUsd on every progress and totalCostUsd matching to the cent in done; Last-Event-ID replay yielded exactly the missed events;GET /query/runs/16/eventsproxied via jobs.subject_id and replayed full history. Cost gate (load-bearing for paid quota): pre-query MAX(llm_calls.id)=2488, post-query new rows = exactly [2489 classify $0.005831, 2490 generation $0.022569], SUM=$0.0284 = done.totalCostUsd to the cent. 6 new OpenAPI paths (/jobs,/jobs/{jobId},/jobs/{jobId}/cancel,/jobs/{jobId}/events,/query,/query/runs/{runId}/events), 28 paths total. Discovered + fixed a Turso transient stream-404 issue: the libsql adapter raisesValueError("api error: status=404 Not Found, body=...stream not found...")after long-idle gaps; the first emit_event of a query was silently dropped becausesrc.stage8_router.router.answer's on_progress wrapper swallows callback exceptions. Hardened by wrapping every store function in_with_retry()that retries once on Hrana stream-not-found errors. After fix, 3 back-to-back live queries each emitted the full event sequence with zero drops. Threading vs subprocess: query jobs run in-thread (3-10s I/O-bound; shared SQLite handle helps); subprocess + psutil tree-kill stays as the right call for the multi-hour Stage 5 ingest. Streamlit still boots cleanly. Commits onrewrite/api:4f05f6f(schema migration),790bfe3(jobs subsystem + routers + DTOs + startup reconciler),1231244(tests + OpenAPI fix for event DTOs),9f3fe5d(Turso retry hardening). Next: Stage 5 (Jobs subsystem β ingest as subprocess +--stagesCLI flag). - 2026-05-17 (BE) β Stage 3 (Read-only routers) landed on
rewrite/api. New files:src/api/routers/{books,history,costs,tools,indexes,processing_log,labels,pdf}.pyandsrc/api/dto/{books,query,costs,tools,indexes,processing_log,labels,pdf_pages}.py. Tests:tests/test_{books,history,costs}.pyβ 27 tests, all pass; Stage 1+2's 14 tests still pass.src/api/errors.pyextended withBookNotFound(404 BOOK_NOT_FOUND withdetails.bookId),QueryRunNotFound(404 withdetails.runId),NotIndexed(409),BadRequest(400).src/api/app.pymounts all 8 new routers and installs aRequestValidationErrorhandler that re-shapes Pydantic 422s into the canonicalBAD_REQUESTenvelope. Wire surface: 22 OpenAPI paths (Stage 1+2 = 5, Stage 3 adds 17); 44 component schemas (was ~7). Path params use camelCase URL templates (/books/{bookId},/query/runs/{runId}) withPath(..., alias=...)keeping Python args snake_case. All DTOs inheritApiModelβ camelCase wire keys. Enums areenum.Enumβ real OpenAPI unions (BookStatusEnum, RouterModeEnum, CostGroupByEnum, etc.) so FE codegen produces TS unions.BookStatusCountsDTO.ocr_doneis the only explicit underscore-on-the-wire exemption β pinned to honour CONTRACT.md Β§10's literal-status-key shape. The global camelCase regression intest_costs.py:test_every_schema_property_is_camelcasesweeps every schema in the spec and asserts no other leaks. Cost-monitoring gate:MAX(llm_calls.id)before = 2481, after the full test + live verification = 2481. 0 new paid calls (Stage 3 only readsllm_calls). Admin gates verified: viewer β/processing-logβ 403 FORBIDDEN envelope; admin β 200. Streamlit boots cleanly. Commits onrewrite/api:f7bc2ce(DTOs + errors),116c119(routers + app wiring),19e89e6(tests). Next: Stage 4 (Query + SSE βjobs/subsystem,POST /queryreturning 202 jobId/sseUrl,GET /jobs/{id}/eventsSSE with replay-from-Last-Event-ID,progress.costSoFarUsdper CONTRACT.md Β§12.3). - 2026-05-17 (BE) β Stage 2 (Auth) landed on
rewrite/api. New files:src/api/auth/{__init__,sessions,router}.py,src/api/deps.py,src/api/middleware/{__init__,csrf}.py,src/api/dto/auth.py,tests/test_auth.py. Schema:sessionstable + 2 indexes added insideSCHEMA_SQL(CREATE TABLE IF NOT EXISTS β idempotent). Additive edits:src/lib/auth/repo.pygotfrom_session_token(token)+user_for_username(username);src/api/errors.pygotUnauthenticated(401, UNAUTHENTICATED) andForbidden(403, FORBIDDEN);src/api/app.pymounts the auth router and registers the CSRF middleware. Wire shape: cookiesession=<urlsafe(32)>; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000(30d), matching the row'sexpires_at. Verified live end-to-end on port 8011: bad creds β 401, good β 200 + Set-Cookie + MeResponse, /me works, POST /logout withoutX-Requested-Withβ 403 FORBIDDEN, with header β 204, post-logout /me β 401. Cost-monitoring gate: 0 newllm_callsrows during the full test cycle (auth path is pure SQLite + TOML; never imports an LLM SDK). camelCase regression test extended to cover the new DTOs. Streamlit still boots. Next: Stage 3 (Read-only routers β/books,/history,/costs,/tools,/indexes,/processing-log,/labels,/books/status-counts). - 2026-05-17 (BE) β Stage 0 + Stage 1 landed on
rewrite/api. Newsrc/api/package:app.py(FastAPI app + ApiError handler),config.py(port + git-sha + masked-config),errors.py(ApiError stub),dto/common.py(ApiModel base, ErrorResponse, JobStartResponse β alias_generator=to_camel applied),dto/system.py(HealthResponse, HealthCheck, VersionResponse),routers/system.py(GET /system/health,GET /system/version). Health probe wraps the existingsrc.lib.system.healthchecks for DB/Qdrant/API-keys and adds a Storage probe viasrc.lib.storage.get_storage().list_book_ids(). Stale-pricing check walkstools_registry.yaml; current state isstalePricing: []because every tool'scost_model.last_updatedis within 90 days. New testtests/test_system_health.py(5 tests, all pass) covers the OpenAPI shape, camelCase enforcement, the/healthshape, and secret-masking in/version.pyproject.tomlgainedfastapi,uvicorn[standard],python-multipart,psutil,httpx,pytest-asyncio. No paid-API calls made;llm_callsrow count unchanged (519 β 519). Streamlit smoke-imports unaffected. Next: Stage 2 (Auth β sessions table + login/logout/me + CSRF middleware). - 2026-05-17 (orchestrator) β CONTRACT.md v2 (Express proxy, cost monitoring Β§12), FRONTEND_BUILD.md updated for Vite+Express stack, BACKEND_BUILD.md Β§11 cost monitoring added. Stage 0 FE complete (per AI Studio); Stage 1 FE ready to start. BE branch not yet created (waiting on Streamlit indexing to finish).
- (future entries here)