Datalumina

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:20

API 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

backend/app/main.py
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 app

create_app is a factory, so uvicorn runs it with --factory:

cd backend
uv run uvicorn app.main:create_app --factory --reload

SQLite 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:

backend/app/database.py
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.py speaks HTTP: request parsing, status codes, the 4 MB upload limit, and the allowed content types (PDF, JPEG, PNG)
  • backend/app/documents/service.py orchestrates: 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 502
  • backend/app/documents/repository.py touches the database, nothing else does

The full surface, documented in the repo's docs/api-and-pipeline.md:

MethodPathPurpose
POST/api/documentsUpload a document and run the full pipeline
GET/api/documentsList saved reviews, newest first
GET/api/documents/{id}Fetch one review
GET/api/documents/{id}/fileServe the stored upload
PUT/api/documents/{id}Apply field corrections and revalidate
PUT/api/documents/{id}/accountingConfirm or override the GL account
POST/api/documents/{id}/decisionApprove or reject
POST/api/documents/{id}/correction-emailDraft a supplier correction email
DELETE/api/documents/{id}Delete a review and its file
GET/api/accounting/gl-accountsThe fixed GL catalog
GET/healthLiveness 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 --reload starts and /health returns {"status": "ok"}
  • Uploading a sample through Swagger processes it end to end and returns issues plus a GL suggestion
  • backend/data/documents.db exists and GET /api/documents lists the processed document

On this page