Files
LerNota/openspec/changes/categoria/tasks.md
T

7.9 KiB

1. Schema: tabela categoria e coluna categoria_id

  • 1.1 Adicionar categoria (id INTEGER PRIMARY KEY AUTOINCREMENT, categoria TEXT NOT NULL, palavra_chave TEXT NOT NULL) ao SCHEMA/executescript em app/database.py (CREATE TABLE IF NOT EXISTS)
  • 1.2 Adicionar coluna categoria_id INTEGER REFERENCES categoria(id) (nullable) em fiscal_documents, com checagem via PRAGMA table_info(fiscal_documents) antes do ALTER TABLE para não falhar em bancos já existentes
  • 1.3 Adicionar insert idempotente INSERT OR IGNORE INTO categoria (id, categoria, palavra_chave) VALUES (1, 'Não Encontrado', '') no mesmo passo de inicialização do schema, garantindo que a linha reservada id = 1 sempre exista sem nunca sobrescrever edições feitas pelo usuário
  • 1.4 Adicionar helpers em app/database.py: create_categoria(conn, categoria, palavra_chave), get_categoria(conn, categoria_id), list_categorias(conn), update_categoria(conn, categoria_id, categoria, palavra_chave), delete_categoria(conn, categoria_id) (deve rejeitar categoria_id == 1 sem alterar nada; caso contrário, fazer UPDATE fiscal_documents SET categoria_id = 1 WHERE categoria_id = ? antes de excluir a linha, na mesma transação), find_categoria_by_keyword(conn, supplier_name) (busca case-insensitive de palavra_chave como substring de supplier_name, excluindo id = 1, ordenado por id ASC, retorna a primeira correspondência — importante: palavra_chave vazia da linha reservada é substring de qualquer texto, então incluí-la quebraria o casamento de palavras-chave reais)

2. Módulo de categorização com limites de segurança

  • 2.1 Criar app/categorization.py com função categorize_supplier(supplier_name: str, conn, batch_id) -> int (sempre retorna um categoria.id válido, nunca None) implementando a ordem: cache em memória → limite por lote → IA → palavra-chave → 1 ("Não Encontrado")
  • 2.2 Implementar cache em memória por fornecedor normalizado (strip().upper()), populado após qualquer categorização (IA, palavra-chave, ou 1 quando nada é encontrado), consultado antes de qualquer chamada de IA
  • 2.3 Implementar contador de chamadas de IA por import_batches.id, comparado a settings.CATEGORIZATION_MAX_AI_CALLS_PER_BATCH; ao atingir o limite, pular direto para o fallback de palavra-chave (e depois 1 se nada corresponder)
  • 2.4 Implementar chamada de IA dedicada (OpenAI chat.completions.create, prompt curto com supplier_name + lista de categorias já existentes na tabela categoria), validando que a resposta pertence ao conjunto conhecido; qualquer exceção ou valor fora do conjunto é tratado como "sem categoria da IA" (sem retry)
  • 2.5 Adicionar CATEGORIZATION_MAX_AI_CALLS_PER_BATCH (default configurável) em app/config.py
  • 2.6 Adicionar logs (INFO/DEBUG) quando uma chamada de IA é pulada por cache hit ou por limite de lote atingido, incluindo fornecedor e motivo

3. Integração no fluxo de ingestão

  • 3.1 Chamar categorize_supplier no momento em que o fiscal_documents é criado/confirmado, persistindo o categoria_id retornado (sempre um valor válido, no mínimo 1) — implementado em database.py::confirm_batch (não em ingestion.py: a criação/confirmação de fiscal_documents acontece em database.py, ingestion.py só extrai)
  • 3.2 Garantir que falha na categorização (qualquer exceção não tratada dentro do módulo) não impede a criação do fiscal_documents — a nota deve ser salva com categoria_id = 1 ("Não Encontrado") nesse caso

4. Dashboard: exibição e filtro por categoria

  • 4.1 Criar função category_totals(conn, start, end) em app/database.py (mesmo padrão de monthly_totals/supplier_totals), agrupando por categoria.categoria (via LEFT JOIN, incluindo a linha id = 1 "Não Encontrado") e um grupo separado "Sem categoria" apenas para categoria_id IS NULL (documentos legados não reprocessados)
  • 4.2 Adicionar parâmetro opcional category em dashboard_routes.py, propagando o filtro para monthly_totals, supplier_totals, category_totals e demais consultas da página
  • 4.3 Atualizar app/templates/dashboard.html com um gráfico/lista de gastos por categoria e um controle de filtro (select) populado a partir de list_categorias (inclui "Não Encontrado") + opção "Sem categoria" para os registros legados
  • 4.4 Validado (sem extensão de navegador disponível na sessão: verificado via requisições HTTP autenticadas + inspeção do HTML renderizado, e via chamadas diretas às funções de banco simulando um lote confirmado) — Dashboard exibe totais por categoria e o filtro reduz corretamente KPIs/gráficos/lista de documentos recentes

5. CRUD administrativo da tabela categoria

  • 5.1 Criar app/routes/categoria_routes.py com router = APIRouter() e as rotas GET /categorias, GET/POST /categorias/new, GET/POST /categorias/{categoria_id}/edit, POST /categorias/{categoria_id}/delete, seguindo exatamente o padrão de documents_routes.py (uso de render(), flash(), auth.check_csrf)
  • 5.2 Registrar categoria_routes em app/main.py (import + app.include_router(categoria_routes.router)), no mesmo bloco onde os demais routers são registrados
  • 5.3 Criar app/templates/categorias_list.html ({% extends "base.html" %}) reaproveitando o layout de documents_list.html: .page-head com botão "+ Nova", tabela (card/table/table-scroll) listando id, categoria, palavra_chave, e ações de editar/excluir por linha (form inline com onsubmit="return confirm(...)" e csrf_token oculto, igual ao padrão de exclusão de documentos); a linha id = 1 ("Não Encontrado") mostra a ação de excluir desabilitada/oculta, já que a exclusão é sempre rejeitada
  • 5.4 Criar app/templates/categoria_form.html ({% extends "base.html" %}) reaproveitando o layout de document_form.html, com variável mode ("new"/"edit") controlando a action do form entre /categorias/new e /categorias/{id}/edit
  • 5.5 Adicionar link "Categorias" na <nav class="nav"> de app/templates/base.html, com a mesma lógica de classe active (request.url.path.startswith('/categorias')) usada pelos demais links
  • 5.6 Validado (sem extensão de navegador disponível na sessão: driver via requisições HTTP autenticadas contra o servidor real, cookies de sessão + CSRF) o fluxo completo: criar, listar, editar e excluir uma categoria; confirmado que excluir a categoria id = 1 é rejeitado, e que excluir uma categoria em uso reatribui os documentos relacionados para "Não Encontrado" sem erro

6. Testes e validação

  • 6.1 Testes unitários para find_categoria_by_keyword (match, no match, case-insensitive, múltiplos candidatos → primeiro por id, e confirmando que id = 1 nunca é retornado por essa função mesmo com palavra_chave vazia)
  • 6.2 Testes unitários para categorize_supplier cobrindo: cache hit (sem chamada de IA), limite de lote atingido (sem chamada de IA), IA retorna categoria válida, IA falha/retorna inválido → fallback por palavra-chave, nenhum método encontra categoria → retorna 1
  • 6.3 Teste de integração do fluxo de ingestão ponta a ponta: nota processada resulta em fiscal_documents.categoria_id correto nos cenários de IA, fallback por palavra-chave e "Não Encontrado" (1)
  • 6.4 Testes unitários/integração para o CRUD de categoria: criar, ler, atualizar, excluir (incluindo excluir categoria referenciada por fiscal_documents → reatribuição para 1, e tentar excluir id = 1 → rejeitado)
  • 6.5 Automatizado como equivalente ao teste manual (test_repeated_suppliers_call_ai_once_each_and_respect_batch_limit em tests/test_categorization.py, usando mock + assertLogs): lote com fornecedores repetidos confirma que a IA é chamada apenas uma vez por fornecedor distinto e que o limite por lote é respeitado