Terminal / api /vision.py
Pulka
feat(telegram): bot UX 100% — WebApp button, /logs, /ping, inline query, _BACK_KB
4cbd6c4 verified
Raw
History Blame
23.6 kB
"""
vision.py — Generazione e analisi immagini + ricerca immagini.
Endpoints:
POST /api/vision/generate — FLUX.1-schnell (HF Inference API)
POST /api/vision/analyze — Groq llama-3.2-vision / GPT-4o-mini / BLIP fallback
GET /api/vision/search — Pexels > Pixabay > Unsplash Source (zero API key)
Problematiche HF Inference API:
- 503 "loading": cold-start fino a 60s → retry con backoff
- Output generate: raw bytes PNG (non JSON)
- BLIP: captioning solo, non risponde a domande aperte
- Rate limit senza HF_TOKEN: ~10 req/hr per IP
Fallback chain analyze_image:
1. Groq llama-3.2-11b-vision-preview (free tier, veloce, richiede GROQ_API_KEY)
2. GPT-4o-mini vision (richiede OPENAI_API_KEY)
3. BLIP-large captioning (HF Inference, libero ma solo didascalia)
"""
import asyncio, base64, os, httpx, logging
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(prefix="/api/vision", tags=["vision"])
_logger = logging.getLogger("vision")
_HF_API = "https://api-inference.huggingface.co"
_USER_AGENT = "Mozilla/5.0 (compatible; AgenteAI/3.0)"
_MODEL_MAP: dict[str, str] = {
"FLUX.1-schnell": "black-forest-labs/FLUX.1-schnell",
"FLUX.1-dev": "black-forest-labs/FLUX.1-dev",
"sdxl": "stabilityai/stable-diffusion-xl-base-1.0",
"flux": "black-forest-labs/FLUX.1-schnell",
"flux-schnell": "black-forest-labs/FLUX.1-schnell",
}
def _hf_headers(content_type: str = "application/json") -> dict:
token = os.getenv("HF_TOKEN", "")
h = {"User-Agent": _USER_AGENT, "Content-Type": content_type}
if token:
h["Authorization"] = f"Bearer {token}"
return h
# ─── Models ───────────────────────────────────────────────────────────────────
class GenerateImageRequest(BaseModel):
prompt: str
negative_prompt: str = ""
width: int = 512
height: int = 512
steps: int = 4
model: str = "FLUX.1-schnell"
save_path: str = ""
class AnalyzeImageRequest(BaseModel):
url: str = ""
base64_image: str = ""
question: str = "Descrivi questa immagine in dettaglio in italiano."
# ─── /generate ────────────────────────────────────────────────────────────────
@router.post("/generate")
async def generate_image(req: GenerateImageRequest):
"""
Genera immagine via HF Inference API (FLUX.1-schnell default).
Strategia retry:
- Se il modello è in cold-start (503), aspetta estimated_time (max 45s)
e riprova una volta sola. Due tentativi totali.
- HF restituisce raw bytes PNG — non JSON.
- steps ottimali FLUX.1-schnell: 4 (veloce) – 8 (qualità).
"""
model_id = _MODEL_MAP.get(req.model, "black-forest-labs/FLUX.1-schnell")
url = f"{_HF_API}/models/{model_id}"
payload: dict = {"inputs": req.prompt.strip()[:400]}
params: dict = {"num_inference_steps": min(max(req.steps, 1), 8)}
if req.width != 512: params["width"] = min(max(req.width, 256), 1024)
if req.height != 512: params["height"] = min(max(req.height, 256), 1024)
if req.negative_prompt:
params["negative_prompt"] = req.negative_prompt[:200]
payload["parameters"] = params
async with httpx.AsyncClient(timeout=90) as client:
for attempt in range(2):
try:
r = await client.post(url, headers=_hf_headers(), json=payload)
if r.status_code == 200:
b64 = base64.b64encode(r.content).decode()
return {
"ok": True, "image_b64": b64, "mime": "image/png",
"model": req.model, "prompt": req.prompt[:100],
}
if r.status_code == 503 and attempt == 0:
try:
wait = min(float(r.json().get("estimated_time", 20)), 45)
except Exception:
wait = 20
_logger.info("HF model loading, waiting %.0fs…", wait)
await asyncio.sleep(wait)
continue
try:
err = r.json().get("error", r.text[:200])
except Exception:
err = r.text[:200]
return {
"ok": False, "error": f"HF API {r.status_code}: {err}",
"hint": "Aggiungi HF_TOKEN nelle variabili d'ambiente per più richieste/ora.",
}
except httpx.TimeoutException:
return {"ok": False, "error": "Timeout 90s — modello in cold-start. Riprova tra 30s."}
except Exception as e:
return {"ok": False, "error": str(e)[:300]}
return {"ok": False, "error": "Impossibile generare dopo 2 tentativi."}
# ─── /analyze ─────────────────────────────────────────────────────────────────
@router.post("/analyze")
async def analyze_image(req: AnalyzeImageRequest):
"""
Analizza immagine con vision LLM.
Chain:
1. Groq llama-3.2-11b-vision (free tier, 30 img/min)
2. GPT-4o-mini vision
3. BLIP-large captioning (HF, puro captioning senza Q&A)
"""
# Scarica immagine se URL
image_b64 = req.base64_image
image_mime = "image/jpeg"
if not image_b64 and req.url:
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
r = await c.get(req.url, headers={"User-Agent": _USER_AGENT})
if r.status_code == 200:
ct = r.headers.get("content-type", "")
image_mime = "image/png" if "png" in ct else "image/webp" if "webp" in ct else "image/jpeg"
image_b64 = base64.b64encode(r.content).decode()
else:
return {"ok": False, "error": f"Download immagine fallito: HTTP {r.status_code}"}
except Exception as e:
return {"ok": False, "error": f"Errore download: {str(e)[:200]}"}
if not image_b64:
return {"ok": False, "error": "Nessuna immagine (url o base64_image richiesti)."}
img_data_url = f"data:{image_mime};base64,{image_b64}"
question = req.question.strip() or "Descrivi questa immagine in dettaglio in italiano."
vision_body_msgs = [{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": img_data_url}},
{"type": "text", "text": question},
]}]
# 1. Groq vision (free tier)
_groq_key = os.getenv("GROQ_API_KEY", "")
if _groq_key:
try:
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {_groq_key}", "Content-Type": "application/json"},
json={"model": "llama-3.2-11b-vision-preview", "max_tokens": 600,
"messages": vision_body_msgs},
)
if r.status_code == 200:
# S750-GAP-H: guard choices[] — Groq può ritornare {"error":"rate_limit"}
_chs = r.json().get("choices") or []
_desc = (_chs[0].get("message",{}).get("content") or "") if _chs else ""
if _desc:
return {"ok": True, "description": _desc, "provider": "llama-3.2-vision"}
except Exception as _e:
_logger.debug("analyze_image: groq vision failed (%s)", type(_e).__name__)
# 2. Gemini Vision (free tier — GEMINI_API_KEY da aistudio.google.com)
# GAP-TOOL-2-fix: Gemini 1.5 Flash supporta vision, è gratuito su AI Studio, non richiede dominio.
# Inserito prima di GPT-4o-mini (paid) come primo fallback gratuito di Groq.
_gemini_key = os.getenv("GEMINI_API_KEY", "")
if _gemini_key:
try:
_g_payload = {
"contents": [{
"parts": [
{"inline_data": {"mime_type": image_mime, "data": image_b64}},
{"text": question},
]
}],
"generationConfig": {"maxOutputTokens": 600},
}
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(
f"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={_gemini_key}",
headers={"Content-Type": "application/json"},
json=_g_payload,
)
if r.status_code == 200:
_cands = r.json().get("candidates") or []
_parts = (_cands[0].get("content", {}).get("parts") or []) if _cands else []
_desc_g = next((p.get("text", "") for p in _parts if "text" in p), "")
if _desc_g:
return {"ok": True, "description": _desc_g, "provider": "gemini-1.5-flash"}
except Exception as _e:
_logger.debug("analyze_image: gemini vision failed (%s)", type(_e).__name__)
# 3. OpenAI GPT-4o-mini vision
_openai_key = os.getenv("OPENAI_API_KEY", "")
_openai_base = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/")
if _openai_key:
try:
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(
f"{_openai_base}/chat/completions",
headers={"Authorization": f"Bearer {_openai_key}", "Content-Type": "application/json"},
json={"model": "gpt-4o-mini", "max_tokens": 600, "messages": vision_body_msgs},
)
if r.status_code == 200:
# S750-GAP-H: guard choices[] — provider può ritornare {"error":...}
_chs2 = r.json().get("choices") or []
_desc2 = (_chs2[0].get("message",{}).get("content") or "") if _chs2 else ""
if _desc2:
return {"ok": True, "description": _desc2, "provider": "gpt-4o-mini"}
except Exception as _e:
_logger.debug("analyze_image: openai vision failed (%s)", type(_e).__name__)
# 4. HF BLIP-large (captioning only — ultimo fallback)
try:
img_bytes = base64.b64decode(image_b64)
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(
f"{_HF_API}/models/Salesforce/blip-image-captioning-large",
headers={k: v for k, v in _hf_headers("application/octet-stream").items()},
content=img_bytes,
)
if r.status_code == 200:
results = r.json()
caption = (results[0].get("generated_text", "") if isinstance(results, list) and results else "")
if caption:
note = ("\n\n_BLIP fornisce solo didascalia base. Per Q&A su immagini, "
"aggiungi GROQ_API_KEY (gratuito su console.groq.com)._")
return {"ok": True, "description": caption + note, "provider": "blip-large"}
elif r.status_code == 503:
return {"ok": False, "error": "BLIP in avvio (cold-start ~30s). Riprova tra qualche secondo.",
"hint": "Aggiungi GROQ_API_KEY per analisi rapida e senza limiti di cold-start."}
except Exception as _e:
_logger.debug("analyze_image: blip captioning failed (%s)", type(_e).__name__)
return {
"ok": False, "error": "Analisi immagini non disponibile.",
"hint": ("Aggiungi GROQ_API_KEY (free su console.groq.com) o OPENAI_API_KEY "
"nelle variabili del tuo HF Space."),
}
# ─── /search ──────────────────────────────────────────────────────────────────
@router.get("/search")
async def search_images(query: str, limit: int = 5, safe: bool = True):
"""
Ricerca immagini: Pexels > Pixabay > Unsplash Source fallback.
Pexels API (PEXELS_API_KEY, gratuita 200 req/hr):
https://www.pexels.com/api/
Pixabay API (PIXABAY_API_KEY, gratuita 500 req/hr):
https://pixabay.com/api/docs/
Unsplash Source (zero API key — fallback):
URL deterministica ma rilevante per la query.
Non è una vera ricerca — genera varianti random per lo stesso query.
"""
n = min(max(int(limit), 1), 10)
# Pexels
_pexels = os.getenv("PEXELS_API_KEY", "")
if _pexels:
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(
"https://api.pexels.com/v1/search",
headers={"Authorization": _pexels},
params={"query": query, "per_page": n, "size": "medium"},
)
if r.status_code == 200:
photos = r.json().get("photos", [])
return {"ok": True, "source": "pexels", "results": [
{"url": p["src"]["medium"], "thumb": p["src"]["tiny"],
"alt": p.get("alt", query), "source": "Pexels",
"page_url": p["url"], "author": p["photographer"]}
for p in photos
]}
except Exception as _exc:
_logger.debug("[vision] silenced %s", type(_exc).__name__) # noqa: BLE001
# Pixabay
_pixabay = os.getenv("PIXABAY_API_KEY", "")
if _pixabay:
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(
"https://pixabay.com/api/",
params={"key": _pixabay, "q": query, "per_page": n,
"image_type": "photo", "safesearch": "true" if safe else "false"},
)
if r.status_code == 200:
return {"ok": True, "source": "pixabay", "results": [
{"url": h["webformatURL"], "thumb": h["previewURL"],
"alt": h.get("tags", query), "source": "Pixabay",
"page_url": h["pageURL"], "author": h["user"]}
for h in r.json().get("hits", [])
]}
except Exception as _exc:
_logger.debug("[vision] silenced %s", type(_exc).__name__) # noqa: BLE001
# Unsplash Source fallback (zero key — random ma rilevante)
sq = query.replace(" ", ",")[:80]
return {
"ok": True, "source": "unsplash_source",
"note": "Usa PEXELS_API_KEY o PIXABAY_API_KEY per ricerca precisa.",
"results": [
{"url": f"https://source.unsplash.com/600x400/?{sq}&sig={i}",
"thumb": f"https://source.unsplash.com/200x150/?{sq}&sig={i}",
"alt": f"{query} — immagine {i + 1}",
"source": "Unsplash", "page_url": f"https://unsplash.com/s/photos/{sq}",
"author": "Unsplash"}
for i in range(n)
],
}
class ScreenshotRequest(BaseModel):
url: str
width: int = 1280
height: int = 900
mobile: bool = False
wait: float = 2.0 # secondi di attesa dopo il caricamento
full_page: bool = False
@router.post("/screenshot")
async def screenshot_url(req: ScreenshotRequest):
"""
Screenshot Playwright (Chromium headless) di qualsiasi URL.
Restituisce PNG come data URL base64.
Fallback automatico a Microlink se Playwright non disponibile.
"""
try:
from playwright.async_api import async_playwright # noqa: PLC0415
except ImportError:
return {"ok": False, "error": "playwright non installato sul server.", "fallback": "microlink"}
w = min(max(int(req.width) or 1280, 320), 2560)
h = min(max(int(req.height) or 900, 240), 1440)
try:
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--single-process"],
)
ctx = await browser.new_context(
viewport={"width": 375 if req.mobile else w, "height": 812 if req.mobile else h},
device_scale_factor=2 if req.mobile else 1,
user_agent=(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"
if req.mobile else
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36"
),
)
page = await ctx.new_page()
await page.goto(str(req.url), wait_until="domcontentloaded", timeout=20_000)
if req.wait > 0:
await asyncio.sleep(min(float(req.wait), 5.0))
png_bytes = await page.screenshot(full_page=req.full_page, type="png")
await browser.close()
data_url = "data:image/png;base64," + base64.b64encode(png_bytes).decode()
return {
"ok": True,
"image_url": data_url,
"width": 375 if req.mobile else w,
"height": 812 if req.mobile else h,
"device": "mobile" if req.mobile else "desktop",
}
except Exception as e:
_logger.warning("screenshot %s: playwright failed: %s — trying Microlink", req.url, str(e)[:80])
# S601: Microlink fallback — chiamata reale invece di semplice errore
try:
import httpx as _httpx_ml
async with _httpx_ml.AsyncClient(timeout=15) as _mc:
_ml_r = await _mc.get(
"https://api.microlink.io/",
params={
"url": str(req.url),
"screenshot": "true",
"meta": "false",
"embed": "screenshot.url",
"viewport.width": 375 if req.mobile else w,
"viewport.height": 812 if req.mobile else h,
},
headers={"User-Agent": "agente-ai/3.2"},
)
if _ml_r.status_code == 200:
_ml_data = _ml_r.json()
_img_url = (_ml_data.get("data") or {}).get("screenshot", {}).get("url") or _ml_data.get("data")
if isinstance(_img_url, str) and _img_url.startswith("http"):
# Download image and convert to base64 data URL
_img_resp = await _mc.get(_img_url, timeout=10)
if _img_resp.status_code == 200:
_ct = _img_resp.headers.get("content-type", "image/jpeg")
_data_url = f"data:{_ct};base64," + base64.b64encode(_img_resp.content).decode()
return {
"ok": True,
"image_url": _data_url,
"width": 375 if req.mobile else w,
"height": 812 if req.mobile else h,
"device": "mobile" if req.mobile else "desktop",
"source": "microlink",
}
except Exception as _ml_exc:
_logger.warning("screenshot %s: microlink also failed: %s", req.url, str(_ml_exc)[:80])
return {"ok": False, "error": str(e)[:200], "fallback": "microlink_unavailable"}
# ── PDF Generation ─────────────────────────────────────────────────────────────
class PdfRequest(BaseModel):
content: str
filename: str = "documento.pdf"
format: str = "html" # html | markdown | text
@router.post("/pdf")
async def create_pdf(req: PdfRequest):
"""
S601: Genera PDF da HTML, Markdown o testo.
Tenta WeasyPrint → reportlab → risposta JSON+base64 del contenuto raw.
Restituisce {ok, pdf_b64, filename, size_bytes} oppure {ok: false, error}.
"""
import base64 as _b64
_original_len = len(req.content)
content = req.content[:80_000]
_truncated = _original_len > 80_000 # S724-PDF-3: segnala troncamento nel response
# 1. WeasyPrint (miglior qualità HTML→PDF)
try:
from weasyprint import HTML as _WP_HTML, CSS as _WP_CSS # noqa: PLC0415
if req.format == "html":
_html = content
elif req.format == "markdown":
try:
import markdown as _md # noqa: PLC0415
_html = _md.markdown(content, extensions=["tables", "fenced_code"])
except ImportError:
_html = f"<pre>{content}</pre>"
else:
_html = f"<pre style='font-family:monospace;white-space:pre-wrap'>{content}</pre>"
_pdf_bytes = _WP_HTML(string=_html).write_pdf()
_r = {"ok": True, "pdf_b64": _b64.b64encode(_pdf_bytes).decode(),
"filename": req.filename, "size_bytes": len(_pdf_bytes), "engine": "weasyprint"}
if _truncated: _r.update({"truncated": True, "original_length": _original_len}) # S724-PDF-3
return _r
except ImportError:
pass # WeasyPrint non installato — prova reportlab
except Exception as _wp_exc:
_logger.warning("pdf weasyprint: %s", str(_wp_exc)[:120])
# 2. reportlab (fallback leggero)
try:
from reportlab.lib.pagesizes import A4 # noqa: PLC0415
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer # noqa: PLC0415
from reportlab.lib.styles import getSampleStyleSheet # noqa: PLC0415
import io as _io
_buf = _io.BytesIO()
_doc = SimpleDocTemplate(_buf, pagesize=A4)
_styles = getSampleStyleSheet()
_story = []
for line in content.split("\n"):
if line.strip():
_story.append(Paragraph(line.replace("<", "&lt;").replace(">", "&gt;"), _styles["Normal"]))
else:
_story.append(Spacer(1, 12))
_doc.build(_story)
_pdf_bytes = _buf.getvalue()
_r = {"ok": True, "pdf_b64": _b64.b64encode(_pdf_bytes).decode(),
"filename": req.filename, "size_bytes": len(_pdf_bytes), "engine": "reportlab"}
if _truncated: _r.update({"truncated": True, "original_length": _original_len}) # S724-PDF-3
return _r
except ImportError:
pass
except Exception as _rl_exc:
_logger.warning("pdf reportlab: %s", str(_rl_exc)[:120])
# 3. Graceful degradation — restituisce il contenuto raw encodato
_raw = content.encode("utf-8")
return {
"ok": False,
"error": "Nessun engine PDF disponibile (WeasyPrint/reportlab non installati). Contenuto allegato come testo.",
"content_b64": _b64.b64encode(_raw).decode(),
"filename": req.filename.replace(".pdf", ".txt"),
"size_bytes": len(_raw),
}