universe: paid-gate + LLM fallback on /portfolio/parse

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-27 12:31:07 +02:00
parent 59b28506df
commit 8bc9dccd40
2 changed files with 80 additions and 3 deletions

View file

@ -189,7 +189,7 @@ async def get_sparkline(
# ---------------------------------------------------------------------------
@router.post("/portfolio/parse")
@router.post("/portfolio/parse", dependencies=[Depends(require_paid)])
async def parse_portfolio(
file: UploadFile = File(...),
session: AsyncSession = Depends(get_session),
@ -210,8 +210,15 @@ async def parse_portfolio(
try:
pie = parse_t212_csv(raw)
except CSVImportError as e:
raise HTTPException(status_code=400, detail=str(e))
except CSVImportError:
# Unrecognised format — try the LLM-fallback parser. It hits a
# global format-fingerprint cache first; only the very first
# upload of each broker format pays an LLM call.
from app.services.llm_csv_parser import LLMParseError, parse_with_llm
try:
pie = await parse_with_llm(raw, session)
except LLMParseError as e:
raise HTTPException(status_code=400, detail=str(e))
positions_out: list[dict] = []
yahoo_tickers: list[str] = []

View file

@ -456,3 +456,73 @@ async def test_parse_with_llm_stale_mapping_raises_but_does_not_evict(tmp_path):
async with factory() as session:
rows = (await session.execute(select(CsvFormatTemplate))).scalars().all()
assert len(rows) == 1
@pytest.mark.asyncio
async def test_parse_portfolio_route_falls_through_to_llm(tmp_path, monkeypatch):
"""End-to-end: T212 parser raises CSVImportError, LLM fallback runs,
response shape matches the existing JSON contract."""
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import AsyncMock
from fastapi import UploadFile
_, factory, setup = _build_session_factory(tmp_path)
await setup()
import app.services.llm_csv_parser as mod
from app.services.openrouter import LogResult
mod.call_llm = AsyncMock(return_value=LogResult(
content='{"ticker_col":"Symbol","qty_col":"Quantity",'
'"cost_col":"Avg Price","currency_col":"Currency",'
'"name_col":"Description",'
'"broker_label":"IBKR Activity Statement"}',
model="deepseek/deepseek-v4-flash",
prompt_tokens=150, completion_tokens=60, cost_usd=0.0003,
))
# The route's inline Yahoo-fetch block would otherwise hit the network.
# Patch market.fetch to return a benign placeholder per ticker.
from app.services import market as market_mod
async def _fake_fetch(client, symbol, label, group, anchor):
return SimpleNamespace(
symbol=symbol, source="test", label=label,
price=None, currency="USD", as_of="2026-05-27",
changes=None, error=None,
)
monkeypatch.setattr(market_mod, "fetch", _fake_fetch)
raw = open("tests/fixtures/ibkr_sample.csv", "rb").read()
upload = UploadFile(filename="ibkr.csv", file=BytesIO(raw))
from app.routers.universe import parse_portfolio
async with factory() as session:
result = await parse_portfolio(file=upload, session=session)
assert result["base_currency"] == "GBP"
# At least the AAPL/MSFT/NVDA rows should be present; resolve_slice may
# filter some if there's no InstrumentMap row, which is fine for this
# test — we just want to confirm the LLM fallback ran end-to-end.
assert isinstance(result["positions"], list)
# LLM was called exactly once (cache miss).
assert mod.call_llm.await_count == 1
def test_parse_portfolio_route_requires_paid():
"""Static check that the /portfolio/parse route is gated by require_paid."""
from app.routers.universe import router
from app.services.access import require_paid
parse_route = next(
r for r in router.routes
if getattr(r, "path", "") == "/portfolio/parse"
)
# FastAPI stores each Depends(...) as a Dependant whose `.call` attribute
# is the wrapped callable (`.dependency` is the older name, removed in
# recent FastAPI versions).
dep_callables = [d.call for d in parse_route.dependant.dependencies]
assert require_paid in dep_callables, (
"The /portfolio/parse route must have Depends(require_paid)"
)