The application
The API layer
Put FastAPI endpoints around the pipeline and persist documents in SQLite with a routes, service, repository split.
By the end of this stage the pipeline is reachable over HTTP. A FastAPI app wraps it in endpoints, every processed document lands in a SQLite database, and Swagger gives you a browser view of the whole backend before any frontend exists.
daveebbelaar/invoice-review1:40:20API before UI
The trigger for this stage is honest. Dave sits down to build the frontend and realizes there is no way for it to talk to the backend. The pipeline only runs from playground scripts. So the API layer comes first, and that ordering is a feature. Endpoints are testable in the browser, which means the backend is fully proven before a single React component exists. As Dave puts it later, "endpoints are the doors to our backend."
The app factory
def create_app() -> FastAPI:
config = APP_CONFIG
config.upload_dir.mkdir(parents=True, exist_ok=True)
engine, session_factory = build_database(config.database_url)
DocumentRecord.metadata.create_all(engine)
app = FastAPI(title="Invoice Review API", version="0.1.0")
app.state.config = config
app.state.engine = engine
app.state.session_factory = session_factory
app.include_router(document_router)
app.include_router(accounting_router)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
return appcreate_app is a factory, so uvicorn runs it with --factory:
cd backend
uv run uvicorn app.main:create_app --factory --reloadSQLite appears
Persistence is the review history requirement from the brief, and SQLite keeps it simple: "just a database that you store on your file system," no server to run. The setup is two functions:
def build_database(database_url: str) -> tuple[Engine, sessionmaker]:
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
engine = create_engine(database_url, connect_args=connect_args)
return engine, sessionmaker(bind=engine, expire_on_commit=False)The database lives at backend/data/documents.db, uploads at backend/data/uploads under UUID filenames. One table carries the product. DocumentRecord in backend/app/documents/models.py stores each pipeline stage as its own JSON column, classification, extraction, validation, gl_suggestion, plus the working review_data and issues, so you can open the database and see exactly what every stage produced for every document. Two normalized columns, vendor name and invoice number, feed an index that powers duplicate detection.
Routes, service, repository
The HTTP layer keeps the layering the architecture page promised:
backend/app/documents/routes.pyspeaks HTTP: request parsing, status codes, the 4 MB upload limit, and the allowed content types (PDF, JPEG, PNG)backend/app/documents/service.pyorchestrates: it writes the upload, runs the pipeline with a duplicate checker wired to the repository, and translates failures into typed errors that routes map to 422 or 502backend/app/documents/repository.pytouches the database, nothing else does
The full surface, documented in the repo's docs/api-and-pipeline.md:
| Method | Path | Purpose |
|---|---|---|
POST | /api/documents | Upload a document and run the full pipeline |
GET | /api/documents | List saved reviews, newest first |
GET | /api/documents/{id} | Fetch one review |
GET | /api/documents/{id}/file | Serve the stored upload |
PUT | /api/documents/{id} | Apply field corrections and revalidate |
PUT | /api/documents/{id}/accounting | Confirm or override the GL account |
POST | /api/documents/{id}/decision | Approve or reject |
POST | /api/documents/{id}/correction-email | Draft a supplier correction email |
DELETE | /api/documents/{id} | Delete a review and its file |
GET | /api/accounting/gl-accounts | The fixed GL catalog |
GET | /health | Liveness check |
Open localhost:8000/docs and FastAPI's generated Swagger UI lists all of it, with a Try it out button on each endpoint. Uploading a sample there runs the entire pipeline and returns the stored document, no frontend required.
The rename
Midway through, Dave notices the upload endpoint is called /invoices even though receipts flow through it too. Rather than live with the lie, he has the agent rename the resource to /documents across the whole codebase, backend and frontend types alike. A small moment with a real lesson. Endpoint names are the domain vocabulary, and "even with planning, we're taking very short steps. AI just fills in the blanks."
Checkpoint
uv run uvicorn app.main:create_app --factory --reloadstarts and/healthreturns{"status": "ok"}- Uploading a sample through Swagger processes it end to end and returns issues plus a GL suggestion
backend/data/documents.dbexists andGET /api/documentslists the processed document