refactor(server): adopt uv and modular typed FastAPI structure
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
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})
|
||||
Reference in New Issue
Block a user