diff --git a/fire_planner/db.py b/fire_planner/db.py index 8cdc079..cdddcee 100644 --- a/fire_planner/db.py +++ b/fire_planner/db.py @@ -322,6 +322,44 @@ class RetirementGoal(Base): server_default=func.now()) +class FireExample(Base): + """One Reddit-sourced FIRE example. + + `reddit_id` UNIQUE makes re-ingest idempotent. Fields are nullable + when the LLM couldn't extract them confidently — never inferred. + Currency normalisation (portfolio_gbp / annual_exp_gbp) happens at + extraction time using `fire_planner/fx.py`; `raw_currency` is kept + for traceability. + """ + __tablename__ = "fire_example" + __table_args__ = {"schema": SCHEMA_NAME} # noqa: RUF012 + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + reddit_id: Mapped[str] = mapped_column(String(16), unique=True, nullable=False) + source_sub: Mapped[str] = mapped_column(String(64), nullable=False) + post_url: Mapped[str] = mapped_column(String, nullable=False) + post_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) + post_title: Mapped[str] = mapped_column(String, nullable=False) + country: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + city: Mapped[str | None] = mapped_column(String(128), nullable=True) + portfolio_gbp: Mapped[Decimal | None] = mapped_column(Numeric(14, 2), nullable=True) + annual_exp_gbp: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) + age: Mapped[int | None] = mapped_column(Integer, nullable=True) + family_size: Mapped[int | None] = mapped_column(Integer, nullable=True) + fi_status: Mapped[str | None] = mapped_column(String(24), nullable=True, index=True) + is_retired: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + raw_currency: Mapped[str | None] = mapped_column(String(3), nullable=True) + raw_excerpt: Mapped[str | None] = mapped_column(String, nullable=True) + llm_model: Mapped[str] = mapped_column(String(64), nullable=False) + llm_confidence: Mapped[Decimal | None] = mapped_column(Numeric(3, 2), nullable=True) + extracted_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), + nullable=False, + server_default=func.now()) + ingested_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), + nullable=False, + server_default=func.now()) + + def create_engine_from_env() -> AsyncEngine: url = os.environ["DB_CONNECTION_STRING"] return create_async_engine(url, pool_pre_ping=True) diff --git a/tests/test_examples_models.py b/tests/test_examples_models.py new file mode 100644 index 0000000..69b620e --- /dev/null +++ b/tests/test_examples_models.py @@ -0,0 +1,43 @@ +"""Schema test — FireExample ORM round-trips through the in-memory engine.""" +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from fire_planner.db import FireExample + + +@pytest.mark.asyncio +async def test_fire_example_round_trip(session: AsyncSession) -> None: + row = FireExample( + reddit_id="abc123", + source_sub="financialindependence", + post_url="https://reddit.com/r/financialindependence/abc123", + post_date=date(2026, 1, 1), + post_title="Hit £1m at 38, living in Manila", + country="Philippines", + city="Manila", + portfolio_gbp=Decimal("1000000.00"), + annual_exp_gbp=Decimal("14400.00"), + age=38, + family_size=2, + fi_status="FIRE", + is_retired=True, + raw_currency="GBP", + raw_excerpt="...£1m...Manila...", + llm_model="qwen3-8b", + llm_confidence=Decimal("0.82"), + ) + session.add(row) + await session.commit() + + result = await session.execute(select(FireExample).where(FireExample.reddit_id == "abc123")) + fetched = result.scalar_one() + assert fetched.country == "Philippines" + assert fetched.portfolio_gbp == Decimal("1000000.00") + assert fetched.fi_status == "FIRE" + assert fetched.is_retired is True