Files
alvaro abd03ed07d refactor: rename API and frontend projects to Vocero
Rename the project directories, Python distribution and import package, and npm package. Update launcher, Docker, tooling, tests, lockfiles, and documentation while preserving existing API contracts.

Make AGENTS.md self-contained and remove external skill and workflow references.
2026-10-06 19:26:55 -03:00

93 lines
3.5 KiB
Python

from __future__ import annotations
import json
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from vocero_api.api.responses import validated_json_response
from vocero_api.core import config as settings
from vocero_api.core import logging as app_logging
from vocero_api.models import requests as models_requests
from vocero_api.models.responses import SearchResponse
router = APIRouter()
@router.post("/api/search", response_model=SearchResponse)
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 validated_json_response({"query": query, "answer": answer, "results": results}, SearchResponse)