Skip to content
Open
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
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,30 @@ jobs:
defaults:
run:
working-directory: apps/api
# The storage tests run against the database the application actually uses.
# Passing on SQLite would prove the tests pass, not that the schema works.
services:
db:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: firenze
POSTGRES_PASSWORD: firenze
POSTGRES_DB: firenze
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U firenze"
--health-interval 5s
--health-timeout 3s
--health-retries 10
env:
FIRENZE_TEST_DATABASE_URL: postgresql+psycopg://firenze:firenze@localhost:5432/firenze
FIRENZE_DATABASE_URL: postgresql+psycopg://firenze:firenze@localhost:5432/firenze
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-python-env
- name: migrations apply cleanly from scratch
run: uv run alembic upgrade head
- run: uv run pytest
- name: contrato commitado está atualizado
run: |
Expand Down
9 changes: 6 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ COMPOSE := docker compose -f infra/compose/docker-compose.yml
API := apps/api

.DEFAULT_GOAL := help
.PHONY: help dev down logs psql install api case ask openapi lint fmt typecheck test check migrate evals
.PHONY: help dev down logs psql install api case ask openapi lint fmt typecheck test check migrate migration evals

help: ## lista os alvos
@grep -hE '^[a-z-]+:.*?## ' $(MAKEFILE_LIST) | sed 's/:.*## /\t/' | expand -t 14
Expand Down Expand Up @@ -51,8 +51,11 @@ test: ## pytest

check: lint typecheck test ## tudo que o CI cobra

migrate: ## aplica as migrations (alembic) — fase 1
@echo "Ainda não existe schema. Chega junto com o gerador de casos (fase 1)." && exit 1
migrate: ## aplica as migrations (alembic)
cd $(API) && uv run alembic upgrade head

migration: ## cria migration a partir do schema (make migration M="add x")
cd $(API) && uv run alembic revision --autogenerate -m "$(M)"

evals: ## roda a suíte de avaliação — fase 3
@echo "Suíte de evals chega na fase 3. Ver docs/06-plano-de-evals.md." && exit 1
39 changes: 39 additions & 0 deletions apps/api/alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
[alembic]
script_location = migrations
prepend_sys_path = src
path_separator = os
# The URL comes from FIRENZE_DATABASE_URL, resolved in migrations/env.py.
sqlalchemy.url =

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARNING
handlers = console
qualname =

[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
51 changes: 51 additions & 0 deletions apps/api/migrations/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Alembic wiring.

The URL is never written in alembic.ini: it carries a password, and a file that
carries a password is a file somebody commits eventually. It comes from
settings, which read the environment.
"""

from logging.config import fileConfig

from alembic import context
from sqlalchemy import engine_from_config, pool

from firenze.config import settings
from firenze.storage import metadata

config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)

if config.config_file_name is not None:
fileConfig(config.config_file_name)

target_metadata = metadata


def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()


def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
24 changes: 24 additions & 0 deletions apps/api/migrations/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
"""

from collections.abc import Sequence

from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}


def upgrade() -> None:
${upgrades if upgrades else "pass"}


def downgrade() -> None:
${downgrades if downgrades else "pass"}
71 changes: 71 additions & 0 deletions apps/api/migrations/versions/0f90e37c4ebe_initial_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""initial schema

Revision ID: 0f90e37c4ebe
Revises:
"""

from collections.abc import Sequence

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

revision: str = '0f90e37c4ebe'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('cases',
sa.Column('id', sa.UUID().with_variant(sa.String(length=36), 'sqlite'), nullable=False),
sa.Column('seed', sa.Integer(), nullable=False),
sa.Column('generator_version', sa.String(length=16), nullable=False),
sa.Column('setting', sa.String(length=64), nullable=False),
sa.Column('document', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('seed', 'generator_version', 'setting', name='uq_cases_identity')
)
op.create_table('matches',
sa.Column('id', sa.UUID().with_variant(sa.String(length=36), 'sqlite'), nullable=False),
sa.Column('case_id', sa.UUID().with_variant(sa.String(length=36), 'sqlite'), nullable=False),
sa.Column('locale', sa.String(length=16), nullable=False),
sa.Column('turns_left', sa.Integer(), nullable=False),
sa.Column('stances', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['case_id'], ['cases.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('solutions',
sa.Column('case_id', sa.UUID().with_variant(sa.String(length=36), 'sqlite'), nullable=False),
sa.Column('document', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=False),
sa.ForeignKeyConstraint(['case_id'], ['cases.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('case_id')
)
op.create_table('statements',
sa.Column('id', sa.UUID().with_variant(sa.String(length=36), 'sqlite'), nullable=False),
sa.Column('match_id', sa.UUID().with_variant(sa.String(length=36), 'sqlite'), nullable=False),
sa.Column('turn', sa.Integer(), nullable=False),
sa.Column('character', sa.String(length=32), nullable=False),
sa.Column('question', sa.Text(), nullable=False),
sa.Column('line', sa.Text(), nullable=False),
sa.Column('stance', sa.String(length=16), nullable=False),
sa.Column('lied', sa.Boolean(), nullable=False),
sa.Column('fact_referenced', sa.String(length=16), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['match_id'], ['matches.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('match_id', 'turn', name='uq_statements_turn')
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('statements')
op.drop_table('solutions')
op.drop_table('matches')
op.drop_table('cases')
# ### end Alembic commands ###
7 changes: 7 additions & 0 deletions apps/api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ dependencies = [
"uvicorn[standard]>=0.34",
"pydantic-settings>=2.7",
"openai>=3.6.0",
"sqlalchemy>=2.0",
"psycopg[binary]>=3.2",
"alembic>=1.19.1",
]

[project.scripts]
Expand All @@ -32,6 +35,9 @@ packages = ["src/firenze"]
line-length = 100
target-version = "py313"
src = ["src", "tests"]
# Alembic writes these; reformatting generated files on every autogenerate is
# churn that hides the one line a human actually changed.
extend-exclude = ["migrations/versions"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
Expand All @@ -40,6 +46,7 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
python_version = "3.13"
strict = true
warn_unreachable = true
exclude = ["migrations/versions"]

[tool.pytest.ini_options]
testpaths = ["tests"]
Expand Down
26 changes: 26 additions & 0 deletions apps/api/src/firenze/storage/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Persistence. The only package that writes SQL."""

from firenze.storage.engine import engine, transaction
from firenze.storage.store import (
NotFound,
load_case,
load_full_case,
load_match,
record_turn,
save_case,
start_match,
)
from firenze.storage.tables import metadata

__all__ = [
"NotFound",
"engine",
"load_case",
"load_full_case",
"load_match",
"metadata",
"record_turn",
"save_case",
"start_match",
"transaction",
]
27 changes: 27 additions & 0 deletions apps/api/src/firenze/storage/engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""One engine, built from configuration.

`pool_pre_ping` because the managed database sits behind a network that drops
idle connections, and a stale one surfaces as a failed turn rather than as a
reconnect (ADR-0002).
"""

from collections.abc import Iterator
from contextlib import contextmanager
from functools import cache

from sqlalchemy import Engine, create_engine
from sqlalchemy.engine import Connection

from firenze.config import settings


@cache
def engine(url: str | None = None) -> Engine:
return create_engine(url or settings.database_url, pool_pre_ping=True, future=True)


@contextmanager
def transaction(url: str | None = None) -> Iterator[Connection]:
"""A unit of work. One turn is one of these."""
with engine(url).begin() as connection:
yield connection
Loading
Loading