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.
93 lines
3.5 KiB
Python
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)
|