diff --git a/tests/fixtures/reddit/example_001.json b/tests/fixtures/reddit/example_001.json new file mode 100644 index 0000000..d1c2772 --- /dev/null +++ b/tests/fixtures/reddit/example_001.json @@ -0,0 +1,21 @@ +{ + "post": { + "reddit_id": "fx001", + "source_sub": "ExpatFIRE", + "url": "https://reddit.com/fx001", + "title": "Pulled the trigger — FIRE'd in Manila at 38", + "body": "Net worth $1.2M, wife + 1 kid, USD-denominated assets in VTI/BND. Expecting to spend ~$24k/yr. Retired last September.", + "created_at": "2026-01-15" + }, + "expected": { + "country": "Philippines", + "city": "Manila", + "portfolio_native": 1200000, + "annual_exp_native": 24000, + "raw_currency": "USD", + "age": 38, + "family_size": 3, + "fi_status": "FIRE", + "is_retired": true + } +} diff --git a/tests/fixtures/reddit/example_002.json b/tests/fixtures/reddit/example_002.json new file mode 100644 index 0000000..546f2a2 --- /dev/null +++ b/tests/fixtures/reddit/example_002.json @@ -0,0 +1,21 @@ +{ + "post": { + "reddit_id": "fx002", + "source_sub": "leanfire", + "url": "https://reddit.com/fx002", + "title": "Pulled the trigger in Manchester — leanFIRE at 44", + "body": "Net worth £620k, single, no kids. Spending ~£18k/yr in Manchester (UK). Not strictly retired — doing freelance one day a week. Lean by choice, not by force.", + "created_at": "2026-02-12" + }, + "expected": { + "country": "United Kingdom", + "city": "Manchester", + "portfolio_native": 620000, + "annual_exp_native": 18000, + "raw_currency": "GBP", + "age": 44, + "family_size": 1, + "fi_status": "leanFIRE", + "is_retired": false + } +} diff --git a/tests/fixtures/reddit/example_003.json b/tests/fixtures/reddit/example_003.json new file mode 100644 index 0000000..3b42786 --- /dev/null +++ b/tests/fixtures/reddit/example_003.json @@ -0,0 +1,21 @@ +{ + "post": { + "reddit_id": "fx003", + "source_sub": "coastFIRE", + "url": "https://reddit.com/fx003", + "title": "Coasting in Bali — $850k portfolio, still working remote", + "body": "Hit my coast number at 35: $850k invested, expecting to grow to FIRE by 50 without contributing. Living in Ubud, Indonesia with my partner. Spending around $30k/yr between us. Working remote part-time for fun.", + "created_at": "2026-03-05" + }, + "expected": { + "country": "Indonesia", + "city": "Bali", + "portfolio_native": 850000, + "annual_exp_native": 30000, + "raw_currency": "USD", + "age": 35, + "family_size": 2, + "fi_status": "coastFIRE", + "is_retired": false + } +} diff --git a/tests/fixtures/reddit/example_004.json b/tests/fixtures/reddit/example_004.json new file mode 100644 index 0000000..d9e353e --- /dev/null +++ b/tests/fixtures/reddit/example_004.json @@ -0,0 +1,21 @@ +{ + "post": { + "reddit_id": "fx004", + "source_sub": "EuropeFIRE", + "url": "https://reddit.com/fx004", + "title": "FIRE'd at 41 in Lisbon, Portugal", + "body": "Portfolio €1.05M, mostly accumulating VWCE. Retired 6 months ago. Wife and two kids — family of 4. Spending €38k/yr in Lisbon (Portugal), including rent. NHR visa locked in.", + "created_at": "2026-04-20" + }, + "expected": { + "country": "Portugal", + "city": "Lisbon", + "portfolio_native": 1050000, + "annual_exp_native": 38000, + "raw_currency": "EUR", + "age": 41, + "family_size": 4, + "fi_status": "FIRE", + "is_retired": true + } +} diff --git a/tests/fixtures/reddit/example_005.json b/tests/fixtures/reddit/example_005.json new file mode 100644 index 0000000..77b5a24 --- /dev/null +++ b/tests/fixtures/reddit/example_005.json @@ -0,0 +1,21 @@ +{ + "post": { + "reddit_id": "fx005", + "source_sub": "FIRE_Ind", + "url": "https://reddit.com/fx005", + "title": "Net worth update: ₹2.5 crore at 32", + "body": "Saved ₹2,50,00,000 (~$300k) net worth at 32 in Bangalore, India. Single. Still working at a product company. Spending ~₹12,00,000/yr. Target: ₹8 crore by 45 to FIRE.", + "created_at": "2026-05-01" + }, + "expected": { + "country": "India", + "city": "Bangalore", + "portfolio_native": 25000000, + "annual_exp_native": 1200000, + "raw_currency": "INR", + "age": 32, + "family_size": 1, + "fi_status": "accumulating", + "is_retired": false + } +} diff --git a/tests/test_examples_fixtures.py b/tests/test_examples_fixtures.py new file mode 100644 index 0000000..5fc1298 --- /dev/null +++ b/tests/test_examples_fixtures.py @@ -0,0 +1,52 @@ +"""Regression suite for LLM extraction — drives the extractor against +hand-curated fixtures and asserts the parsed JSON matches expectations. + +Each fixture is `{post: RawPost, expected: dict}`. The test does NOT +hit a live LLM — it mocks the response to return the *expected* JSON, +exercising the parser, validator, and currency-handling paths.""" +from __future__ import annotations + +import json +from pathlib import Path + +import httpx +import pytest +import respx + +from fire_planner.examples.llm_extract import extract_with_qwen +from fire_planner.examples.models import RawPost + +LLAMA_URL = "http://llama-cpp.llama-cpp.svc.cluster.local:8000/v1/chat/completions" + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "reddit" + + +def _fixtures() -> list[Path]: + return sorted(FIXTURE_DIR.glob("example_*.json")) + + +@respx.mock +@pytest.mark.asyncio +@pytest.mark.parametrize("fixture_path", _fixtures(), ids=lambda p: p.stem) +async def test_extractor_matches_fixture(fixture_path: Path) -> None: + data = json.loads(fixture_path.read_text()) + post = RawPost.model_validate(data["post"]) + expected = data["expected"] + expected_with_conf = {**expected, "confidence": 0.9} + + respx.post(LLAMA_URL).respond( + 200, + json={"choices": [{"message": {"content": json.dumps(expected_with_conf)}}]}, + ) + + async with httpx.AsyncClient() as client: + out = await extract_with_qwen(post, llama_url=LLAMA_URL, client=client) + + assert out is not None + for k, v in expected.items(): + actual = getattr(out, k) + if hasattr(actual, "__float__"): + assert float(actual) == float(v), f"{fixture_path.stem}: {k}" + else: + # Pydantic StrEnum compares equal to its string value + assert actual == v or str(actual) == v, f"{fixture_path.stem}: {k}"