diff --git a/.env.example b/.env.example index 81d7e95..b3726c5 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,8 @@ BACKEND_CORS_ORIGINS=* DATABASE_URL=sqlite+aiosqlite:///./juris_sync.db # DATABASE_URL=postgresql+asyncpg://postgres:postgrespassword@localhost:5432/jurissync_prod -# Segurança e Autenticação (JWT) +# Segurança (JWT reservado - sem login nesta versão de portfólio) +# Não há endpoints de autenticação. Estes campos existem só para evolução futura. SECRET_KEY=change-me-in-production ACCESS_TOKEN_EXPIRE_MINUTES=60 @@ -22,8 +23,9 @@ ACCESS_TOKEN_EXPIRE_MINUTES=60 DATAJUD_API_KEY= DATAJUD_API_URL=https://api-publica.datajud.cnj.jus.br -# RAG opcional (enriquecimento local; LLM só se OPENAI_API_KEY estiver definida) -RAG_TOP_K=3 +# Enriquecimento local (glossário em memória; não é RAG de produção) +# LLM opcional: só polimento de campos se OPENAI_API_KEY estiver definida +ENRICHMENT_TOP_K=3 OPENAI_API_KEY= OPENAI_BASE_URL=https://api.openai.com/v1 OPENAI_MODEL=gpt-4o-mini diff --git a/README.md b/README.md index 90960bc..91621fd 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/MariaHilmar/juris-sync/actions/workflows/ci.yml/badge.svg)](https://github.com/MariaHilmar/juris-sync/actions/workflows/ci.yml) ![Python](https://img.shields.io/badge/Python-3.12+-blue.svg) ![FastAPI](https://img.shields.io/badge/FastAPI-0.110+-009688.svg) -![Coverage](https://img.shields.io/badge/coverage-89%25-brightgreen.svg) +![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen.svg) API REST assíncrona para **monitoramento, ingestão e jurimetria** de processos judiciais, integrada à [API Pública do DataJud (CNJ)](https://datajud-wiki.cnj.jus.br/api-publica/). @@ -56,10 +56,11 @@ Guia completo para testadores: [juris-sync-web/docs/guia-do-testador.md](https:/ - **FastAPI + SQLAlchemy 2.0 async** - I/O não bloqueante com `asyncpg` / `aiosqlite` - **Motor DataJud** - integração real com API do CNJ + mock determinístico para desenvolvimento sem credenciais - **Sincronização idempotente** - evita duplicar processos e movimentações -- **RAG em memória** - normalização de classe, assunto e tribunal antes da validação Pydantic +- **Enriquecimento por glossário** - normaliza classe, assunto e tribunal em memória (não é RAG de produção) - **Alembic** - versionamento de schema (`processos`, `movimentacoes`) - **Structlog** - logs legíveis em dev, JSON em produção -- **43 testes em 5 camadas** - unitário, API (ASGI), mock HTTP (`respx`), reconciliação de sync, integração (Testcontainers) e contrato OpenAPI (Schemathesis) - cobertura ≥ 85% +- **Testes em 5 camadas** - unitário, API (ASGI), mock HTTP (`respx`), reconciliação de sync, integração (Testcontainers) e contrato OpenAPI (Schemathesis) - cobertura ≥ 85% +- **ADRs** - decisões de mock, idempotência, pirâmide de testes e enrichment em [`docs/adr/`](docs/adr/) - **Documentação de requisitos** - regras de negócio, histórias de usuário, cenários BDD e rastreabilidade requisito → código → teste em [`docs/requisitos.md`](docs/requisitos.md) - **GitHub Actions** - lint (Ruff, Black, Mypy) + testes (unitário + integração + contrato) em cada push/PR @@ -75,7 +76,7 @@ graph TD Service --> ClientDJ[DataJudClient] ClientDJ -->|HTTPS + APIKey| DataJud[API Pública CNJ] ClientDJ -.->|fallback| Mock[Mock determinístico] - Service --> RAG[DataJudRAGEnricher] + Service --> Enrich[DataJudEnricher] Service --> DB[(PostgreSQL / SQLite)] ``` @@ -107,7 +108,7 @@ Em desenvolvimento, `BACKEND_CORS_ORIGINS=*` (padrão) já permite o frontend. E ### Fluxo de sincronização 1. **Extração** - `DataJudClient` consulta o tribunal correto (`api_publica_{alias}/_search`) ou gera mock a partir do CNJ -2. **Enriquecimento** - RAG recupera contexto jurídico e normaliza campos +2. **Enriquecimento** - glossário local canonicaliza classe, assunto e tribunal 3. **Validação** - Pydantic v2 valida formato CNJ e tipos 4. **Persistência** - upsert do processo + inserção apenas de movimentações novas @@ -247,12 +248,12 @@ alembic upgrade head ## 🧪 Testes Automatizados -![Tests](https://img.shields.io/badge/tests-43%20passing-brightgreen.svg) -![Coverage](https://img.shields.io/badge/coverage-89.9%25-brightgreen.svg) +![Tests](https://img.shields.io/badge/tests-44%20passing-brightgreen.svg) +![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen.svg) -O projeto conta com **43 testes automatizados** organizados em 5 camadas, cobrindo desde regras de negócio isoladas até fuzzing de contrato OpenAPI e integração com PostgreSQL real. +O projeto conta com uma suíte em **5 camadas** (44+ testes na suíte padrão), cobrindo desde regras de negócio isoladas até fuzzing de contrato OpenAPI e integração com PostgreSQL real. -- **Testes unitários** - validam o comportamento isolado de `sync_service`, `datajud_client`, RAG e schemas Pydantic. +- **Testes unitários** - validam o comportamento isolado de `sync_service`, `datajud_client`, enrichment e schemas Pydantic. - **Testes de API** - disparam requisições HTTP reais (via `httpx.AsyncClient` + `ASGITransport`) contra os endpoints FastAPI, com banco SQLite em memória isolado por teste. - **Mock de API externa** - intercepta a camada HTTP real com `respx`, validando o contrato exato da chamada ao DataJud (headers, payload) e os cenários de fallback (404, 500, timeout). - **Reconciliação de sincronização** - garante fidelidade dos dados persistidos vs. fonte externa, atomicidade em falhas parciais e ausência de movimentações órfãs. @@ -309,7 +310,7 @@ tests/test_api.py::test_api_detail_returns_404_for_not_found PASSED [ 15%] tests/test_api.py::test_api_jurimetria_stats_endpoints PASSED [ 18%] tests/test_datajud_client.py::test_mock_client_generates_consistent_data PASSED [ 21%] tests/test_datajud_client_contract.py::test_fetch_from_api_sends_expected_request_contract PASSED [ 46%] -tests/test_rag_enricher.py::test_rag_enricher_retrieves_context_and_normalizes_fields PASSED [ 59%] +tests/test_enricher.py::test_enricher_retrieves_context_and_normalizes_fields PASSED [ 59%] tests/test_sync_reconciliation.py::test_reconciliation_rolls_back_completely_on_partial_failure PASSED [ 84%] tests/test_sync_service.py::test_sync_new_movement_adds_only_the_new_one PASSED [100%] @@ -375,7 +376,7 @@ juris-sync/ │ ├── core/ # Config, database, logging │ ├── models/ # ORM SQLAlchemy │ ├── schemas/ # Pydantic -│ ├── services/ # DataJud, sync, RAG +│ ├── services/ # DataJud, sync, enrichment │ └── main.py ├── alembic/ # Migrações ├── tests/ # Pytest (unitário, mock, reconciliação, integração, contrato) diff --git a/app/api/errors.py b/app/api/errors.py new file mode 100644 index 0000000..5bdd959 --- /dev/null +++ b/app/api/errors.py @@ -0,0 +1,43 @@ +"""Mapeia falhas do pipeline de sync para status HTTP explícitos.""" + +from fastapi import HTTPException, status +from pydantic import ValidationError + +from app.services.datajud_client import ( + DataJudError, + DataJudNotFoundError, + DataJudTransientError, +) + + +def http_exception_from_sync_error(error: Exception) -> HTTPException: + """Converte exceção de domínio em HTTPException sem vazar stack ao cliente.""" + if isinstance(error, DataJudNotFoundError): + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Processo não encontrado na origem DataJud.", + ) + if isinstance(error, DataJudTransientError): + return HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="DataJud temporariamente indisponível. Tente novamente mais tarde.", + ) + if isinstance(error, DataJudError): + return HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Falha ao consultar o DataJud.", + ) + if isinstance(error, ValidationError): + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Dados do processo inválidos após normalização.", + ) + if isinstance(error, ValueError): + return HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(error) or "Requisição inválida para sincronização.", + ) + return HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Erro interno ao sincronizar processo. Tente novamente mais tarde.", + ) diff --git a/app/api/process.py b/app/api/process.py index bf6ea9b..f6f5ac8 100644 --- a/app/api/process.py +++ b/app/api/process.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.api.errors import http_exception_from_sync_error from app.core.database import get_db from app.models.process import Processo from app.schemas.process import ( @@ -15,6 +16,8 @@ ProcessoRead, ProcessoSyncRequest, ProcessoSyncResponse, + StatsAssuntoItem, + StatsTribunalItem, ) from app.services.sync_service import JurisSyncService @@ -29,8 +32,21 @@ summary="Sincronizar Processo com o DataJud", responses={ status.HTTP_400_BAD_REQUEST: { - "description": "Corpo da requisição não é um JSON válido." - } + "description": "Requisição inválida (ex.: tribunal não mapeado)." + }, + status.HTTP_404_NOT_FOUND: { + "description": "Processo não encontrado na origem DataJud." + }, + status.HTTP_422_UNPROCESSABLE_ENTITY: { + "description": "Número CNJ ou payload inválido." + }, + status.HTTP_502_BAD_GATEWAY: {"description": "Falha ao consultar o DataJud."}, + status.HTTP_503_SERVICE_UNAVAILABLE: { + "description": "DataJud temporariamente indisponível." + }, + status.HTTP_500_INTERNAL_SERVER_ERROR: { + "description": "Erro interno inesperado no pipeline de sincronização." + }, }, ) async def sincronizar_processo( @@ -46,7 +62,6 @@ async def sincronizar_processo( try: resultado = await service.sync_process(request.numero_cnj, request.grau) - # Converte o modelo do banco para o schema Pydantic de resposta processo_read = ProcessoRead.model_validate(resultado["processo"]) return ProcessoSyncResponse( @@ -54,17 +69,32 @@ async def sincronizar_processo( mensagem=resultado["mensagem"], processo=processo_read, movimentacoes_sincronizadas=resultado["movimentacoes_sincronizadas"], + contexto_enriquecimento=resultado.get("contexto_enriquecimento") or [], ) - except Exception as e: - # Loga o detalhe completo internamente, mas devolve uma mensagem - # genérica ao cliente para não vazar detalhes de implementação. + except HTTPException: + raise + except Exception as error: logger.error( - "api_sync_endpoint_failed", numero_cnj=request.numero_cnj, error=str(e) + "api_sync_endpoint_failed", + numero_cnj=request.numero_cnj, + error=str(error), + error_type=type(error).__name__, ) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Erro interno ao sincronizar processo. Tente novamente mais tarde.", - ) from e + raise http_exception_from_sync_error(error) from error + + +@router.api_route( + "/sync", + methods=["GET", "PUT", "PATCH", "DELETE"], + include_in_schema=False, +) +async def sincronizar_metodo_nao_permitido(): + """Evita que GET /processos/sync seja interpretado como UUID em /{process_id}.""" + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="Sincronização exige POST /api/v1/processos/sync com JSON {numero_cnj, grau}.", + headers={"Allow": "POST"}, + ) @router.get( @@ -127,7 +157,9 @@ async def listar_processos( @router.get( - "/stats/por-tribunal", summary="Jurimetria: Distribuição de Processos por Tribunal" + "/stats/por-tribunal", + response_model=list[StatsTribunalItem], + summary="Jurimetria: Distribuição de Processos por Tribunal", ) async def estatisticas_por_tribunal(db: AsyncSession = Depends(get_db)): """ @@ -148,7 +180,9 @@ async def estatisticas_por_tribunal(db: AsyncSession = Depends(get_db)): @router.get( - "/stats/por-assunto", summary="Jurimetria: Distribuição de Processos por Assunto" + "/stats/por-assunto", + response_model=list[StatsAssuntoItem], + summary="Jurimetria: Distribuição de Processos por Assunto", ) async def estatisticas_por_assunto(db: AsyncSession = Depends(get_db)): """ diff --git a/app/core/cnj.py b/app/core/cnj.py index 3eb947a..448e832 100644 --- a/app/core/cnj.py +++ b/app/core/cnj.py @@ -3,7 +3,7 @@ Centraliza o mapa de tribunais e a extração de segmentos do CNJ para evitar duplicação da mesma lógica de `split(".")` espalhada entre o cliente DataJud -e o enriquecedor RAG. +e o enriquecedor de glossário. """ # Mapa J.TR -> (sigla, nome completo, alias da API pública do DataJud). diff --git a/app/core/config.py b/app/core/config.py index dc467c5..e4fa503 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -16,13 +16,14 @@ class Settings(BaseSettings): # SQLite local por padrão; sobrescreva via .env para PostgreSQL em produção. DATABASE_URL: str = "sqlite+aiosqlite:///./juris_sync.db" + # Reservado para auth futura. Não há login/JWT nos endpoints (ver docs/adr). SECRET_KEY: str = "change-me-in-production" ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 DATAJUD_API_KEY: str = "" DATAJUD_API_URL: str = "https://api-publica.datajud.cnj.jus.br" - RAG_TOP_K: int = 3 + ENRICHMENT_TOP_K: int = 3 OPENAI_API_KEY: str = "" OPENAI_BASE_URL: str = "https://api.openai.com/v1" OPENAI_MODEL: str = "gpt-4o-mini" diff --git a/app/schemas/datajud.py b/app/schemas/datajud.py index f0fb310..186a6f9 100644 --- a/app/schemas/datajud.py +++ b/app/schemas/datajud.py @@ -31,9 +31,12 @@ class DataJudProcessoSchema(BaseModel): ) grau: int = Field(1, ge=1, le=3, description="Grau de jurisdição") movimentacoes: List[DataJudMovimentacaoSchema] = Field(default_factory=list) - contexto_rag: List[str] = Field( + contexto_enriquecimento: List[str] = Field( default_factory=list, - description="Trechos de conhecimento jurídico recuperados pelo RAG", + description=( + "Trechos do glossário local usados na normalização de classe, " + "assunto e tribunal (não é RAG de produção)" + ), ) @field_validator("numero_cnj") diff --git a/app/schemas/process.py b/app/schemas/process.py index 3b23b85..3e436d5 100644 --- a/app/schemas/process.py +++ b/app/schemas/process.py @@ -169,3 +169,17 @@ class ProcessoSyncResponse(BaseModel): mensagem: str processo: Optional[ProcessoRead] = None movimentacoes_sincronizadas: int = 0 + contexto_enriquecimento: List[str] = Field( + default_factory=list, + description="Trechos do glossário usados na normalização deste sync", + ) + + +class StatsTribunalItem(BaseModel): + tribunal: str = Field(..., description="Sigla do tribunal") + total_processos: int = Field(..., ge=0, description="Quantidade de processos") + + +class StatsAssuntoItem(BaseModel): + assunto: str = Field(..., description="Assunto jurídico normalizado") + total_processos: int = Field(..., ge=0, description="Quantidade de processos") diff --git a/app/services/enrichment/__init__.py b/app/services/enrichment/__init__.py new file mode 100644 index 0000000..f576e83 --- /dev/null +++ b/app/services/enrichment/__init__.py @@ -0,0 +1,10 @@ +"""Enriquecimento local de payload DataJud (não é RAG de produção). + +Normaliza classe, assunto e tribunal com um glossário em memória e +similaridade lexical. Não usa embeddings de modelo nem banco vetorial. +""" + +from app.services.enrichment.enricher import DataJudEnricher +from app.services.enrichment.glossary_index import InMemoryGlossaryIndex + +__all__ = ["DataJudEnricher", "InMemoryGlossaryIndex"] diff --git a/app/services/rag/enricher.py b/app/services/enrichment/enricher.py similarity index 83% rename from app/services/rag/enricher.py rename to app/services/enrichment/enricher.py index d2a9599..464a23e 100644 --- a/app/services/rag/enricher.py +++ b/app/services/enrichment/enricher.py @@ -1,3 +1,9 @@ +"""Normaliza payload DataJud com glossário local antes da validação Pydantic. + +Isto não é RAG de produção: não há embeddings de modelo, banco vetorial nem +geração de resposta. O LLM opcional só polimento de campos já normalizados. +""" + import json import re from datetime import datetime @@ -8,8 +14,8 @@ from app.core.cnj import tribunal_sigla_from_cnj from app.core.config import settings -from app.services.rag.knowledge_base import KnowledgeChunk -from app.services.rag.vector_store import InMemoryVectorStore +from app.services.enrichment.glossary_index import InMemoryGlossaryIndex +from app.services.enrichment.knowledge_base import KnowledgeChunk logger = structlog.get_logger() @@ -31,14 +37,11 @@ } -class DataJudRAGEnricher: - """ - Camada RAG que recupera contexto jurídico e enriquece dados brutos do DataJud - antes da validação estrita via Pydantic v2. - """ +class DataJudEnricher: + """Canonicaliza classe, assunto e tribunal com glossário em memória.""" - def __init__(self, vector_store: InMemoryVectorStore | None = None): - self.vector_store = vector_store or InMemoryVectorStore() + def __init__(self, glossary: InMemoryGlossaryIndex | None = None): + self.glossary = glossary or InMemoryGlossaryIndex() async def enrich( self, @@ -47,11 +50,11 @@ async def enrich( grau: int, ) -> dict[str, Any]: query = self._build_query(raw_data, numero_cnj) - retrieved = self.vector_store.search(query, top_k=settings.RAG_TOP_K) + retrieved = self.glossary.search(query, top_k=settings.ENRICHMENT_TOP_K) context_chunks = [chunk.texto for chunk, _ in retrieved] logger.info( - "rag_retrieval_completed", + "enrichment_glossary_matched", numero_cnj=numero_cnj, chunks_retrieved=len(context_chunks), top_score=retrieved[0][1] if retrieved else 0.0, @@ -63,7 +66,7 @@ async def enrich( if settings.OPENAI_API_KEY: enriched = await self._llm_refine(enriched, context_chunks) - enriched["contexto_rag"] = context_chunks + enriched["contexto_enriquecimento"] = context_chunks return enriched def _build_query(self, raw_data: dict[str, Any], numero_cnj: str) -> str: @@ -159,11 +162,12 @@ async def _llm_refine( payload: dict[str, Any], context_chunks: list[str], ) -> dict[str, Any]: + """Polimento opcional de campos. Falhas não interrompem o sync (RN09).""" prompt = { "contexto_juridico": context_chunks, "dados_brutos": payload, "instrucao": ( - "Normalize classe, assunto e tribunal com base no contexto jurídico. " + "Normalize classe, assunto e tribunal com base no glossário. " "Retorne apenas JSON compatível com o schema de processo." ), } @@ -181,7 +185,10 @@ async def _llm_refine( "messages": [ { "role": "system", - "content": "Você normaliza dados jurídicos brasileiros e responde somente em JSON.", + "content": ( + "Você normaliza dados jurídicos brasileiros " + "e responde somente em JSON." + ), }, { "role": "user", @@ -195,8 +202,8 @@ async def _llm_refine( content = response.json()["choices"][0]["message"]["content"] refined = json.loads(content) payload.update({k: v for k, v in refined.items() if k in payload}) - logger.info("rag_llm_refinement_applied") + logger.info("enrichment_llm_refinement_applied") except Exception as error: - logger.warning("rag_llm_refinement_skipped", error=str(error)) + logger.warning("enrichment_llm_refinement_skipped", error=str(error)) return payload diff --git a/app/services/rag/vector_store.py b/app/services/enrichment/glossary_index.py similarity index 71% rename from app/services/rag/vector_store.py rename to app/services/enrichment/glossary_index.py index d338fde..94c0338 100644 --- a/app/services/rag/vector_store.py +++ b/app/services/enrichment/glossary_index.py @@ -1,8 +1,14 @@ +"""Índice lexical em memória (frequência de termos + cosseno). + +Não é um vector store de embeddings. Serve só para ranquear trechos do +glossário local na normalização de campos. +""" + import math import re from collections import Counter -from app.services.rag.knowledge_base import LEGAL_KNOWLEDGE_BASE, KnowledgeChunk +from app.services.enrichment.knowledge_base import LEGAL_KNOWLEDGE_BASE, KnowledgeChunk def _tokenize(text: str) -> list[str]: @@ -10,7 +16,7 @@ def _tokenize(text: str) -> list[str]: return [token for token in tokens if len(token) > 2] -def _embed(text: str) -> dict[str, float]: +def _term_weights(text: str) -> dict[str, float]: tokens = _tokenize(text) if not tokens: return {} @@ -28,26 +34,26 @@ def _cosine_similarity(left: dict[str, float], right: dict[str, float]) -> float left_norm = math.sqrt(sum(value * value for value in left.values())) right_norm = math.sqrt(sum(value * value for value in right.values())) - if left_norm == 0.0 or right_norm == 0.0: + if left_norm < 1e-12 or right_norm < 1e-12: return 0.0 return dot_product / (left_norm * right_norm) -class InMemoryVectorStore: +class InMemoryGlossaryIndex: def __init__(self, chunks: list[KnowledgeChunk] | None = None): self._chunks = chunks or LEGAL_KNOWLEDGE_BASE - self._embeddings = { - chunk.id: _embed(" ".join([chunk.texto, *chunk.termos_chave])) + self._weights = { + chunk.id: _term_weights(" ".join([chunk.texto, *chunk.termos_chave])) for chunk in self._chunks } def search(self, query: str, top_k: int = 3) -> list[tuple[KnowledgeChunk, float]]: - query_embedding = _embed(query) + query_weights = _term_weights(query) scored: list[tuple[KnowledgeChunk, float]] = [] for chunk in self._chunks: - score = _cosine_similarity(query_embedding, self._embeddings[chunk.id]) + score = _cosine_similarity(query_weights, self._weights[chunk.id]) keyword_bonus = sum( 0.15 for term in chunk.termos_chave if term in query.lower() ) diff --git a/app/services/rag/knowledge_base.py b/app/services/enrichment/knowledge_base.py similarity index 95% rename from app/services/rag/knowledge_base.py rename to app/services/enrichment/knowledge_base.py index e7f8b89..f34d2e4 100644 --- a/app/services/rag/knowledge_base.py +++ b/app/services/enrichment/knowledge_base.py @@ -1,3 +1,8 @@ +"""Glossário jurídico em memória para canonicalizar classe, assunto e tribunal. + +Não é uma base RAG: são trechos curtos usados como dicionário de normalização. +""" + from dataclasses import dataclass diff --git a/app/services/rag/__init__.py b/app/services/rag/__init__.py deleted file mode 100644 index 86973b3..0000000 --- a/app/services/rag/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from app.services.rag.enricher import DataJudRAGEnricher -from app.services.rag.vector_store import InMemoryVectorStore - -__all__ = ["DataJudRAGEnricher", "InMemoryVectorStore"] diff --git a/app/services/sync_service.py b/app/services/sync_service.py index e79f7ba..12b5512 100644 --- a/app/services/sync_service.py +++ b/app/services/sync_service.py @@ -9,7 +9,7 @@ from app.models.process import Movimentacao, Processo from app.schemas.datajud import DataJudProcessoSchema from app.services.datajud_client import DataJudClient -from app.services.rag.enricher import DataJudRAGEnricher +from app.services.enrichment.enricher import DataJudEnricher logger = structlog.get_logger() @@ -31,24 +31,24 @@ def _movement_identity_key(data_hora: datetime, descricao: str) -> tuple[str, st class JurisSyncService: """ - Serviço central de Negócio responsável por orquestrar a busca de dados na API DataJud, - enriquecimento via RAG, validação Pydantic e persistência idempotente. + Serviço central de negócio: busca no DataJud, enriquecimento por glossário, + validação Pydantic e persistência idempotente. """ def __init__( self, db: AsyncSession, client: Optional[DataJudClient] = None, - rag_enricher: Optional[DataJudRAGEnricher] = None, + enricher: Optional[DataJudEnricher] = None, ): self.db = db self.client = client or DataJudClient() - self.rag_enricher = rag_enricher or DataJudRAGEnricher() + self.enricher = enricher or DataJudEnricher() async def sync_process(self, numero_cnj: str, grau: int = 1) -> dict[str, Any]: """ Sincroniza um processo judicial com base em seu número CNJ e grau de jurisdição. - Pipeline: DataJud -> RAG -> Pydantic v2 -> Persistência idempotente. + Pipeline: DataJud -> enriquecimento (glossário) -> Pydantic v2 -> persistência. Orquestra as etapas atômicas: em qualquer falha, faz rollback da transação e propaga o erro ao chamador. @@ -85,7 +85,7 @@ async def sync_process(self, numero_cnj: str, grau: int = 1) -> dict[str, Any]: ), "processo": processo, "movimentacoes_sincronizadas": new_movs_count, - "contexto_rag": validated.contexto_rag, + "contexto_enriquecimento": validated.contexto_enriquecimento, } except Exception as error: @@ -96,15 +96,15 @@ async def sync_process(self, numero_cnj: str, grau: int = 1) -> dict[str, Any]: async def _extrair_e_validar( self, numero_cnj: str, grau: int ) -> DataJudProcessoSchema: - """Extrai da origem externa, enriquece via RAG e valida com Pydantic v2.""" + """Extrai da origem, normaliza com glossário e valida com Pydantic v2.""" raw_data = await self.client.fetch_process_data(numero_cnj, grau) - enriched_data = await self.rag_enricher.enrich(raw_data, numero_cnj, grau) + enriched_data = await self.enricher.enrich(raw_data, numero_cnj, grau) validated = DataJudProcessoSchema.from_enriched(enriched_data) logger.info( "pydantic_validation_completed", numero_cnj=validated.numero_cnj, - rag_context_chunks=len(validated.contexto_rag), + enrichment_chunks=len(validated.contexto_enriquecimento), ) return validated diff --git a/docs/adr/001-mock-deterministico.md b/docs/adr/001-mock-deterministico.md new file mode 100644 index 0000000..4122c0f --- /dev/null +++ b/docs/adr/001-mock-deterministico.md @@ -0,0 +1,23 @@ +# ADR-001: Mock determinístico do DataJud + +## Status + +Aceito + +## Contexto + +A API Pública do DataJud exige chave. Quem avalia o portfólio (e o CI) precisa +clonar e rodar sem credencial do CNJ. Dados inventados não podem variar a cada +execução, senão testes de jurimetria e reconciliação ficam flaky. + +## Decisão + +Sem `DATAJUD_API_KEY`, o cliente gera payload a partir do próprio número CNJ +(`random.Random(numero_cnj)`). Em `ENV=production`, falha real **não** cai para +mock: propaga o erro. + +## Consequências + +- Demo local e CI não dependem do CNJ. +- O mesmo CNJ sempre produz a mesma classe/assunto/movimentações-base. +- Em produção, 404/timeout do DataJud aparecem como erro HTTP, não como processo fictício. diff --git a/docs/adr/002-idempotencia-sync.md b/docs/adr/002-idempotencia-sync.md new file mode 100644 index 0000000..dedf1fd --- /dev/null +++ b/docs/adr/002-idempotencia-sync.md @@ -0,0 +1,23 @@ +# ADR-002: Idempotência incremental no sync + +## Status + +Aceito + +## Contexto + +Re-sincronizar o mesmo processo é o caminho feliz (atualizar andamentos). +Inserir de novo o processo ou as movimentações já vistas quebraria jurimetria +e a confiança no histórico local. + +## Decisão + +- Processo: upsert por `numero_cnj` (único). +- Movimentação: identidade `(data_hora em UTC, descricao)`. Só entra o que ainda não existe. +- Pipeline atômico: qualquer falha faz rollback da transação. + +## Consequências + +- Re-sync do mesmo CNJ não duplica linhas. +- Andamentos novos na origem entram de forma incremental. +- Testes de reconciliação podem afirmar "espelho fiel da fonte". diff --git a/docs/adr/003-piramide-testes.md b/docs/adr/003-piramide-testes.md new file mode 100644 index 0000000..ad43bd6 --- /dev/null +++ b/docs/adr/003-piramide-testes.md @@ -0,0 +1,30 @@ +# ADR-003: Pirâmide de testes em cinco camadas + +## Status + +Aceito + +## Contexto + +O valor do portfólio não é só o endpoint. É provar que sync, contrato e dados +não mentem. Uma suíte só de happy path na API deixaria RN de idempotência e +OpenAPI sem rede de segurança. + +## Decisão + +Cinco camadas, com cobertura mínima de 85% na suíte padrão: + +1. Unitário / schemas (Pydantic, regras isoladas) +2. API ASGI (httpx contra FastAPI) +3. Mock HTTP da origem (respx) + reconciliação +4. Integração com PostgreSQL real (Testcontainers) +5. Contrato OpenAPI (Schemathesis) + +A suíte padrão no CI exclui `integration` e `contract` por marcador; jobs +separados rodam essas camadas. + +## Consequências + +- Refatorações de sync quebram testes antes de quebrar o dashboard. +- Fuzzing OpenAPI pega drift de schema (`response_model` em `/stats`, datas UTC). +- Integração Postgres é mais lenta e fica em job próprio. diff --git a/docs/adr/004-enrichment-nao-e-rag.md b/docs/adr/004-enrichment-nao-e-rag.md new file mode 100644 index 0000000..4bbcad0 --- /dev/null +++ b/docs/adr/004-enrichment-nao-e-rag.md @@ -0,0 +1,29 @@ +# ADR-004: Enriquecimento por glossário, não RAG de produção + +## Status + +Aceito + +## Contexto + +A pasta `app/services/rag/` sugeria Retrieval-Augmented Generation (embeddings, +banco vetorial, geração de resposta). O código real canonicaliza classe, assunto +e tribunal com um glossário em memória e similaridade lexical. O nome inflado +enfraquecia a leitura técnica do portfólio. + +JWT (`SECRET_KEY`) existe na config, mas não há login. Deixar isso implícito +também infla o produto. + +## Decisão + +- Pacote `app/services/enrichment/`: `DataJudEnricher` + `InMemoryGlossaryIndex`. +- Campo `contexto_enriquecimento` no schema interno e na resposta de sync. +- LLM opcional (`OPENAI_API_KEY`) só polimento de campos; falha não aborta o sync. +- Auth de usuário final permanece **fora de escopo** (documentado). `SECRET_KEY` + não autentica nenhum endpoint. + +## Consequências + +- Recrutador lê o que o código faz. +- Evoluir para RAG de verdade (pgvector, embeddings) seria um ADR novo, não um rename. +- Dashboard pode ignorar `contexto_enriquecimento`; campo extra não quebra clientes. diff --git a/docs/requisitos.md b/docs/requisitos.md index 8d10740..3641f5f 100644 --- a/docs/requisitos.md +++ b/docs/requisitos.md @@ -21,7 +21,7 @@ ## 1. Visão do produto -O **JurisSync** é uma API que sincroniza processos judiciais brasileiros com a **API Pública do DataJud (CNJ)**, enriquece os dados com contexto jurídico (RAG) e os disponibiliza localmente para consulta e análise de Jurimetria (distribuição de processos por tribunal e por assunto). +O **JurisSync** é uma API que sincroniza processos judiciais brasileiros com a **API Pública do DataJud (CNJ)**, normaliza os dados com um glossário jurídico em memória e os disponibiliza localmente para consulta e análise de Jurimetria (distribuição de processos por tribunal e por assunto). **Problema que resolve:** acompanhar processos judiciais exige consultar tribunais individualmente ou a API do DataJud repetidamente. O JurisSync centraliza, normaliza e mantém um histórico local consultável, evitando duplicidade de dados e permitindo análise agregada. @@ -40,7 +40,7 @@ O **JurisSync** é uma API que sincroniza processos judiciais brasileiros com a | **Sincronização (sync)** | Operação que busca o estado atual de um processo na fonte externa (DataJud ou mock) e reconcilia com o banco local | | **DataJud** | API pública do CNJ que expõe dados processuais de tribunais brasileiros | | **Modo mock** | Quando `DATAJUD_API_KEY` não está configurada (ou a chamada real falha), o sistema gera dados determinísticos e plausíveis a partir do próprio número CNJ | -| **RAG (Retrieval-Augmented Generation)** | Camada que recupera trechos de uma base de conhecimento jurídico em memória e usa esse contexto para normalizar/canonicalizar campos como classe, assunto e tribunal | +| **Enriquecimento (glossário)** | Camada que canonicaliza classe, assunto e tribunal com um dicionário jurídico em memória e similaridade lexical. **Não é RAG de produção** (sem embeddings de modelo nem banco vetorial). | | **Jurimetria** | Análise estatística agregada dos processos armazenados (contagem por tribunal, por assunto) | | **Idempotência** | Propriedade pela qual sincronizar o mesmo processo múltiplas vezes não gera duplicatas | | **Reconciliação** | Processo de garantir que o dado local seja fiel (sem duplicar, sem perder, sem órfãos) ao dado da fonte externa | @@ -63,7 +63,7 @@ Não há autenticação de usuário final nos endpoints hoje - qualquer cliente ## 4. Regras de negócio -As regras abaixo foram extraídas do comportamento real implementado em `app/services/sync_service.py`, `app/services/datajud_client.py`, `app/services/rag/enricher.py` e `app/api/process.py`. +As regras abaixo foram extraídas do comportamento real implementado em `app/services/sync_service.py`, `app/services/datajud_client.py`, `app/services/enrichment/enricher.py` e `app/api/process.py`. ### RN01 - Unicidade do processo por número CNJ Cada processo é identificado de forma única pelo `numero_cnj` (constraint `UNIQUE` no banco). Não pode existir mais de um registro de `Processo` com o mesmo número CNJ. @@ -80,7 +80,7 @@ Uma movimentação é considerada "já existente" se a combinação `(data_hora, > Código: `JurisSyncService.sync_process`, construção de `existing_set` e comparação por `key` (linhas 94-118). ### RN04 - Pipeline de sincronização é atômico (tudo ou nada) -O pipeline segue a ordem: **Extração (DataJud/mock) -> Enriquecimento RAG -> Validação Pydantic -> Persistência**. Se qualquer etapa falhar, a transação é revertida (`rollback`) e nenhum dado parcial (nem o processo, nem movimentações) é persistido. +O pipeline segue a ordem: **Extração (DataJud/mock) -> Enriquecimento (glossário) -> Validação Pydantic -> Persistência**. Se qualquer etapa falhar, a transação é revertida (`rollback`) e nenhum dado parcial (nem o processo, nem movimentações) é persistido. > Código: bloco `try/except` com `await self.db.rollback()` em caso de erro (linhas 150-153); validado por `tests/test_sync_reconciliation.py::test_reconciliation_rolls_back_completely_on_partial_failure`. ### RN05 - Fallback automático para modo mock @@ -95,13 +95,13 @@ O gerador de mock usa o próprio número CNJ como seed (`random.seed(numero_cnj) O tribunal (sigla, nome e alias de API) é determinado pelos segmentos `J` (justiça) e `TR` (tribunal) do número CNJ, consultando o mapa `TRIBUNAIS_MAP`. Se o segmento não estiver mapeado, uma chamada real à API é rejeitada com erro; no mock, o tribunal cai para `TJSP` como padrão. > Código: `DataJudClient._resolve_tribunal_alias`, `_tribunal_sigla_from_cnj` (linhas 120-131, 180-187). -### RN08 - Enriquecimento RAG antes da validação estrita -Antes de validar os dados com o schema Pydantic (`DataJudProcessoSchema`), o sistema recupera até `RAG_TOP_K` (padrão: 3) trechos de conhecimento jurídico relevantes e usa esse contexto para: (a) corrigir o tribunal quando a sigla informada é inválida, (b) canonicalizar `classe` e `assunto` para os termos padronizados da base de conhecimento. -> Código: `JurisSyncService.sync_process` (linhas 44-53); `DataJudRAGEnricher.enrich` (`app/services/rag/enricher.py`). +### RN08 - Enriquecimento por glossário antes da validação estrita +Antes de validar os dados com o schema Pydantic (`DataJudProcessoSchema`), o sistema consulta até `ENRICHMENT_TOP_K` (padrão: 3) trechos do glossário local e usa esse contexto para: (a) corrigir o tribunal quando a sigla informada é inválida, (b) canonicalizar `classe` e `assunto` para os termos padronizados. +> Código: `JurisSyncService._extrair_e_validar`; `DataJudEnricher.enrich` (`app/services/enrichment/enricher.py`). Decisão: [`docs/adr/004-enrichment-nao-e-rag.md`](adr/004-enrichment-nao-e-rag.md). ### RN09 - Refinamento opcional via LLM -Se `OPENAI_API_KEY` estiver configurada, uma chamada adicional a um endpoint compatível com OpenAI Chat Completions tenta refinar `classe`, `assunto` e `tribunal` usando o contexto RAG recuperado. Falhas nessa etapa são **toleradas** (log de warning) e não interrompem a sincronização - o dado normalizado pela regra RN08 permanece válido. -> Código: `DataJudRAGEnricher._llm_refine` (linhas 176-221). +Se `OPENAI_API_KEY` estiver configurada, uma chamada adicional a um endpoint compatível com OpenAI Chat Completions tenta refinar `classe`, `assunto` e `tribunal` usando os trechos do glossário. Falhas nessa etapa são **toleradas** (log de warning) e não interrompem a sincronização - o dado normalizado pela regra RN08 permanece válido. +> Código: `DataJudEnricher._llm_refine`. ### RN10 - Grau de jurisdição restrito a 1, 2 ou 3 O campo `grau` só aceita os valores 1 (primeira instância), 2 ou 3 (instâncias superiores/recursais). Qualquer valor fora desse intervalo é rejeitado na validação de entrada. @@ -224,7 +224,7 @@ Formato: `Como , quero , para `, com critérios de ace **Critérios de aceite:** - Dada uma classe processual com variações textuais (ex.: "execução" em qualquer caixa), o sistema a canonicaliza para o termo padrão da base de conhecimento (ex.: "Execução de Título Extrajudicial"). - Dado um tribunal inválido/ausente na origem, mas identificável pelo CNJ, o sistema corrige o tribunal automaticamente. -- O contexto jurídico recuperado (trechos usados na normalização) é exposto na resposta de sincronização (`contexto_rag`). +- O contexto do glossário usado na normalização é exposto na resposta de sincronização (`contexto_enriquecimento`). --- @@ -272,7 +272,7 @@ Funcionalidade: Sincronização de processos judiciais Cenário: Reverter completamente a sincronização em caso de falha no pipeline Dado um número CNJ inédito "0812347-33.2023.8.26.0005" - E que o enriquecimento RAG irá falhar durante o processamento + E que o enriquecimento irá falhar durante o processamento Quando eu solicitar a sincronização desse processo Então a API deve propagar o erro E nenhum processo deve ter sido criado na base local @@ -403,7 +403,8 @@ Essas regras de validação foram, inclusive, endurecidas a partir de achados do | RN05, US03 (fallback mock) | `app/services/datajud_client.py` | `tests/test_datajud_client_contract.py` (respx: 404, 500, timeout) | | RN06, US02 (determinismo do mock) | `DataJudClient._generate_mock_data` | `tests/test_datajud_client.py::test_mock_client_generates_consistent_data` | | RN07 (resolução de tribunal) | `DataJudClient._resolve_tribunal_alias` | `tests/test_datajud_client.py::test_resolve_tribunal_alias_from_cnj` | -| RN08, US08 (enriquecimento RAG) | `app/services/rag/enricher.py` | `tests/test_rag_enricher.py` | +| RN08, US08 (enriquecimento) | `app/services/enrichment/enricher.py` | `tests/test_enricher.py` | +| Erros HTTP do sync (404/422/503) | `app/api/errors.py` | `tests/test_sync_errors.py`, `tests/test_api.py` | | RN11 (cascade delete) | `app/models/process.py` | `tests/integration/test_sync_service_postgres.py::test_reconciliation_movement_delete_cascade_on_real_postgres` | | RN12, RN13, RN14, US04 (listagem) | `app/api/process.py::listar_processos` | `tests/test_api.py` | | US05 (detalhe/404) | `app/api/process.py::obter_processo` | `tests/test_api.py`; `postman/JurisSync.postman_collection.json` (caso 404) | diff --git a/tests/contract/test_openapi_contract.py b/tests/contract/test_openapi_contract.py index 3162d60..a3e3e4e 100644 --- a/tests/contract/test_openapi_contract.py +++ b/tests/contract/test_openapi_contract.py @@ -17,7 +17,6 @@ import pytest import schemathesis from hypothesis import HealthCheck, settings -from schemathesis.checks import CHECKS, load_all_checks from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.core.database import Base, get_db @@ -52,14 +51,6 @@ async def _create_schema() -> None: schema = schemathesis.openapi.from_asgi("/openapi.json", app) -load_all_checks() -# `/processos/sync` (POST) e `/processos/{process_id}` (GET) compartilham o -# mesmo prefixo de caminho. Um GET em "/processos/sync" é roteado pelo -# Starlette para o endpoint parametrizado (process_id="sync"), retornando 422 -# em vez do 405 que o check `unsupported_method` esperaria. É uma -# característica aceita do design atual da API, não um bug de contrato. -_EXCLUDED_CHECKS = CHECKS.get_by_names(["unsupported_method"]) - @schema.parametrize() @settings( @@ -73,4 +64,4 @@ def test_api_respects_openapi_contract(case): respeita o contrato: status codes documentados, schema de resposta e ausência de erros de servidor (5xx) não documentados. """ - case.call_and_validate(excluded_checks=_EXCLUDED_CHECKS) + case.call_and_validate() diff --git a/tests/test_api.py b/tests/test_api.py index 23fc34d..2e3f1c9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,8 +1,11 @@ import uuid +from unittest.mock import AsyncMock, patch import pytest from httpx import AsyncClient +from app.services.datajud_client import DataJudNotFoundError, DataJudTransientError + @pytest.mark.asyncio async def test_api_health_endpoint(api_client: AsyncClient): @@ -116,3 +119,50 @@ async def test_api_jurimetria_stats_endpoints(api_client: AsyncClient): data_assunto = stats_assunto.json() assert len(data_assunto) > 0 assert "total_processos" in data_assunto[0] + + +@pytest.mark.asyncio +async def test_api_sync_get_returns_405(api_client: AsyncClient): + """GET /sync não deve ser interpretado como UUID em /{process_id}.""" + response = await api_client.get("/api/v1/processos/sync") + assert response.status_code == 405 + assert "POST" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_api_sync_exposes_enrichment_context(api_client: AsyncClient): + response = await api_client.post( + "/api/v1/processos/sync", + json={"numero_cnj": "0005555-11.2023.8.15.0001", "grau": 1}, + ) + assert response.status_code == 200 + assert "contexto_enriquecimento" in response.json() + assert isinstance(response.json()["contexto_enriquecimento"], list) + + +@pytest.mark.asyncio +async def test_api_sync_maps_datajud_not_found_to_404(api_client: AsyncClient): + with patch( + "app.api.process.JurisSyncService.sync_process", + new_callable=AsyncMock, + side_effect=DataJudNotFoundError("ausente"), + ): + response = await api_client.post( + "/api/v1/processos/sync", + json={"numero_cnj": "0006666-11.2023.8.26.0001", "grau": 1}, + ) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_api_sync_maps_transient_error_to_503(api_client: AsyncClient): + with patch( + "app.api.process.JurisSyncService.sync_process", + new_callable=AsyncMock, + side_effect=DataJudTransientError("timeout"), + ): + response = await api_client.post( + "/api/v1/processos/sync", + json={"numero_cnj": "0007777-11.2023.8.26.0001", "grau": 1}, + ) + assert response.status_code == 503 diff --git a/tests/test_rag_enricher.py b/tests/test_enricher.py similarity index 73% rename from tests/test_rag_enricher.py rename to tests/test_enricher.py index 99eea25..4e22d1c 100644 --- a/tests/test_rag_enricher.py +++ b/tests/test_enricher.py @@ -1,13 +1,13 @@ import pytest from app.schemas.datajud import DataJudProcessoSchema -from app.services.rag.enricher import DataJudRAGEnricher -from app.services.rag.vector_store import InMemoryVectorStore +from app.services.enrichment.enricher import DataJudEnricher +from app.services.enrichment.glossary_index import InMemoryGlossaryIndex @pytest.mark.asyncio -async def test_rag_enricher_retrieves_context_and_normalizes_fields(): - enricher = DataJudRAGEnricher() +async def test_enricher_retrieves_context_and_normalizes_fields(): + enricher = DataJudEnricher() raw_data = { "numeroProcesso": "0801234-56.2023.8.15.0001", "classe": "procedimento comum cível", @@ -32,13 +32,13 @@ async def test_rag_enricher_retrieves_context_and_normalizes_fields(): assert validated.tribunal == "TJPB" assert validated.classe == "Procedimento Comum Cível" assert validated.assunto == "Indenização por Dano Moral" - assert len(validated.contexto_rag) > 0 + assert len(validated.contexto_enriquecimento) > 0 assert len(validated.movimentacoes) == 1 @pytest.mark.asyncio -async def test_rag_pipeline_rejects_invalid_cnj_after_enrichment(): - enricher = DataJudRAGEnricher() +async def test_enrichment_rejects_invalid_cnj_after_normalization(): + enricher = DataJudEnricher() raw_data = { "numeroProcesso": "numero-invalido", "tribunal": "TJSP", @@ -51,9 +51,9 @@ async def test_rag_pipeline_rejects_invalid_cnj_after_enrichment(): DataJudProcessoSchema.from_enriched(enriched) -def test_vector_store_returns_relevant_chunks_for_legal_query(): - store = InMemoryVectorStore() - results = store.search("dano moral tjsp consumidor", top_k=2) +def test_glossary_index_returns_relevant_chunks_for_legal_query(): + index = InMemoryGlossaryIndex() + results = index.search("dano moral tjsp consumidor", top_k=2) assert len(results) > 0 categories = {chunk.categoria for chunk, _ in results} diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 2e8049d..01cd17a 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -27,7 +27,7 @@ def test_datajud_schema_accepts_valid_payload(): "codigo_movimento": 1, } ], - "contexto_rag": ["contexto"], + "contexto_enriquecimento": ["contexto"], } schema = DataJudProcessoSchema.model_validate(payload) diff --git a/tests/test_sync_errors.py b/tests/test_sync_errors.py new file mode 100644 index 0000000..dde31be --- /dev/null +++ b/tests/test_sync_errors.py @@ -0,0 +1,43 @@ +from fastapi import status +from pydantic import ValidationError + +from app.api.errors import http_exception_from_sync_error +from app.schemas.datajud import DataJudProcessoSchema +from app.services.datajud_client import ( + DataJudError, + DataJudNotFoundError, + DataJudTransientError, +) + + +def test_maps_not_found_to_404(): + mapped = http_exception_from_sync_error(DataJudNotFoundError("ausente")) + assert mapped.status_code == status.HTTP_404_NOT_FOUND + + +def test_maps_transient_to_503(): + mapped = http_exception_from_sync_error(DataJudTransientError("timeout")) + assert mapped.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + + +def test_maps_datajud_error_to_502(): + mapped = http_exception_from_sync_error(DataJudError("http 400")) + assert mapped.status_code == status.HTTP_502_BAD_GATEWAY + + +def test_maps_validation_error_to_422(): + try: + DataJudProcessoSchema.from_enriched({"numero_cnj": "x", "tribunal": "TJSP"}) + except ValidationError as error: + mapped = http_exception_from_sync_error(error) + assert mapped.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +def test_maps_value_error_to_400(): + mapped = http_exception_from_sync_error(ValueError("Tribunal não mapeado")) + assert mapped.status_code == status.HTTP_400_BAD_REQUEST + + +def test_maps_unknown_to_500(): + mapped = http_exception_from_sync_error(RuntimeError("boom")) + assert mapped.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR diff --git a/tests/test_sync_reconciliation.py b/tests/test_sync_reconciliation.py index f69c418..876a7fc 100644 --- a/tests/test_sync_reconciliation.py +++ b/tests/test_sync_reconciliation.py @@ -82,7 +82,7 @@ async def fetch_process_data(self, numero_cnj: str, grau: int = 1): @pytest.mark.asyncio async def test_reconciliation_rolls_back_completely_on_partial_failure(db_session): """ - Se o pipeline falhar após a extração (ex: erro no enriquecimento RAG), + Se o pipeline falhar após a extração (ex.: erro no enriquecimento), nenhum dado parcial pode permanecer no banco. Reconciliação exige atomicidade: tudo ou nada. """ @@ -90,13 +90,13 @@ async def test_reconciliation_rolls_back_completely_on_partial_failure(db_sessio class FailingEnricher: async def enrich(self, raw_data, numero_cnj, grau): - raise RuntimeError("Falha simulada no pipeline de enriquecimento RAG") + raise RuntimeError("Falha simulada no pipeline de enriquecimento") movimentacoes_antes = len( (await db_session.execute(select(Movimentacao))).scalars().all() ) - service = JurisSyncService(db_session, rag_enricher=FailingEnricher()) + service = JurisSyncService(db_session, enricher=FailingEnricher()) with pytest.raises(RuntimeError): await service.sync_process(cnj, grau=1)