Plano Técnico — Banco de Dados: SafeStreets
Blueprint técnico derivado de
spec.md. Descreve o como de cada decisão. Antes de implementar qualquer task, releiaspec.mde este arquivo.
Stack e decisões técnicas
| Componente | Tecnologia | Justificativa |
|---|---|---|
| SGBD | PostgreSQL 15 (Alpine) | Já usado no Docker Compose atual |
| ORM | SQLAlchemy | Já instalado no requirements.txt |
| Migrations | Alembic | Padrão de mercado para projetos SQLAlchemy, gera SQL automaticamente dos models |
| Validação | Pydantic (via FastAPI) | Validação ocorre antes de qualquer escrita no banco |
| Cache (dev) | In-memory dict Python com TTL | Zero dependência, suficiente para desenvolvimento e testes |
| Cache (prod) | Redis | Pendente ADR-003 — não implementar agora |
| Conexão | os.getenv("DATABASE_URL") |
Permite rodar em qualquer ambiente sem alterar código |
| Pool | pool_pre_ping=True, size=10, overflow=20 |
Evita conexões mortas e suporta carga razoável |
Estrutura de arquivos (estado alvo)
backend/
├── app/
│ ├── core/
│ │ ├── database.py ← MODIFICAR (env var + pool config + get_db)
│ │ └── cache.py ← CRIAR (in-memory com TTL)
│ ├── models/
│ │ ├── __init__.py ← CRIAR (importa todos os models para o Alembic)
│ │ ├── local_pin.py ← CRIAR (tabela locais_pin)
│ │ ├── ocorrencia.py ← MODIFICAR (expandir para schema completo)
│ │ └── historico_consulta.py ← CRIAR (tabela historico_consultas)
│ ├── repositories/
│ │ ├── __init__.py ← CRIAR (vazio)
│ │ ├── ocorrencia_repository.py ← CRIAR
│ │ └── local_pin_repository.py ← CRIAR
│ └── services/
│ └── risco_service.py ← CRIAR
├── alembic/ ← CRIAR via `alembic init`
│ ├── versions/ ← gerada automaticamente
│ └── env.py ← MODIFICAR após init
├── tests/
│ └── test_main.py ← já existe; adicionar testes de repo e risco
├── docker-compose.yml ← MODIFICAR (healthcheck + serviço api)
└── requirements.txt ← MODIFICAR (adicionar alembic)
Modelo relacional
[locais_pin] 1 ──── 0..* [ocorrencias_criminais]
│
│ 1
└──────── 0..* [historico_consultas]
Ambas as FKs usam ON DELETE CASCADE: remover um local_pin remove todas as ocorrências e consultas associadas.
Tabela: locais_pin
Ponto geográfico único ou centroide de uma Região Administrativa.
Colunas
| Coluna | Tipo | Nullable | Default | Notas |
|---|---|---|---|---|
id |
SERIAL | ❌ | auto | PK |
latitude |
NUMERIC(9,6) | ❌ | — | CHECK BETWEEN -90 AND 90 |
longitude |
NUMERIC(9,6) | ❌ | — | CHECK BETWEEN -180 AND 180 |
regiao_administrativa |
VARCHAR(10) | ❌ | — | ex: "RA-026" — indexado |
nome_regiao |
VARCHAR(100) | ✅ | NULL | ex: "Taguatinga" |
tipo_localizacao |
VARCHAR(20) | ✅ | 'centroide' | 'centroide' ou 'preciso' |
criado_em |
TIMESTAMPTZ | ✅ | NOW() |
Índices
idx_locais_pin_coords→(latitude, longitude)— busca por bounding boxidx_locais_pin_regiao→(regiao_administrativa)
Model SQLAlchemy
# backend/app/models/local_pin.py
from sqlalchemy import Column, Integer, String, Numeric, DateTime, CheckConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class LocalPin(Base):
__tablename__ = "locais_pin"
id = Column(Integer, primary_key=True, index=True)
latitude = Column(Numeric(9, 6), nullable=False)
longitude = Column(Numeric(9, 6), nullable=False)
regiao_administrativa = Column(String(10), nullable=False, index=True)
nome_regiao = Column(String(100))
tipo_localizacao = Column(String(20), default="centroide")
criado_em = Column(DateTime(timezone=True), server_default=func.now())
ocorrencias = relationship("Ocorrencia", back_populates="local_pin")
historico_consultas = relationship("HistoricoConsulta", back_populates="local_pin")
__table_args__ = (
CheckConstraint("latitude BETWEEN -90 AND 90", name="chk_latitude"),
CheckConstraint("longitude BETWEEN -180 AND 180", name="chk_longitude"),
)
Tabela: ocorrencias_criminais
Tabela central. Cada linha é uma ocorrência processada pelo pipeline ETL.
⚠️ O model atual (
ocorrencia.py) usa__tablename__ = "ocorrencias"e só tem 4 campos. Ele será completamente reescrito.
Colunas
| Coluna | Tipo | Nullable | Default | Notas |
|---|---|---|---|---|
id |
SERIAL | ❌ | auto | PK |
locais_pin_id |
INTEGER | ❌ | — | FK → locais_pin.id CASCADE |
titulo_noticia |
VARCHAR(500) | ❌ | — | indexado |
descricao_detalhada |
TEXT | ✅ | NULL | |
fonte_url |
VARCHAR(2048) | ✅ | NULL | URL original (Correio Braziliense) |
latitude |
NUMERIC(9,6) | ❌ | — | Redundante ao locais_pin — evita JOIN em queries rápidas |
longitude |
NUMERIC(9,6) | ❌ | — | Idem |
regiao_administrativa |
VARCHAR(10) | ❌ | — | indexado |
resumo_gemini |
TEXT | ✅ | NULL | Gerado pelo Gemini API |
resumo_status |
VARCHAR(20) | ✅ | 'PENDENTE' | CHECK: 'COMPLETO', 'PENDENTE', 'ERRO', 'FALLBACK_GENERICO' |
risco_nivel |
VARCHAR(10) | ✅ | 'baixo' | CHECK: 'baixo', 'medio', 'alto' — calculado por risco_service |
data_ocorrencia |
TIMESTAMPTZ | ❌ | — | |
criado_em |
TIMESTAMPTZ | ✅ | NOW() | |
atualizado_em |
TIMESTAMPTZ | ✅ | NOW() | onupdate=func.now() no ORM |
Índices
idx_ocorrencias_pin→(locais_pin_id)idx_ocorrencias_regiao→(regiao_administrativa)idx_ocorrencias_data→(data_ocorrencia DESC)idx_ocorrencias_status→(resumo_status)WHEREresumo_status = 'PENDENTE'— índice parcial
Model SQLAlchemy
# backend/app/models/ocorrencia.py
from sqlalchemy import Column, Integer, String, Text, Numeric, DateTime, ForeignKey, CheckConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class Ocorrencia(Base):
__tablename__ = "ocorrencias_criminais"
id = Column(Integer, primary_key=True, index=True)
locais_pin_id = Column(Integer, ForeignKey("locais_pin.id", ondelete="CASCADE"), nullable=False)
titulo_noticia = Column(String(500), nullable=False, index=True)
descricao_detalhada = Column(Text)
fonte_url = Column(String(2048))
latitude = Column(Numeric(9, 6), nullable=False)
longitude = Column(Numeric(9, 6), nullable=False)
regiao_administrativa = Column(String(10), nullable=False, index=True)
resumo_gemini = Column(Text)
resumo_status = Column(String(20), default="PENDENTE")
risco_nivel = Column(String(10), default="baixo")
data_ocorrencia = Column(DateTime(timezone=True), nullable=False)
criado_em = Column(DateTime(timezone=True), server_default=func.now())
atualizado_em = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
local_pin = relationship("LocalPin", back_populates="ocorrencias")
__table_args__ = (
CheckConstraint(
"resumo_status IN ('COMPLETO', 'PENDENTE', 'ERRO', 'FALLBACK_GENERICO')",
name="chk_resumo_status"
),
CheckConstraint(
"risco_nivel IN ('baixo', 'medio', 'alto')",
name="chk_risco_nivel"
),
)
Tabela: historico_consultas
Cache espacial com TTL. Registra quando cada região foi consultada e quando o cache expira.
Colunas
| Coluna | Tipo | Nullable | Default | Notas |
|---|---|---|---|---|
id |
SERIAL | ❌ | auto | PK |
locais_pin_id |
INTEGER | ✅ | NULL | FK → locais_pin.id CASCADE |
timestamp_ultima_atualizacao |
TIMESTAMPTZ | ❌ | NOW() | |
ttl_expiracao |
TIMESTAMPTZ | ❌ | — | timestamp + 24h (padrão — ver ADR-002) |
raio_geografico |
VARCHAR(100) | ✅ | NULL | ex: "bbox:-15.8,-48.1,-15.7,-47.9" |
total_ocorrencias_cached |
INTEGER | ✅ | 0 | |
criado_em |
TIMESTAMPTZ | ✅ | NOW() |
Índices
idx_historico_ttl→(ttl_expiracao)— queries de expiraçãoidx_historico_pin→(locais_pin_id)
Model SQLAlchemy
# backend/app/models/historico_consulta.py
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class HistoricoConsulta(Base):
__tablename__ = "historico_consultas"
id = Column(Integer, primary_key=True, index=True)
locais_pin_id = Column(Integer, ForeignKey("locais_pin.id", ondelete="CASCADE"))
timestamp_ultima_atualizacao = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
ttl_expiracao = Column(DateTime(timezone=True), nullable=False)
raio_geografico = Column(String(100))
total_ocorrencias_cached = Column(Integer, default=0)
criado_em = Column(DateTime(timezone=True), server_default=func.now())
local_pin = relationship("LocalPin", back_populates="historico_consultas")
Configuração de conexão (alvo)
# backend/app/core/database.py
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://admin:123@localhost:5432/safestreets"
)
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
echo=False,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
Alembic — inicialização e fluxo
# 1. Dentro de /backend:
alembic init alembic
# 2. Editar alembic/env.py:
# from app.core.database import Base
# from app.models import local_pin, ocorrencia, historico_consulta
# target_metadata = Base.metadata
# 3. Gerar primeira migration:
alembic revision --autogenerate -m "cria tabelas locais_pin, ocorrencias_criminais e historico_consultas"
# 4. Aplicar:
alembic upgrade head
# Mudanças futuras sempre seguem o padrão:
alembic revision --autogenerate -m "descricao"
alembic upgrade head
Repository Pattern
OcorrenciaRepository
Métodos obrigatórios:
- criar(ocorrencia) → salva e retorna com id populado
- buscar_por_id(id) → Optional[Ocorrencia]
- buscar_por_regiao_e_periodo(regiao, data_inicio, data_fim, limit, offset) → list[Ocorrencia]
- contar_por_regiao(regiao) → int — usado pelo risco_service
- buscar_pendentes_resumo() → ocorrências com resumo_status == "PENDENTE"
- atualizar_resumo(id, resumo, status) → atualiza campos de IA
LocalPinRepository
Métodos obrigatórios:
- buscar_ou_criar(lat, lon, regiao, nome) → evita duplicatas de ponto geográfico
- buscar_por_regiao(regiao) → list[LocalPin]
Serviço de risco
Lógica de negócio — não pertence ao Repository.
# backend/app/services/risco_service.py
LIMIARES_RISCO = {"alto": 50, "medio": 20}
def calcular_risco(regiao: str, db: Session) -> str:
total = OcorrenciaRepository(db).contar_por_regiao(regiao)
if total >= LIMIARES_RISCO["alto"]: return "alto"
if total >= LIMIARES_RISCO["medio"]: return "medio"
return "baixo"
Cache in-memory (dev)
# backend/app/core/cache.py
from datetime import datetime, timedelta
_cache: dict = {}
def get_cache(key: str):
entry = _cache.get(key)
if entry and entry["expira_em"] > datetime.utcnow():
return entry["valor"]
return None
def set_cache(key: str, valor, ttl_horas: int = 24):
_cache[key] = {
"valor": valor,
"expira_em": datetime.utcnow() + timedelta(hours=ttl_horas),
}
Docker Compose (alvo)
Adicionar ao docker-compose.yml atual:
services:
db:
# ... existente ...
healthcheck:
test: ["CMD-SHELL", "pg_isready -U admin -d safestreets"]
interval: 5s
timeout: 5s
retries: 5
api:
build: .
container_name: safestreets_api
environment:
DATABASE_URL: postgresql://admin:123@db:5432/safestreets
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
Testes
tests/test_ocorrencia_repository.py— testarcriar,buscar_por_regiao_e_periodo,contar_por_regiao,buscar_pendentes_resumo,atualizar_resumotests/test_risco_service.py— testar os 3 limiares (< 20, 20–49, ≥ 50) com mock do repository
Usar banco de teste em memória (SQLite) ou banco PostgreSQL de teste via fixture.
Decisões em aberto (ADRs)
| ADR | Questão | Impacto |
|---|---|---|
| ADR-002 | TTL variável por RA? | Coluna ttl_expiracao em historico_consultas |
| ADR-003 | Redis em produção? | Troca de cache.py por cache_redis.py |
| ADR-004 | Schema versioning com Alembic? | Estratégia de rollback e migrations |