""" 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, Depends, 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( domain: str = Query(..., min_length=3, max_length=255), refresh: bool = Query(False, description="Bypass cache and regenerate"), current_user: CurrentUser = Depends(), db: Database = Depends(), ): 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, )