16 KiB
Context
LerNotaFiscal is a FastAPI app that ingests Brazilian invoices (PDF/image), extracts supplier_name, purchase_date, total_paid via extract_with_ai (app/ai_extraction.py, OpenAI chat.completions.create with vision, JSON mode), with an OCR/heuristic fallback in ingestion.py when AI is disabled or fails. Persistence is raw sqlite3 (app/database.py): no ORM, no migrations framework — the SCHEMA string is executed via executescript() on every connection open, so new tables/columns must be added additively (CREATE TABLE IF NOT EXISTS, ALTER TABLE ... ADD COLUMN guarded by a PRAGMA table_info check, since SQLite's ADD COLUMN IF NOT EXISTS isn't available in older syntax). The Dashboard (app/routes/dashboard_routes.py + app/templates/dashboard.html) currently shows KPIs, a monthly bar chart, and a top-8 supplier bar chart, with no filter controls; filtering by supplier/date exists only on the documents list page (documents_routes.py, via list_fiscal(conn, start, end, supplier)). There is currently no rate-limiting, retry, or caching layer anywhere in the codebase — AI safety today is a bare try/except around the extraction call.
This change adds category classification to fiscal documents, with AI as the primary classifier and a user-maintained keyword table as deterministic fallback, plus safety limits so the new AI call path cannot loop or blow up token spend.
Goals / Non-Goals
Goals:
- Persist a category per fiscal document, derived automatically (AI first, keyword fallback second, reserved "Não Encontrado" category otherwise — never left unclassified for documents that go through the flow).
- Let the categorization result be visible and filterable on the Dashboard.
- Guarantee the AI categorization call can run at most once per document per ingestion, with a supplier-level cache and a per-batch call ceiling, so it can never loop or run away on cost.
- Keep the
categoriatable simple and editable (id, categoria, palavra_chave) so non-technical users can extend keyword matching without code changes.
Non-Goals:
- No multi-category-per-document (one category per fiscal document in this change).
- No retraining/fine-tuning of the AI model; categorization uses the existing OpenAI client with a prompt, not a separate ML pipeline.
- No UI for bulk re-categorization of historical documents (existing documents predating this change simply show "Sem categoria" (
categoria_idstillNULL) until reprocessed; a manual "re-run categorization" action is out of scope unless trivial to add in tasks). - No category hierarchy/subcategories.
Decisions
1. Categorization is a separate step after extraction, not baked into extract_with_ai's prompt.
Rationale: extract_with_ai already has a fixed JSON schema for fornecedor/data/valor and is also exercised by the OCR-fallback path (where there is no AI call at all). Categorization needs its own safety limits (cache, per-batch ceiling) that are orthogonal to extraction, so it's cleaner as a new module app/categorization.py with a function like categorize_supplier(supplier_name: str, conn, batch_id) -> int, called from ingestion.py right before/at the same time fiscal_documents is created. The function always returns a valid categoria.id — a real category, or 1 ("Não Encontrado") when AI and keyword matching both fail — never None. Alternative considered: extend the extraction prompt to also return a category — rejected because it would apply AI categorization even when the caller only wants OCR fallback, and would entangle unrelated retry/cache logic with extraction.
2. categoria_id lives on fiscal_documents (nullable FK), not a separate join table.
Rationale: one category per document is sufficient (see Non-Goals); a nullable integer FK matches the existing schema style (INTEGER PRIMARY KEY AUTOINCREMENT, snake_case). Added via ALTER TABLE fiscal_documents ADD COLUMN categoria_id INTEGER REFERENCES categoria(id), guarded by a PRAGMA table_info(fiscal_documents) check before running the ALTER, consistent with the additive-schema pattern already used in database.py. detected_documents is left unchanged since categorization only needs to run once a document is confirmed into fiscal_documents. The column stays nullable at the schema level purely so pre-existing rows (created before this migration) don't need a backfill — the categorization flow itself never writes NULL; it always writes either a real category id or the reserved 1 (see Decision 3).
3. Reserved row categoria.id = 1 = "Não Encontrado", auto-ensured, undeletable.
Rationale: the user will populate the categoria table with their own categories/keywords, but the categorization flow needs a guaranteed, stable target to write to when AI and keyword matching both fail — it cannot depend on the user having remembered to seed anything. So the schema initialization step (the same executescript/setup path that creates the categoria table) also runs an idempotent INSERT OR IGNORE INTO categoria (id, categoria, palavra_chave) VALUES (1, 'Não Encontrado', ''), guaranteeing the row exists on first run without ever overwriting it if the user has since edited its categoria/palavra_chave values. delete_categoria rejects deletion when categoria_id == 1 (checked before running the delete). Alternative considered: have the app raise/500 if row 1 is missing and require the user to seed it manually first — rejected as too fragile; a one-line idempotent insert removes an entire class of "why isn't categorization working" support issues at negligible cost.
4. AI categorization is a lightweight, separate OpenAI call scoped to just the supplier name.
Rationale: sending only supplier_name (plus the list of known category names as allowed options, sourced from distinct categoria.categoria values already in the table) keeps the prompt tiny and cheap compared to the vision-based extraction call, and lets us validate the AI's answer against a closed set of categories. If the AI returns a value outside that set (or the call fails), it's treated as "no usable category" and the flow falls to keyword fallback. Alternative considered: let the AI invent free-text categories — rejected, since ungoverned category proliferation would break the keyword table concept and the Dashboard grouping.
5. Keyword fallback matching is case-insensitive substring match of palavra_chave in supplier_name, first match wins, excluding the reserved row.
Rationale: simplest possible rule that a non-technical user can reason about when populating the categoria table; matches the existing LIKE-based supplier filter style already in list_fiscal. Row order (id ASC, id != 1) determines precedence when multiple keywords could match — documented so admins can order entries if needed. Important: find_categoria_by_keyword must exclude id = 1 from its search. Since the reserved row's palavra_chave is normally an empty string, and an empty string is a substring of every value, including it in the ordered scan would make it match first (lowest id) on every call and defeat all real keyword matching. id = 1 is only ever reached as the final, explicit fallback in categorize_supplier when find_categoria_by_keyword (over id != 1) returns no match — never as a keyword-table hit itself. Alternative considered: token-based/fuzzy matching — deferred as unnecessary complexity for v1.
6. Safety limits are implemented as a small in-process module, not an external rate-limiter.
Rationale: the app has no existing rate-limiting infrastructure and volume is modest (single-user/small-team invoice processing), so a simple in-memory cache dict (supplier_name -> categoria_id) plus a per-import-batch counter (reset at the start of each import_batches row) is sufficient and avoids new dependencies. Concretely:
- No retry: the categorization call wraps a single
try/except, mirroringextract_with_ai's existing pattern — on any exception, treat as "no category from AI" and continue. - Supplier cache: keyed by normalized (
strip().upper()) supplier name, populated the first time a supplier is categorized (whether by AI, keyword, or the1/"Não Encontrado" fallback) within the process lifetime; on cache hit, skip the AI call entirely and reuse the cachedcategoria_id. This is an in-memory dict for this change (module-level), acceptable since it degrades gracefully (worst case: re-categorize on process restart, still bounded by the per-batch limit). - Per-batch ceiling: a configurable
CATEGORIZATION_MAX_AI_CALLS_PER_BATCH(default e.g. 50) inapp/config.py; a counter tied to the currentimport_batches.idis incremented per AI call and checked before each call; once reached, remaining documents in that batch use keyword fallback (then1/"Não Encontrado" if no keyword matches) with no further AI calls. - Logging: each skip (cache hit or limit reached) logs at INFO/DEBUG with supplier name and reason, using the existing logging setup. Alternative considered: persisting the cache in a DB table — deferred; in-memory is enough to satisfy "no infinite loop / no runaway cost" and keeps the change additive and low-risk.
7. Dashboard category breakdown reuses the existing chart/query pattern (supplier_totals-style function), plus a new category query param alongside start/end.
Rationale: dashboard_routes.py already computes monthly_totals and supplier_totals from fiscal_documents; a new category_totals(conn, start, end) follows the same shape, and threading an optional category filter through the existing query functions (list_fiscal, monthly_totals, supplier_totals, new category_totals) is consistent with how supplier filtering already works. The category filter control is added to the Dashboard template, populated from list_categorias (which includes "Não Encontrado") plus a separate "Sem categoria" option for any legacy categoria_id IS NULL rows.
8. Categoria CRUD admin UI mirrors the existing "Documentos" list/form pattern exactly — new files, same conventions, no new UI framework.
Rationale: the project already has an established, working pattern for list + create/edit + delete pages (documents_list.html + document_form.html, driven by documents_routes.py), all server-rendered Jinja2, plain HTML forms (POST-only, no JS/AJAX), CSRF via a hidden csrf_token field checked with auth.check_csrf, and a shared stylesheet (app/static/styles.css, classes like card, table, table-scroll, page-head, btn btn-primary/ghost/sm, linkbtn danger, field, filters). Reusing it exactly (rather than introducing a component library, modal dialogs, or AJAX) keeps the new admin screens visually and behaviorally indistinguishable from the rest of the app. Concretely:
- New router
app/routes/categoria_routes.py(router = APIRouter()), imported and registered inapp/main.pyalongside the existing routers, following the sameGET/POSTroute-pair convention used for documents:GET /categorias— list (reusescard/table/table-scrollmarkup)GET /categorias/new,POST /categorias/new— create form + handlerGET /categorias/{categoria_id}/edit,POST /categorias/{categoria_id}/edit— edit form + handlerPOST /categorias/{categoria_id}/delete— delete (inline per-row form withonsubmit="return confirm(...)"and hiddencsrf_token, exactly like the documents list's delete action)
- New templates
app/templates/categorias_list.htmlandapp/templates/categoria_form.html, both{% extends "base.html" %}, the form template reused for both create and edit via amodevariable ("new"/"edit") exactly asdocument_form.htmldoes. app/database.pygainscreate_categoria(conn, categoria, palavra_chave),get_categoria(conn, categoria_id),update_categoria(conn, categoria_id, categoria, palavra_chave),delete_categoria(conn, categoria_id), alongside the already-plannedlist_categorias/find_categoria_by_keyword, matching the naming style ofcreate_fiscal/update_fiscal.- A new "Categorias" link is added to the nav bar in
base.html(<nav class="nav">), alongside "Dashboard"/"Documentos", using the same active-linkrequest.url.path.startswith(...)pattern. delete_categoriafirst checkscategoria_id == 1and rejects the delete (flash error, no DB change) per Decision 3's reserved-row protection. Otherwise, deleting acategoriathat is referenced by existingfiscal_documents.categoria_idmust not orphan those rows or raise a foreign-key error:delete_categoriaexplicitly runsUPDATE fiscal_documents SET categoria_id = 1 WHERE categoria_id = ?(reassigning affected documents to "Não Encontrado") before deleting thecategoriarow, in the same transaction, rather than relying onON DELETE SET NULL— SQLite foreign key enforcement (PRAGMA foreign_keys) is off by default and this codebase does not currently enable it, so the app must enforce this itself. Alternative considered: leave orphaned documents'categoria_idasNULLon delete instead of reassigning to1— rejected for consistency:1("Não Encontrado") is now the single canonical "not properly classified" bucket for anything the categorization flow processes or re-processes, whileNULLis reserved strictly for legacy pre-migration rows never touched by this flow.
Risks / Trade-offs
- [Risk] In-memory supplier cache and per-batch counter are lost on process restart (e.g., app redeploy mid-batch) → could allow a few extra AI calls right after restart. Mitigation: the per-batch ceiling is intentionally conservative (default well below any reasonable per-import volume), and the ceiling still applies from the moment the process restarts, bounding worst-case cost; a persistent cache can be added later if needed.
- [Risk] AI-suggested categories drifting from the closed set (typos, casing) could be silently rejected, causing over-reliance on keyword fallback. Mitigation: normalize AI output (trim/case-fold) before validating against known categories; log rejected AI suggestions so gaps in the
categoriatable are visible for the admin to fix. - [Risk] Existing fiscal documents (created before this change) will show "Sem categoria" (
categoria_idNULL) until reprocessed, distinct from "Não Encontrado" (categoria_id = 1) used for documents the flow actively tried and failed to classify. Mitigation: acceptable for this change (Non-Goal: no bulk re-categorization); documented as an intentional distinction so it isn't mistaken for a bug; can be revisited if users need a backfill. - [Risk] If the operator edits the reserved row's
categoria/palavra_chavevalues (e.g. changespalavra_chaveto something non-empty), it could start matching real suppliers via the keyword fallback path, diluting its meaning as a pure "nothing matched" bucket. Mitigation: document thatid = 1is reserved for "no match" semantics and itspalavra_chaveshould normally stay empty; the app does not enforce this beyond the initial seed since the user is expected to manage the table's content. - [Trade-off] Choosing "first keyword match wins" instead of "most specific match wins" is simpler but could misclassify if keywords overlap (e.g., "MERCADO" matching both a generic and specific entry). Documented as a known limitation; admins should keep keywords reasonably distinct.
Migration Plan
- Add
categoriatable (with the idempotentid = 1"Não Encontrado" seed insert) andfiscal_documents.categoria_idcolumn via additiveexecutescript/ALTER TABLEinapp/database.py(guarded by existence checks so it's safe to run against existing databases). - Ship
app/categorization.pywith the AI-then-keyword-then-none flow and safety limits, wired intoingestion.pyat document confirmation time. - Update
dashboard_routes.py/dashboard.htmlto add category totals + filter. - Ship
app/routes/categoria_routes.py+categorias_list.html/categoria_form.htmlfor CRUD admin management of thecategoriatable, registered inapp/main.pyand linked frombase.html's nav. - Deploy is a normal code + schema update; no data backfill required (existing rows simply have
categoria_id = NULL). - Rollback: revert code; the added column/table can remain harmless if rolled back (nullable, unused), or be dropped manually if desired.
Open Questions
- Should users be able to manually override/edit a document's category from the documents list UI in this change, or is that a follow-up? (Assumed follow-up unless trivial.)