""" 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, YIELD_LANDING_PROMPT_VERSION, VisionResult, YieldLandingConfig, generate_vision, generate_yield_landing, ) router = APIRouter(prefix="/llm", tags=["LLM Vision"]) class VisionResponse(BaseModel): domain: str cached: bool model: str prompt_version: str generated_at: str result: VisionResult class YieldLandingPreviewResponse(BaseModel): domain: str cached: bool model: str prompt_version: str generated_at: str result: YieldLandingConfig 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, ) @router.get("/yield/landing-preview", response_model=YieldLandingPreviewResponse) async def get_yield_landing_preview( current_user: CurrentUser, db: Database, domain: str = Query(..., min_length=3, max_length=255), refresh: bool = Query(False, description="Bypass cache and regenerate"), ): """ Generate a Yield landing page configuration preview for Terminal UX. Trader + Tycoon: allowed. Scout: blocked (upgrade teaser). """ 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 == "yield_landing_preview_v1", LLMArtifact.domain == normalized, LLMArtifact.prompt_version == YIELD_LANDING_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 = YieldLandingConfig.model_validate(payload) except Exception: cached = None else: return YieldLandingPreviewResponse( 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_yield_landing(normalized) except LLMGatewayError as e: raise HTTPException(status_code=502, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=f"Landing preview generation failed: {e}") artifact = LLMArtifact( user_id=current_user.id, kind="yield_landing_preview_v1", domain=normalized, prompt_version=YIELD_LANDING_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 YieldLandingPreviewResponse( domain=normalized, cached=False, model=model_used, prompt_version=YIELD_LANDING_PROMPT_VERSION, generated_at=now.isoformat(), result=result, )