Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
23 changes: 12 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

Expand Down Expand Up @@ -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

Expand All @@ -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)]
```

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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%]

Expand Down Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions app/api/errors.py
Original file line number Diff line number Diff line change
@@ -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.",
)
60 changes: 47 additions & 13 deletions app/api/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -15,6 +16,8 @@
ProcessoRead,
ProcessoSyncRequest,
ProcessoSyncResponse,
StatsAssuntoItem,
StatsTribunalItem,
)
from app.services.sync_service import JurisSyncService

Expand All @@ -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(
Expand All @@ -46,25 +62,39 @@ 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(
sucesso=resultado["sucesso"],
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(
Expand Down Expand Up @@ -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)):
"""
Expand All @@ -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)):
"""
Expand Down
2 changes: 1 addition & 1 deletion app/core/cnj.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 5 additions & 2 deletions app/schemas/datajud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
14 changes: 14 additions & 0 deletions app/schemas/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
10 changes: 10 additions & 0 deletions app/services/enrichment/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading