Spaces:
Configuration error
Configuration error
Pulka
feat(telegram): bot UX 100% — WebApp button, /logs, /ping, inline query, _BACK_KB
4cbd6c4 verified | """ | |
| 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 ──────────────────────────────────────────────────────────────── | |
| 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 ───────────────────────────────────────────────────────────────── | |
| 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 ────────────────────────────────────────────────────────────────── | |
| 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 | |
| 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 | |
| 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("<", "<").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), | |
| } | |