examples: 5 hand-curated fixtures + parametrized regression suite
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
eb53f6dbb6
commit
9e14909ca6
6 changed files with 157 additions and 0 deletions
21
tests/fixtures/reddit/example_001.json
vendored
Normal file
21
tests/fixtures/reddit/example_001.json
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
21
tests/fixtures/reddit/example_002.json
vendored
Normal file
21
tests/fixtures/reddit/example_002.json
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
21
tests/fixtures/reddit/example_003.json
vendored
Normal file
21
tests/fixtures/reddit/example_003.json
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
21
tests/fixtures/reddit/example_004.json
vendored
Normal file
21
tests/fixtures/reddit/example_004.json
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
21
tests/fixtures/reddit/example_005.json
vendored
Normal file
21
tests/fixtures/reddit/example_005.json
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
52
tests/test_examples_fixtures.py
Normal file
52
tests/test_examples_fixtures.py
Normal file
|
|
@ -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}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue