from __future__ import annotations import json import httpx from fastapi import APIRouter, HTTPException from fastapi.responses import JSONResponse from s2s_client.core import config as settings from s2s_client.core import logging as app_logging from s2s_client.models import requests as models_requests router = APIRouter() @router.post("/api/search") async def search(req: models_requests.SearchRequest) -> JSONResponse: """Proxy a Google search via Serper.dev. The key stays on the server unless the user brought their own (then theirs is used for this request only).""" query = (req.query or "").strip() if not query: raise HTTPException(status_code=400, detail="La consulta está vacía.") key = (req.key or "").strip() or settings.SERPER_KEY has_user_key = bool((req.key or "").strip()) if not key: # No server key and the user didn't supply one — search is unavailable. raise HTTPException(status_code=503, detail="La búsqueda no está configurada.") headers = {"X-API-KEY": key, "Content-Type": "application/json"} payload = {"q": query, "num": settings.MAX_RESULTS} app_logging.logger.debug( "[EXT][SERPER] search POST %s query=%s userKey=%s num=%d", settings.SERPER_URL, json.dumps(query, ensure_ascii=True)[:200], has_user_key, settings.MAX_RESULTS, ) try: async with httpx.AsyncClient(timeout=12.0) as http: resp = await http.post(settings.SERPER_URL, headers=headers, json=payload) except httpx.RequestError as exc: app_logging.logger.warning("Serper unreachable: %r", exc) raise HTTPException(status_code=502, detail="No se puede conectar con el proveedor de búsqueda.") # noqa: B904 app_logging.logger.debug( "[EXT][SERPER] search RESP status=%s len=%dB", resp.status_code, len(resp.content or b""), ) if resp.status_code != 200: # Serper's error body carries the real reason (e.g. "Not enough # credits") and contains no key, so it's safe to log and relay. body = resp.text[:300] app_logging.logger.warning("Serper error %s: %s", resp.status_code, body) msg = None try: msg = resp.json().get("message") except Exception: pass detail = f"Error del proveedor de búsqueda ({resp.status_code})" if msg: detail += f": {msg}" raise HTTPException(status_code=502, detail=detail) data = resp.json() results = [] for item in (data.get("organic") or [])[: settings.MAX_RESULTS]: results.append( { "title": item.get("title", ""), "snippet": item.get("snippet", ""), "url": item.get("link", ""), } ) # A direct answer when Google has one — saves the model a hop. box = data.get("answerBox") or {} answer = box.get("answer") or box.get("snippet") or None if not answer: kg = data.get("knowledgeGraph") or {} answer = kg.get("description") or None app_logging.logger.debug( "[EXT][SERPER] search PARSED hasAnswer=%s resultCount=%d titles=%s", bool(answer), len(results), [r.get("title", "")[:40] for r in results], ) return JSONResponse({"query": query, "answer": answer, "results": results})