pounce/backend/app/services/llm_vision.py
Yves Gugger 5a1fcb30dd
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
Forge: prominent AI mode selector, improved contrast throughout Hunt tabs
2025-12-17 16:28:45 +01:00

167 lines
5.4 KiB
Python

"""
Vision + Yield landing generation via internal LLM gateway.
Outputs MUST be strict JSON to support caching + UI rendering.
"""
from __future__ import annotations
import json
from typing import Any
from pydantic import BaseModel, Field, ValidationError, conint
from app.config import get_settings
from app.services.llm_gateway import chat_completions
settings = get_settings()
VISION_PROMPT_VERSION = "v1"
YIELD_LANDING_PROMPT_VERSION = "v1"
class VisionResult(BaseModel):
business_concept: str = Field(..., min_length=10, max_length=240)
industry_vertical: str = Field(..., min_length=2, max_length=60)
buyer_persona: str = Field(..., min_length=5, max_length=120)
cold_email_subject: str = Field(..., min_length=5, max_length=120)
cold_email_body: str = Field(..., min_length=20, max_length=800)
monetization_idea: str = Field(..., min_length=10, max_length=240)
radio_test_score: conint(ge=1, le=10) # type: ignore[valid-type]
reasoning: str = Field(..., min_length=20, max_length=800)
class YieldLandingConfig(BaseModel):
template: str = Field(..., min_length=2, max_length=50) # e.g. "nature", "commerce", "tech"
headline: str = Field(..., min_length=10, max_length=180)
seo_intro: str = Field(..., min_length=80, max_length=800)
cta_label: str = Field(..., min_length=4, max_length=60)
niche: str = Field(..., min_length=2, max_length=60)
color_scheme: str = Field(..., min_length=2, max_length=30)
def _extract_first_json_object(text: str) -> str:
"""
Extract the first {...} JSON object from text.
We do NOT generate fallback content; if parsing fails, caller must raise.
"""
s = (text or "").strip()
if not s:
raise ValueError("Empty LLM response")
if s.startswith("{") and s.endswith("}"):
return s
start = s.find("{")
end = s.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError("LLM response is not JSON")
return s[start : end + 1]
async def generate_vision(domain: str) -> tuple[VisionResult, str]:
"""
Returns (VisionResult, model_used).
"""
model = settings.llm_default_model
system = (
"You are the Pounce AI, a domain intelligence engine.\n"
"You must respond with STRICT JSON only. No markdown. No commentary.\n"
"Language: English.\n"
"If a field is unknown, make a best-effort realistic assumption.\n"
)
user = (
f"Analyze domain '{domain}'.\n"
"Act as a VC + domain broker.\n"
"Create a realistic business concept and a buyer/outreach angle.\n"
"Output STRICT JSON with exactly these keys:\n"
"{\n"
' "business_concept": "...",\n'
' "industry_vertical": "...",\n'
' "buyer_persona": "...",\n'
' "cold_email_subject": "...",\n'
' "cold_email_body": "...",\n'
' "monetization_idea": "...",\n'
' "radio_test_score": 1,\n'
' "reasoning": "..."\n'
"}\n"
)
payload: dict[str, Any] = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.6,
"stream": False,
}
res = await chat_completions(payload)
content = (
res.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
json_str = _extract_first_json_object(str(content))
try:
data = json.loads(json_str)
except Exception as e:
raise ValueError(f"Failed to parse LLM JSON: {e}") from e
try:
return VisionResult.model_validate(data), model
except ValidationError as e:
raise ValueError(f"LLM JSON schema mismatch: {e}") from e
async def generate_yield_landing(domain: str) -> tuple[YieldLandingConfig, str]:
"""
Returns (YieldLandingConfig, model_used).
"""
model = settings.llm_default_model
system = (
"You are the Pounce AI, a domain monetization engine.\n"
"You must respond with STRICT JSON only. No markdown. No commentary.\n"
"Language: English.\n"
"Write helpful, non-spammy copy. Avoid medical/legal claims.\n"
)
user = (
f"Analyze domain '{domain}'.\n"
"Goal: create a minimal SEO-friendly landing page plan that can route visitors to an affiliate offer.\n"
"Output STRICT JSON with exactly these keys:\n"
"{\n"
' "template": "tech|commerce|finance|nature|local|generic",\n'
' "headline": "...",\n'
' "seo_intro": "...",\n'
' "cta_label": "...",\n'
' "niche": "...",\n'
' "color_scheme": "..." \n'
"}\n"
"Keep seo_intro 120-220 words.\n"
)
payload: dict[str, Any] = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.5,
"stream": False,
}
res = await chat_completions(payload)
content = (
res.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
json_str = _extract_first_json_object(str(content))
try:
data = json.loads(json_str)
except Exception as e:
raise ValueError(f"Failed to parse LLM JSON: {e}") from e
try:
return YieldLandingConfig.model_validate(data), model
except ValidationError as e:
raise ValueError(f"LLM JSON schema mismatch: {e}") from e