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
134 lines
4.1 KiB
Python
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, 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,
|
|
)
|
|
|