From 8bc9dccd402c3e50d094ab3c5f2afb3a5bbc9cbb Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Wed, 27 May 2026 12:31:07 +0200 Subject: [PATCH] universe: paid-gate + LLM fallback on /portfolio/parse Co-Authored-By: Claude Opus 4.7 --- app/routers/universe.py | 13 +++++-- tests/test_llm_csv_parser.py | 70 ++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/app/routers/universe.py b/app/routers/universe.py index 561f419..bdb5ac7 100644 --- a/app/routers/universe.py +++ b/app/routers/universe.py @@ -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] = [] diff --git a/tests/test_llm_csv_parser.py b/tests/test_llm_csv_parser.py index e969c3e..239f8ae 100644 --- a/tests/test_llm_csv_parser.py +++ b/tests/test_llm_csv_parser.py @@ -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)" + )