universe: paid-gate + LLM fallback on /portfolio/parse
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
59b28506df
commit
8bc9dccd40
2 changed files with 80 additions and 3 deletions
|
|
@ -189,7 +189,7 @@ async def get_sparkline(
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.post("/portfolio/parse")
|
@router.post("/portfolio/parse", dependencies=[Depends(require_paid)])
|
||||||
async def parse_portfolio(
|
async def parse_portfolio(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
|
@ -210,8 +210,15 @@ async def parse_portfolio(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pie = parse_t212_csv(raw)
|
pie = parse_t212_csv(raw)
|
||||||
except CSVImportError as e:
|
except CSVImportError:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
# 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] = []
|
positions_out: list[dict] = []
|
||||||
yahoo_tickers: list[str] = []
|
yahoo_tickers: list[str] = []
|
||||||
|
|
|
||||||
|
|
@ -456,3 +456,73 @@ async def test_parse_with_llm_stale_mapping_raises_but_does_not_evict(tmp_path):
|
||||||
async with factory() as session:
|
async with factory() as session:
|
||||||
rows = (await session.execute(select(CsvFormatTemplate))).scalars().all()
|
rows = (await session.execute(select(CsvFormatTemplate))).scalars().all()
|
||||||
assert len(rows) == 1
|
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)"
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue