pounce/backend/app/api/llm_vision.py
Yves Gugger f9e1da9ba0
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Deploy: 2025-12-17 16:37
2025-12-17 16:37:27 +01:00

134 lines
4.1 KiB
Python

"""
Vision API (Terminal-only).
- Trader + Tycoon: can generate Vision JSON (cached in DB)
- Scout: receives a 403 with an upgrade teaser message
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import and_, select
from app.api.deps import CurrentUser, Database
from app.models.llm_artifact import LLMArtifact
from app.models.subscription import Subscription, SubscriptionTier
from app.services.llm_gateway import LLMGatewayError
from app.services.llm_vision import VISION_PROMPT_VERSION, VisionResult, generate_vision
router = APIRouter(prefix="/llm", tags=["LLM Vision"])
class VisionResponse(BaseModel):
domain: str
cached: bool
model: str
prompt_version: str
generated_at: str
result: VisionResult
async def _get_or_create_subscription(db: Database, user_id: int) -> Subscription:
res = await db.execute(select(Subscription).where(Subscription.user_id == user_id))
sub = res.scalar_one_or_none()
if sub:
return sub
sub = Subscription(user_id=user_id, tier=SubscriptionTier.SCOUT, max_domains=5, check_frequency="daily")
db.add(sub)
await db.commit()
await db.refresh(sub)
return sub
def _require_trader_or_higher(sub: Subscription) -> None:
if sub.tier not in (SubscriptionTier.TRADER, SubscriptionTier.TYCOON):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Vision is available on Trader and Tycoon plans. Upgrade to unlock.",
)
@router.get("/vision", response_model=VisionResponse)
async def get_vision(
current_user: CurrentUser,
db: Database,
domain: str = Query(..., min_length=3, max_length=255),
refresh: bool = Query(False, description="Bypass cache and regenerate"),
):
sub = await _get_or_create_subscription(db, current_user.id)
_require_trader_or_higher(sub)
normalized = domain.strip().lower()
now = datetime.utcnow()
ttl_days = 30
if not refresh:
cached = (
await db.execute(
select(LLMArtifact)
.where(
and_(
LLMArtifact.kind == "vision_v1",
LLMArtifact.domain == normalized,
LLMArtifact.prompt_version == VISION_PROMPT_VERSION,
(LLMArtifact.expires_at.is_(None) | (LLMArtifact.expires_at > now)),
)
)
.order_by(LLMArtifact.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
if cached:
try:
payload = json.loads(cached.payload_json)
result = VisionResult.model_validate(payload)
except Exception:
# Corrupt cache: regenerate.
cached = None
else:
return VisionResponse(
domain=normalized,
cached=True,
model=cached.model,
prompt_version=cached.prompt_version,
generated_at=cached.created_at.isoformat(),
result=result,
)
try:
result, model_used = await generate_vision(normalized)
except LLMGatewayError as e:
raise HTTPException(status_code=502, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Vision generation failed: {e}")
artifact = LLMArtifact(
user_id=current_user.id,
kind="vision_v1",
domain=normalized,
prompt_version=VISION_PROMPT_VERSION,
model=model_used,
payload_json=result.model_dump_json(),
created_at=now,
updated_at=now,
expires_at=now + timedelta(days=ttl_days),
)
db.add(artifact)
await db.commit()
return VisionResponse(
domain=normalized,
cached=False,
model=model_used,
prompt_version=VISION_PROMPT_VERSION,
generated_at=now.isoformat(),
result=result,
)