""" 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"
{content}
" else: _html = f"
{content}
" _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("<", "<").replace(">", ">"), _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), }