diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3080ed..aaaceff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | diff --git a/Makefile b/Makefile index d04d3fa..b1325b7 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 diff --git a/apps/api/alembic.ini b/apps/api/alembic.ini new file mode 100644 index 0000000..4c2b470 --- /dev/null +++ b/apps/api/alembic.ini @@ -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 diff --git a/apps/api/migrations/env.py b/apps/api/migrations/env.py new file mode 100644 index 0000000..f536751 --- /dev/null +++ b/apps/api/migrations/env.py @@ -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() diff --git a/apps/api/migrations/script.py.mako b/apps/api/migrations/script.py.mako new file mode 100644 index 0000000..99c8842 --- /dev/null +++ b/apps/api/migrations/script.py.mako @@ -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"} diff --git a/apps/api/migrations/versions/0f90e37c4ebe_initial_schema.py b/apps/api/migrations/versions/0f90e37c4ebe_initial_schema.py new file mode 100644 index 0000000..1105a75 --- /dev/null +++ b/apps/api/migrations/versions/0f90e37c4ebe_initial_schema.py @@ -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 ### diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index f761bf1..448a9b3 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -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] @@ -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"] @@ -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"] diff --git a/apps/api/src/firenze/storage/__init__.py b/apps/api/src/firenze/storage/__init__.py new file mode 100644 index 0000000..82d7fe9 --- /dev/null +++ b/apps/api/src/firenze/storage/__init__.py @@ -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", +] diff --git a/apps/api/src/firenze/storage/engine.py b/apps/api/src/firenze/storage/engine.py new file mode 100644 index 0000000..c8d09f3 --- /dev/null +++ b/apps/api/src/firenze/storage/engine.py @@ -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 diff --git a/apps/api/src/firenze/storage/store.py b/apps/api/src/firenze/storage/store.py new file mode 100644 index 0000000..3f96fbd --- /dev/null +++ b/apps/api/src/firenze/storage/store.py @@ -0,0 +1,179 @@ +"""Reading and writing matches. + +Two methods load a case, and which one you call decides what you are allowed to +know: + + load_case(case_id) -> Case # no solution reachable + load_match(match_id) -> Match # solution included, for the verdict + +That is the same boundary as everywhere else in this project, expressed once +more in the layer where it is easiest to lose (RN-011). A caller that only needs +to render a briefing takes the first and physically cannot obtain the culprit — +the query does not touch the table it lives in. + +A turn is one transaction: the statement, the stance and the budget move +together or not at all. Splitting them would let a crash leave a match that was +charged for an answer it never recorded (RN-030). +""" + +import uuid + +from sqlalchemy import insert, select, update +from sqlalchemy.engine import Connection + +from firenze.domain import Case, CaseWithSolution, Match, Solution, Stance, Statement +from firenze.storage.tables import cases, matches, solutions, statements + + +class NotFound(LookupError): + """No row with that id.""" + + +def save_case(connection: Connection, full: CaseWithSolution) -> uuid.UUID: + """Store a case and its solution. Idempotent on the case's identity.""" + case = full.case + existing = connection.execute( + select(cases.c.id).where( + cases.c.seed == case.seed, + cases.c.generator_version == case.generator_version, + cases.c.setting == case.setting, + ) + ).scalar_one_or_none() + if existing is not None: + return uuid.UUID(str(existing)) + + case_id = uuid.uuid4() + connection.execute( + insert(cases).values( + id=case_id, + seed=case.seed, + generator_version=case.generator_version, + setting=case.setting, + document=case.model_dump(mode="json"), + ) + ) + connection.execute( + insert(solutions).values(case_id=case_id, document=full.solution.model_dump(mode="json")) + ) + return case_id + + +def load_case(connection: Connection, case_id: uuid.UUID) -> Case: + """The case as a player's side of the system may know it. No solution.""" + row = connection.execute( + select(cases.c.document).where(cases.c.id == case_id) + ).scalar_one_or_none() + if row is None: + raise NotFound(f"no case {case_id}") + return Case.model_validate(row) + + +def load_full_case(connection: Connection, case_id: uuid.UUID) -> CaseWithSolution: + """Case plus solution. For the generator, the solver and the verdict.""" + row = connection.execute( + select(cases.c.document, solutions.c.document) + .join(solutions, solutions.c.case_id == cases.c.id) + .where(cases.c.id == case_id) + ).first() + if row is None: + raise NotFound(f"no case {case_id}") + return CaseWithSolution( + case=Case.model_validate(row[0]), solution=Solution.model_validate(row[1]) + ) + + +def start_match(connection: Connection, full: CaseWithSolution, locale: str) -> uuid.UUID: + """Save the case if needed and open a match on it.""" + case_id = save_case(connection, full) + match_id = uuid.uuid4() + connection.execute( + insert(matches).values( + id=match_id, + case_id=case_id, + locale=locale, + turns_left=Match.model_fields["turns_left"].default, + stances={}, + ) + ) + return match_id + + +def load_match(connection: Connection, match_id: uuid.UUID) -> Match: + row = connection.execute( + select(matches.c.case_id, matches.c.locale, matches.c.turns_left, matches.c.stances).where( + matches.c.id == match_id + ) + ).first() + if row is None: + raise NotFound(f"no match {match_id}") + + said = connection.execute( + select( + statements.c.turn, + statements.c.character, + statements.c.question, + statements.c.line, + statements.c.stance, + statements.c.lied, + statements.c.fact_referenced, + ) + .where(statements.c.match_id == match_id) + .order_by(statements.c.turn) + ).all() + + return Match( + full_case=load_full_case(connection, uuid.UUID(str(row[0]))), + locale=row[1], + turns_left=row[2], + stances={who: Stance(value) for who, value in (row[3] or {}).items()}, + statements=tuple( + Statement( + turn=s[0], + character=s[1], + question=s[2], + line=s[3], + stance=Stance(s[4]), + lied=s[5], + fact_referenced=s[6], + ) + for s in said + ), + ) + + +def record_turn( + connection: Connection, + match_id: uuid.UUID, + match: Match, + statement: Statement | None, +) -> None: + """Persist what one turn changed, in one transaction. + + `statement` is None when the reply was rejected — the budget still moves, + because a turn that only charged for answers the system liked would be a + turn a player could farm by provoking failures. + """ + connection.execute( + update(matches) + .where(matches.c.id == match_id) + .values( + turns_left=match.turns_left, + stances={who: stance.value for who, stance in match.stances.items()}, + ) + ) + if statement is None: + return + + connection.execute( + insert(statements).values( + id=uuid.uuid4(), + match_id=match_id, + turn=statement.turn, + character=statement.character, + question=statement.question, + line=statement.line, + stance=statement.stance.value, + lied=statement.lied, + fact_referenced=statement.fact_referenced, + ) + ) diff --git a/apps/api/src/firenze/storage/tables.py b/apps/api/src/firenze/storage/tables.py new file mode 100644 index 0000000..9752948 --- /dev/null +++ b/apps/api/src/firenze/storage/tables.py @@ -0,0 +1,94 @@ +"""The schema. + +Two shapes of data, stored differently on purpose. + +**A case is a document.** It is generated, immutable once published, and only +ever read whole. Normalising facts and cast into tables would buy joins nobody +needs and a migration every time the generator gains a field. It goes in JSONB. + +**A match is relational.** Turns accumulate, statements are queried per +character to find contradictions (RN-021), and budgets change under +concurrency. Those are rows. + +## Why the solution has its own table + +`Case` and `Solution` are separate entities so that isolation is a type +signature rather than a discipline (RN-011). The same argument applies one layer +down: if the solution were a column on `cases`, every `SELECT *` would carry the +culprit, and the guarantee would hold only as long as nobody wrote a convenient +query. In its own table, a read of `cases` **cannot** return it. +""" + +from sqlalchemy import ( + JSON, + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + MetaData, + String, + Table, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID + +metadata = MetaData() + +# JSONB on Postgres, plain JSON elsewhere, so the schema stays readable in a +# SQLite session without pretending SQLite is what production runs. +JSON_DOC = JSON().with_variant(JSONB(), "postgresql") +UUID_PK = UUID(as_uuid=True).with_variant(String(36), "sqlite") + +cases = Table( + "cases", + metadata, + Column("id", UUID_PK, primary_key=True), + Column("seed", Integer, nullable=False), + Column("generator_version", String(16), nullable=False), + Column("setting", String(64), nullable=False), + Column("document", JSON_DOC, nullable=False), + Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False), + # Seed alone does not identify a case; these three together do. + UniqueConstraint("seed", "generator_version", "setting", name="uq_cases_identity"), +) + +solutions = Table( + "solutions", + metadata, + Column("case_id", UUID_PK, ForeignKey("cases.id", ondelete="CASCADE"), primary_key=True), + Column("document", JSON_DOC, nullable=False), +) + +matches = Table( + "matches", + metadata, + Column("id", UUID_PK, primary_key=True), + Column("case_id", UUID_PK, ForeignKey("cases.id"), nullable=False), + Column("locale", String(16), nullable=False), + Column("turns_left", Integer, nullable=False), + Column("stances", JSON_DOC, nullable=False, default=dict), + Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False), +) + +statements = Table( + "statements", + metadata, + Column("id", UUID_PK, primary_key=True), + Column("match_id", UUID_PK, ForeignKey("matches.id", ondelete="CASCADE"), nullable=False), + Column("turn", Integer, nullable=False), + Column("character", String(32), nullable=False), + Column("question", Text, nullable=False), + Column("line", Text, nullable=False), + Column("stance", String(16), nullable=False), + Column("lied", Boolean, nullable=False), + Column("fact_referenced", String(16), nullable=True), + Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False), + # Contradiction detection reads one character's statements within one match, + # never across matches and never across characters (RN-013, RN-021). + UniqueConstraint("match_id", "turn", name="uq_statements_turn"), +) + +__all__ = ["cases", "matches", "metadata", "solutions", "statements"] diff --git a/apps/api/tests/test_storage.py b/apps/api/tests/test_storage.py new file mode 100644 index 0000000..bdc2c6f --- /dev/null +++ b/apps/api/tests/test_storage.py @@ -0,0 +1,195 @@ +"""Storage tests, against a real Postgres. + +Not against SQLite. The schema uses JSONB and a composite unique constraint, and +a test that passes on a database the application will never run on proves that +the test passes. `make dev` starts the one these expect. +""" + +import os +import uuid + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.exc import OperationalError + +from firenze.domain import Match, Stance, Statement +from firenze.generation import generate +from firenze.i18n import load +from firenze.interrogation import ask +from firenze.model import FakeModel +from firenze.storage import ( + NotFound, + load_case, + load_full_case, + load_match, + metadata, + record_turn, + save_case, + start_match, +) + +URL = os.environ.get( + "FIRENZE_TEST_DATABASE_URL", + "postgresql+psycopg://firenze:firenze@localhost:5433/firenze", +) + + +def _reachable() -> bool: + try: + create_engine(URL).connect().close() + except OperationalError: + return False + return True + + +pytestmark = pytest.mark.skipif( + not _reachable(), + reason=f"no database at {URL} — start one with `make dev`", +) + + +@pytest.fixture +def connection(): # type: ignore[no-untyped-def] + """A transaction rolled back at the end, so tests never see each other.""" + engine = create_engine(URL) + metadata.create_all(engine) + with engine.connect() as connection: + transaction = connection.begin() + yield connection + transaction.rollback() + + +def test_a_case_survives_a_round_trip(connection) -> None: # type: ignore[no-untyped-def] + full = generate(seed=42) + + case_id = save_case(connection, full) + + assert load_full_case(connection, case_id) == full + + +def test_saving_the_same_case_twice_gives_the_same_row(connection) -> None: # type: ignore[no-untyped-def] + """Identity is seed, generator version and setting — not a fresh uuid.""" + full = generate(seed=42) + + first = save_case(connection, full) + second = save_case(connection, full) + + assert first == second + assert connection.execute(text("SELECT count(*) FROM cases")).scalar_one() == 1 + + +def test_loading_a_case_cannot_reach_the_solution(connection) -> None: # type: ignore[no-untyped-def] + """RN-011 in the layer where it is easiest to lose. + + The solution lives in another table, so this read does not touch it — the + guarantee is the query, not the caller's restraint. + """ + full = generate(seed=42) + case_id = save_case(connection, full) + + serialised = load_case(connection, case_id).model_dump_json() + + assert full.solution.culprit not in serialised.replace('"sus-', "") + assert full.solution.means_key not in serialised + assert full.solution.motive_key not in serialised + + +def test_a_missing_case_is_not_found(connection) -> None: # type: ignore[no-untyped-def] + with pytest.raises(NotFound): + load_case(connection, uuid.uuid4()) + + +def test_a_match_survives_a_round_trip(connection) -> None: # type: ignore[no-untyped-def] + match_id = start_match(connection, generate(seed=42), "pt-BR") + + match = load_match(connection, match_id) + + assert match.locale == "pt-BR" + assert match.turns_left == 30 + assert match.statements == () + assert match.full_case == generate(seed=42) + + +def test_a_turn_persists_the_statement_the_stance_and_the_budget(connection) -> None: # type: ignore[no-untyped-def] + match_id = start_match(connection, generate(seed=42), "pt-BR") + match = load_match(connection, match_id) + + result = ask(match, "sus-1", "onde você estava?", catalog=load("pt-BR"), model=FakeModel()) + record_turn(connection, match_id, result.match, result.statement) + + reloaded = load_match(connection, match_id) + assert reloaded.turns_left == 29 + assert len(reloaded.statements) == 1 + assert reloaded.statements[0].character == "sus-1" + assert reloaded.stance_of("sus-1") is result.match.stance_of("sus-1") + + +def test_a_rejected_turn_still_moves_the_budget(connection) -> None: # type: ignore[no-untyped-def] + """No statement to record, and the turn is spent anyway. (RN-030)""" + match_id = start_match(connection, generate(seed=42), "pt-BR") + match = load_match(connection, match_id) + spent = match.model_copy(update={"turns_left": 29}) + + record_turn(connection, match_id, spent, None) + + reloaded = load_match(connection, match_id) + assert reloaded.turns_left == 29 + assert reloaded.statements == () + + +def test_statements_come_back_in_the_order_they_were_said(connection) -> None: # type: ignore[no-untyped-def] + match_id = start_match(connection, generate(seed=42), "pt-BR") + match = load_match(connection, match_id) + + for question in ("primeira?", "segunda?", "terceira?"): + result = ask(match, "sus-1", question, catalog=load("pt-BR"), model=FakeModel()) + record_turn(connection, match_id, result.match, result.statement) + match = result.match + + said = load_match(connection, match_id).statements + + assert [s.turn for s in said] == [1, 2, 3] + assert [s.question for s in said] == ["primeira?", "segunda?", "terceira?"] + + +def test_two_matches_on_the_same_case_do_not_share_statements(connection) -> None: # type: ignore[no-untyped-def] + """One case, two playthroughs, no leakage between them.""" + full = generate(seed=42) + first_id = start_match(connection, full, "pt-BR") + second_id = start_match(connection, full, "en") + + match = load_match(connection, first_id) + result = ask(match, "sus-1", "onde?", catalog=load("pt-BR"), model=FakeModel()) + record_turn(connection, first_id, result.match, result.statement) + + assert len(load_match(connection, first_id).statements) == 1 + assert load_match(connection, second_id).statements == () + + +def test_the_turn_number_is_unique_within_a_match(connection) -> None: # type: ignore[no-untyped-def] + """The database refuses two turn 1s, so a retry cannot double-record.""" + from sqlalchemy.exc import IntegrityError + + match_id = start_match(connection, generate(seed=42), "pt-BR") + match = load_match(connection, match_id) + statement = Statement( + turn=1, + character="sus-1", + question="?", + line="...", + stance=Stance.cooperative, + lied=False, + ) + + record_turn(connection, match_id, match, statement) + + with pytest.raises(IntegrityError): + record_turn(connection, match_id, match, statement) + + +def test_a_match_in_memory_and_a_match_from_the_database_behave_the_same(connection) -> None: # type: ignore[no-untyped-def] + """The store is a detail; the domain object is the same either way.""" + full = generate(seed=42) + match_id = start_match(connection, full, "pt-BR") + + assert load_match(connection, match_id) == Match(full_case=full, locale="pt-BR") diff --git a/apps/api/uv.lock b/apps/api/uv.lock index b0e9f08..a11a834 100644 --- a/apps/api/uv.lock +++ b/apps/api/uv.lock @@ -6,6 +6,20 @@ resolution-markers = [ "python_full_version < '3.15'", ] +[[package]] +name = "alembic" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/2b/e4153978368de59918115c9e01d3ebf58a558a7285efa7e960c383c4b59a/alembic-1.19.1.tar.gz", hash = "sha256:e0fca0518118c78acc493e31bcb5402f190057aaf6df8b5b95ce94c4789cf648", size = 2070816, upload-time = "2026-08-08T16:32:01.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/89/e62cc37b69ad357cc8ecd6e7367f5245f523d3cbb338a66197212bdf6749/alembic-1.19.1-py3-none-any.whl", hash = "sha256:b39018cb3d9413a19cbd54cf3c02ad33998641f0538eb77413a488a21c3e14be", size = 265946, upload-time = "2026-08-08T16:32:03.153Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.5" @@ -148,9 +162,12 @@ name = "firenze-api" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "alembic" }, { name = "fastapi" }, { name = "openai" }, + { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, + { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -164,17 +181,67 @@ dev = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.19.1" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" }, { name = "openai", specifier = ">=3.6.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic-settings", specifier = ">=2.7" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9" }, + { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34" }, ] provides-extras = ["dev"] +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -433,6 +500,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, ] +[[package]] +name = "mako" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", hash = "sha256:d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27", size = 410165, upload-time = "2026-08-05T06:10:56.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", hash = "sha256:a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617", size = 80010, upload-time = "2026-08-05T06:10:58.248Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mypy" version = "2.3.1" @@ -534,6 +665,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/73/8fb739d0f6bba247b9b93c9840c402a4f88545be5f1d4b02b23366371c00/psycopg-3.3.5.tar.gz", hash = "sha256:d0a3d9ccf5788af054cbd745278cb02401b5c312aeaafbf2c6144460aec47da4", size = 166508, upload-time = "2026-08-31T22:45:43.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/2e/d0a645bcaadde68bd6d93c43f02f14b0191bdda367ce3f7722abe3da744a/psycopg-3.3.5-py3-none-any.whl", hash = "sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e", size = 213598, upload-time = "2026-08-31T22:39:02.184Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/1c/e718752cc63cf4e99e4a10fd36e3a3364dabdd0819484a24c0d79fbb9685/psycopg_binary-3.3.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6e85d50b87257fb117675a19ee59daa7bf9a57f6431500adf7059df799232ef4", size = 4704421, upload-time = "2026-08-31T22:42:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/af/cf/a0e748e27c09b92738e4460582d121ba1908be3e36791e150f435e54b332/psycopg_binary-3.3.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5becd311f9af8d180bad372f51fb2252fd02cb2073056e2b170c9274f95fe7f", size = 4765054, upload-time = "2026-08-31T22:43:05.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/62/0cbac0266d56c94dd1f702d9af2b5d54bf80b6e658048f4f2c5bd63dd7e3/psycopg_binary-3.3.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:19e5bf9872dbd164c220567fd385ba2309c7d9df1541f78343510c6b0f36a1b7", size = 5547137, upload-time = "2026-08-31T22:43:11.768Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3a/73c6f8871f38fc07a9c0b4cbc9467beb116a2783cecf87dfa53900a396dc/psycopg_binary-3.3.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb3b3bffebfe07110730626e76238161124f35ac87b748d663316a28d22f58b0", size = 5227577, upload-time = "2026-08-31T22:43:20.767Z" }, + { url = "https://files.pythonhosted.org/packages/59/7b/9f17b9f4d297b774dc574199c4dcd02dadc32a00c4056265918de5c70635/psycopg_binary-3.3.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2111f880add40fb03c60556069ad68e884a0908a74d2debafc603caf93b73552", size = 6824606, upload-time = "2026-08-31T22:43:33.698Z" }, + { url = "https://files.pythonhosted.org/packages/94/86/d84dadd94a004dbbb43ce0579f1f766fdc6b8cbef745090e24bea77b5283/psycopg_binary-3.3.5-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:40505676b1526b9ea387dace034040a8c8b0bcf984cd6bd4720a2ab15e813586", size = 5060854, upload-time = "2026-08-31T22:43:42.258Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/e5078be2c7d2490c0d6cd4cb7b3601aec44be2fa8b3fe927e9419b06004f/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5816472e3bb05615f33a741e0835043d1f4bf9709ff30d2f4aed71815cfc6b5e", size = 4589511, upload-time = "2026-08-31T22:43:50.012Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/08dfb5b18fa482e025864fd91a022310d0782c42e4cf5dda5d0e010b6790/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:358748fc4c8ccdc0e2bdf55420494930e19c3ade586ea9c3a6de3dad1f897311", size = 4268144, upload-time = "2026-08-31T22:43:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/88/b9/cb01dc1d63f3241b49b2ca7fb9f98d0f5c76127f0dbb9440635a6ad0233e/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1ef2e498be47800f6202b9a2304c22646325ca6d54001b7c785bcfdb24a1e8ab", size = 4001036, upload-time = "2026-08-31T22:44:04.429Z" }, + { url = "https://files.pythonhosted.org/packages/95/49/7c17dd832c05b380562ff2ff5f6ab2bcaeca8b7fff2c2b355854368a5bdb/psycopg_binary-3.3.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:88e01aa2e938a45655a8a5213fc3a44ba78cb4cab8a569b3e0bcb3d1d0eaba16", size = 4313112, upload-time = "2026-08-31T22:44:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2c/b0b2f887185d6a2ec0b3bef948cc07656d2d1a5d96fa7f2bb03f6ef06ca4/psycopg_binary-3.3.5-cp313-cp313-win_amd64.whl", hash = "sha256:ba466011569297114449df9d523438e1adeedf3e4f31ffb78e897ec3fef3076b", size = 3647313, upload-time = "2026-08-31T22:44:19.139Z" }, + { url = "https://files.pythonhosted.org/packages/46/7f/4e2395da194558533bd9c31f35e4dc58ecbbae6a7176b0d2f72d629e8a51/psycopg_binary-3.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f8b132c7243ef5f503f0b6f986bf16d38a51b0df1c6ba2577743f128be03e3", size = 4712612, upload-time = "2026-08-31T22:44:25.723Z" }, + { url = "https://files.pythonhosted.org/packages/ae/94/fdb2093c8ccd7048449156db526ad746740cf34fe9c01e5dc1b7a7a8b257/psycopg_binary-3.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c0cac998b9b1e82dec853d2e53b3d34d56a525cf231f9441a636cfd5992929a9", size = 4775139, upload-time = "2026-08-31T22:44:36.719Z" }, + { url = "https://files.pythonhosted.org/packages/31/52/5195e87960715f7be2005761b72d56fce4e7757d5333fd40384f071c2de1/psycopg_binary-3.3.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:479b96fd78149cfa10369dc53fbfb89ee729be13146b584a23dbc7e164c0cf1e", size = 5556807, upload-time = "2026-08-31T22:44:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/f7/42/2d616210a91e1327516ed5ae71961aaa31bb740e4a61d7190f2221685a40/psycopg_binary-3.3.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f45d77e398542ce0937d9fa3cd9d84e9c5fc6b34c50a66404ae840bada312750", size = 5236206, upload-time = "2026-08-31T22:44:47.943Z" }, + { url = "https://files.pythonhosted.org/packages/08/2e/e54b0d4cc263b3526e3728bb50a69660d5e79e52255ccd2a1a71e40e6f9a/psycopg_binary-3.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98a388509306e5e08a4203253ac52846bc1b034e5cbd0ae6da1211593cc28594", size = 6838066, upload-time = "2026-08-31T22:44:55.701Z" }, + { url = "https://files.pythonhosted.org/packages/77/80/ec22a110f81a44c411965982097efeee86006bdd5a1f51f628318106c84c/psycopg_binary-3.3.5-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab39e2794b95af61a2ff69e33e5ab6ac5df36e9ffea9a3b18e38b2aaca8c5ad5", size = 5072036, upload-time = "2026-08-31T22:45:02.838Z" }, + { url = "https://files.pythonhosted.org/packages/93/55/7bc3c3ac769ab4fe0c619f2179b4aab3ae043f4d478283dd8279d24c0be4/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9c071bf78e5c2e6efa40bc9089a954d7b41221347a72f35c6bf2d8c96e632f75", size = 4604058, upload-time = "2026-08-31T22:45:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/8e7414f7dc10b2959e664330cdcf393e412f67355975dafa876cef265264/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:14fdfd65a96ecbd8b586d14546105641f4a6ac7cbe335c786830ea4de94bbe60", size = 4284766, upload-time = "2026-08-31T22:45:17.881Z" }, + { url = "https://files.pythonhosted.org/packages/c1/72/33f293c1d3ee9114f47e9ef4880f31c9de864e84a9b09454c26e106c25ed/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8dbd694f3741dd4ac5bc60b70e17f7841aefb3f0f38cef4d2756de270e03af43", size = 4011958, upload-time = "2026-08-31T22:45:24.792Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c9/8e38840e5a7d006987bbc9acb29951912253fa50549a4db92b3aa535f089/psycopg_binary-3.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:14f432430fd9e1a9e7d9ab2fe14956c77f5d074ebdc556a1ad04e9a1bd3fca04", size = 4323273, upload-time = "2026-08-31T22:45:32.973Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/b7ebf601c307f93e7c4c4ebac0edc9db3b2729ca038efe700a18f86b5517/psycopg_binary-3.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:df209e64674a34b41662c67fdc8b4e0ffd77d2136393790691d086a09f9a6cab", size = 3745885, upload-time = "2026-08-31T22:45:40.537Z" }, +] + [[package]] name = "pydantic" version = "2.13.5" @@ -723,6 +900,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, + { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, +] + [[package]] name = "starlette" version = "1.6.0" @@ -765,6 +970,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + [[package]] name = "uvicorn" version = "0.52.4" diff --git a/docs/00-plano-de-projeto.md b/docs/00-plano-de-projeto.md index a670ac8..c05949b 100644 --- a/docs/00-plano-de-projeto.md +++ b/docs/00-plano-de-projeto.md @@ -184,6 +184,8 @@ ADR escrita para convencer, não para decidir. qualquer fornecedor de modelo - [`0008`](adr/0008-magalu-prosa-as-the-model-provider.md) — Magalu Prosa como fornecedor, com adaptador OpenAI-compatible +- [`0009`](adr/0009-a-case-is-a-document-a-match-is-rows.md) — caso é documento, + partida é linhas; solução em tabela própria Todas as ADRs são escritas em inglês (ADR-0006) — as quatro primeiras foram traduzidas depois de escritas. diff --git a/docs/08-achados.md b/docs/08-achados.md index 884459a..e13ceed 100644 --- a/docs/08-achados.md +++ b/docs/08-achados.md @@ -128,6 +128,18 @@ barato — contra uma VM que consome créditos dormindo. --- +### 2026-08-31 — Isolamento também se perde no SQL + +A solução ficou em tabela própria, não em coluna de `cases`. Como coluna, o +culpado viajaria em todo `SELECT *`, e a garantia duraria só até alguém escrever +a query conveniente ([#21](https://github.com/Madeuss/firenze/pull/21)). + +**Por que importa:** o mesmo argumento que separou `Case` de `Solution` no +domínio vale uma camada abaixo. Fronteira que existe só em um nível vaza no +outro. + +--- + ## Idioma ### 2026-08-29 — Gramática portuguesa vazou para dentro do modelo de domínio diff --git a/docs/adr/0009-a-case-is-a-document-a-match-is-rows.md b/docs/adr/0009-a-case-is-a-document-a-match-is-rows.md new file mode 100644 index 0000000..bc0a303 --- /dev/null +++ b/docs/adr/0009-a-case-is-a-document-a-match-is-rows.md @@ -0,0 +1,93 @@ +# ADR-0009: A case is a document, a match is rows + +## Status + +Accepted — 2026-08-31 + +## Context + +Matches have to survive a restart before there is an HTTP endpoint worth +writing, and before NPC memory can live in the same transaction as game state — +which is the whole argument of ADR-0002. + +The two things being stored are not alike. + +A **case** is generated from a seed, validated once, and then immutable. It is +always read whole: a briefing needs the cast, the facts and the timeline +together, and nothing ever queries "all facts in the cellar across every case". +Its shape also moves — the generator gained `setting` days after it gained +`interval_count`. + +A **match** is the opposite. Turns accumulate one at a time, statements are read +back per character to find contradictions (RN-021), and the budget changes on +every turn under whatever concurrency the future brings. + +## Decision + +**The case is a document.** One `cases` row with a JSONB column, plus the three +columns that identify it — seed, generator version, setting — as real columns +with a unique constraint over them. Normalising facts and cast into tables would +buy joins nobody performs and a migration every time the generator learns a new +field. + +**The match is relational.** `matches` and `statements` are ordinary tables with +foreign keys, because they are appended to, queried by character, and updated +under contention. + +**The solution gets its own table.** `Case` and `Solution` are separate entities +so that isolation is a type signature rather than a discipline (RN-011). The +same argument applies to storage: as a column on `cases`, the culprit would ride +along in every `SELECT *`, and the guarantee would last exactly as long as +nobody wrote a convenient query. In its own table, `load_case` **cannot** return +it — the read does not touch where it lives. + +**SQLAlchemy Core, synchronously.** Core rather than the ORM because these are +five queries over four tables and Pydantic already owns the domain objects; an +ORM would add a second object model to keep in agreement with the first. +Synchronous because the slow thing in a turn is the model call, not the +database, and FastAPI runs sync endpoints in a threadpool — an async driver +would add a second execution model to reason about for a saving that does not +show up at one player per match. + +**Storage tests run against Postgres**, in CI as a service container. A schema +using JSONB and a composite unique constraint, tested on SQLite, proves the +tests pass. + +## Consequences + ++ The case round-trips exactly, canary tokens and all, so a stored match + reproduces the mystery it started from. ++ The generator can gain fields without a migration. ++ Isolation survives into the persistence layer, where it is easiest to lose. ++ One transaction covers the statement, the stance and the budget, so a crash + cannot leave a match charged for an answer it never recorded (RN-030). ++ NPC memory can join match state in the same transaction when it arrives, which + is the reason ADR-0002 chose one database. +− A JSONB case is opaque to SQL: "which cases put someone in the cellar at + 22:00" is a scan, not an index. Acceptable while nothing asks; a generated + column would be the answer if something does. +− No schema validation on the document. A case written by an older generator + deserialises into whatever the current models accept, and `generator_version` + is what makes that detectable rather than silent. +− Synchronous I/O will need revisiting if a single process ever serves many + concurrent matches. The store is small enough that this is a rewrite of one + module. +− CI now needs a database service, which costs seconds per run and a moving part + that can fail on its own. + +## Alternatives considered + +- **Fully normalised, facts and cast as tables.** The obvious relational answer, + and it would make the case queryable. Rejected because nothing queries it: the + access pattern is "load this whole case", and the price is a migration for + every field the generator gains — which, at one generator change per week + lately, is the wrong side of the trade. +- **Documents all the way, matches in JSONB too.** Simplest to write and wrong + by the second concurrent turn: appending a statement would mean read, modify, + write, and losing one under a race. +- **SQLModel.** One class serving as both Pydantic model and table. Attractive + until the domain model wants to be frozen, computed and free of persistence + concerns — which this one is, deliberately. +- **Async SQLAlchemy with asyncpg.** The default reflex for FastAPI. Deferred: + it buys concurrency the workload does not have yet, at the cost of a second + execution model in every test and CLI path.