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.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""Validate service-owned response structure without rewriting the wire body."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
def validated_json_response(
|
||||
content: Any,
|
||||
model: type[BaseModel],
|
||||
*,
|
||||
status_code: int = 200,
|
||||
) -> JSONResponse:
|
||||
model.model_validate(content)
|
||||
# model_dump would insert defaults, potentially coerce values, and reorder
|
||||
# fields. Keeping the original JSONResponse serialization is intentional.
|
||||
return JSONResponse(content=content, status_code=status_code)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Register routes in their original order without changing public paths."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from vocero_api.api.routes import apipilot, caller, config, search, sessions
|
||||
from vocero_api.models.responses import ReasoningResponse
|
||||
from vocero_api.realtime.proxy import realtime_proxy
|
||||
from vocero_api.services.reasoning import reasoning
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(config.router)
|
||||
router.include_router(apipilot.router)
|
||||
router.add_api_route("/api/reasoning", reasoning, methods=["POST"], response_model=ReasoningResponse)
|
||||
router.add_api_websocket_route("/v1/realtime", realtime_proxy)
|
||||
router.include_router(caller.router)
|
||||
router.include_router(search.router)
|
||||
router.include_router(sessions.router)
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
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 ToolCatalogResponse, ToolExecutionResponse
|
||||
from vocero_api.providers import apipilot as providers_apipilot
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/apipilot/tools", response_model=ToolCatalogResponse)
|
||||
async def apipilot_list_tools(
|
||||
request: Request,
|
||||
include_unauthorized: bool = True,
|
||||
refresh: bool = False,
|
||||
) -> JSONResponse:
|
||||
"""Public proxy for ApiPilot tool discovery (guide §2.1).
|
||||
|
||||
Forwards Authorization / x-demo-session headers from the caller to the
|
||||
upstream backend verbatim and returns the list of `ToolDefinition` records
|
||||
exactly as documented:
|
||||
|
||||
{ "name": "...", "description": "...", "parameters": {...}, "requires_auth": true/false }
|
||||
|
||||
A short in-process TTL cache is applied (default 5 min, configurable via
|
||||
S2S_APIPILOT_TTL_SEC). The client may force a refresh via `?refresh=true`
|
||||
(e.g. after a user just logged in and previously saw only requires_auth tools)."""
|
||||
if not settings.S2S_APIPILOT_TOOLS_URL:
|
||||
return validated_json_response(
|
||||
model=ToolCatalogResponse,
|
||||
status_code=501,
|
||||
content={
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": {
|
||||
"code": "APIPILOT_NOT_CONFIGURED",
|
||||
"message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
|
||||
"status_code": 501,
|
||||
"raw": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
auth, demo = providers_apipilot._apipilot_parse_authorization(request)
|
||||
try:
|
||||
tools = await providers_apipilot._apipilot_fetch_tools(
|
||||
auth_header=auth,
|
||||
demo_session=demo,
|
||||
include_unauthorized=bool(include_unauthorized),
|
||||
force_refresh=bool(refresh),
|
||||
)
|
||||
except HTTPException as exc:
|
||||
# Normalize to envelope-style payload so downstream code is uniform.
|
||||
detail = (
|
||||
exc.detail
|
||||
if isinstance(exc.detail, dict)
|
||||
else {
|
||||
"code": "APIPILOT_ERROR",
|
||||
"message": str(exc.detail) if exc.detail else "Error",
|
||||
"status_code": exc.status_code,
|
||||
"raw": None,
|
||||
}
|
||||
)
|
||||
return validated_json_response(
|
||||
model=ToolCatalogResponse,
|
||||
status_code=exc.status_code,
|
||||
content={"success": False, "data": None, "error": detail},
|
||||
)
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT] /api/apipilot/tools OK tools=%d include_unauthorized=%s refresh=%s",
|
||||
len(tools),
|
||||
bool(include_unauthorized),
|
||||
bool(refresh),
|
||||
)
|
||||
return validated_json_response(
|
||||
{
|
||||
"success": True,
|
||||
"data": {
|
||||
"count": len(tools),
|
||||
"include_unauthorized": bool(include_unauthorized),
|
||||
"tools": tools,
|
||||
},
|
||||
"error": None,
|
||||
},
|
||||
ToolCatalogResponse,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/apipilot/execute", response_model=ToolExecutionResponse)
|
||||
async def apipilot_execute(request: Request, body: models_requests.ApipilotExecuteRequest) -> JSONResponse:
|
||||
"""Public proxy for ApiPilot /execute (guide §2.2).
|
||||
|
||||
Accepts `{name, arguments}`, runs the mandatory PRE-CHECK flow (fresh tool
|
||||
discovery, `requires_auth` gate, strict JSON-Schema argument validation),
|
||||
forwards the request to the upstream backend, and returns the envelope
|
||||
`{success, data, error}`.
|
||||
|
||||
Show only `error.message` to the end user. `error.code`, `error.status_code`
|
||||
and `error.raw` are for internal auditing (guide §2.2, recommended UI errors)."""
|
||||
if not settings.S2S_APIPILOT_TOOLS_URL:
|
||||
return validated_json_response(
|
||||
model=ToolExecutionResponse,
|
||||
status_code=501,
|
||||
content={
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": {
|
||||
"code": "APIPILOT_NOT_CONFIGURED",
|
||||
"message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
|
||||
"status_code": 501,
|
||||
"raw": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
auth, demo = providers_apipilot._apipilot_parse_authorization(request)
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT-CALL-NAME] USE=API_ENDPOINT_EXECUTE name=%r argsKeys=%s authReceived=%s",
|
||||
(body.name or ""),
|
||||
sorted(list((body.arguments or {}).keys())) if isinstance(body.arguments, dict) else [],
|
||||
("Y" if auth or demo else "none"),
|
||||
)
|
||||
envelope = await providers_apipilot._apipilot_execute_tool(
|
||||
auth_header=auth,
|
||||
demo_session=demo,
|
||||
name=body.name,
|
||||
arguments=body.arguments if isinstance(body.arguments, dict) else {},
|
||||
)
|
||||
status_code = 200
|
||||
if (not envelope.get("success")) and isinstance(envelope.get("error"), dict):
|
||||
code = envelope["error"].get("status_code")
|
||||
try:
|
||||
code_int = int(code)
|
||||
if 400 <= code_int < 599:
|
||||
status_code = code_int
|
||||
except Exception:
|
||||
pass
|
||||
app_logging.logger.info(
|
||||
"[APIPILOT] /api/apipilot/execute name=%s success=%s status=%s",
|
||||
body.name,
|
||||
envelope.get("success"),
|
||||
status_code,
|
||||
)
|
||||
return validated_json_response(envelope, ToolExecutionResponse, status_code=status_code)
|
||||
@@ -0,0 +1,289 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
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.responses import ReportsResponse, UserProfileResponse
|
||||
from vocero_api.providers import caller as providers_caller
|
||||
from vocero_api.services import context as services_context
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/user/phone", response_model=UserProfileResponse)
|
||||
async def user_by_phone(phoneNumber: str) -> JSONResponse:
|
||||
"""HTTP passthrough: fetches caller identity / profile from the external
|
||||
info endpoint set via VOCERO_USER_PHONE_AUTH_URL and forwards the raw
|
||||
JSON to the browser.
|
||||
|
||||
The proxy NEVER inspects this payload nor injects it into the realtime
|
||||
WebSocket session. It is only used by:
|
||||
- the browser UI to render a caller name header (#ident-user-name)
|
||||
- the /api/reasoning HTTP body user_context field (browser's choice).
|
||||
|
||||
When VOCERO_USER_PHONE_AUTH_URL is unset we return 503 so the UI can
|
||||
silently skip identity loading (backward compatible with generic deploys).
|
||||
|
||||
When S2S_LOGIN_BY_PHONE is set, the proxy first performs a login-by-phone
|
||||
POST with the caller phone and (if a bearer token is extracted) forwards
|
||||
it as Authorization: Bearer on the subsequent profile GET.
|
||||
"""
|
||||
|
||||
if not settings.VOCERO_USER_PHONE_AUTH_URL:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="VOCERO_USER_PHONE_AUTH_URL no está configurado en este despliegue.",
|
||||
)
|
||||
phone = (phoneNumber or "").strip()
|
||||
if not phone:
|
||||
raise HTTPException(status_code=400, detail="Se requiere el campo phoneNumber.")
|
||||
|
||||
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set).
|
||||
login_token = None
|
||||
if settings.S2S_LOGIN_BY_PHONE:
|
||||
login_token, _login_obj = await providers_caller._login_by_phone(phone)
|
||||
|
||||
target_url = f"{settings.VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone})}"
|
||||
app_logging.logger.debug(
|
||||
"[EXT][USER-BY-PHONE] GET upstream=%s phoneLen=%d bearer=%s",
|
||||
settings.VOCERO_USER_PHONE_AUTH_URL,
|
||||
len(phone),
|
||||
"yes" if login_token else "no",
|
||||
)
|
||||
try:
|
||||
headers = {}
|
||||
if login_token:
|
||||
headers["Authorization"] = f"Bearer {login_token}"
|
||||
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http:
|
||||
resp = await http.get(target_url, headers=headers or None)
|
||||
except httpx.RequestError as exc:
|
||||
app_logging.logger.warning("[EXT][USER-BY-PHONE] unreachable: %r", exc)
|
||||
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de información del usuario.") # noqa: B904
|
||||
# Forward the upstream status + JSON verbatim; never rewrite the payload.
|
||||
if resp.status_code >= 500:
|
||||
app_logging.logger.warning(
|
||||
"[EXT][USER-BY-PHONE] upstream HTTP %s: %.200s",
|
||||
resp.status_code,
|
||||
resp.text,
|
||||
)
|
||||
try:
|
||||
payload = resp.json()
|
||||
except Exception:
|
||||
raise HTTPException( # noqa: B904
|
||||
status_code=502,
|
||||
detail="El servicio de información del usuario devolvió un JSON no válido.",
|
||||
)
|
||||
return validated_json_response(
|
||||
payload, UserProfileResponse, status_code=resp.status_code if resp.status_code != 204 else 200
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/user/reports", response_model=ReportsResponse)
|
||||
async def user_reports(phoneNumber: str | None = None) -> JSONResponse:
|
||||
"""Fetch + normalize the scheduled reports list for the caller UI.
|
||||
|
||||
Mirrors the same sequence used in the WebSocket connect handler and
|
||||
/api/reasoning:
|
||||
0. login-by-phone (optional) → bearer token
|
||||
1. GET S2S_REPORT_LIST with ?phoneNumber= (if phone provided) + Bearer auth
|
||||
2. run the same resolver used for prompt injection: filter 3 fields,
|
||||
sort DESC, build hints + entries → JSON returned verbatim so the
|
||||
browser can render a "reports loaded" summary in the UI header.
|
||||
|
||||
Returns 503 when S2S_REPORT_LIST is not configured (generic deploy with no
|
||||
reports feature enabled) so the browser silently skips UI rendering.
|
||||
Response shape on success:
|
||||
{
|
||||
"count": N,
|
||||
"wrappedLen": X,
|
||||
"hints": [ "• Report #1: '<label>' — scheduled on <date>", ... ],
|
||||
"entries": [
|
||||
{"sourceScheduleLabel": "...", "date": "...", "podcastScript": "..."},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
USER FIX 2026-09-11: remove mass injection of scheduled reports.
|
||||
• Even this BROWSER-FACING endpoint (/api/user/reports) now honours the new
|
||||
S2S_INJECT_REPORTS default=False gate: when S2S_INJECT_REPORTS is not
|
||||
explicitly true, we return a polite 403 + empty response — the endpoint
|
||||
does NOT execute any upstream S2S_REPORT_LIST HTTP call, does NOT expose
|
||||
any podcastScript bodies, and does NOT emit entries/hints. This avoids
|
||||
a subtle loophole where the browser could still fetch scheduled reports
|
||||
through /api/user/reports while the LLM prompt paths were gated.
|
||||
"""
|
||||
# ── MASS-INJECTION KILL SWITCH (user request) ─────────────────────────
|
||||
if not settings.S2S_INJECT_REPORTS:
|
||||
return validated_json_response(
|
||||
model=ReportsResponse,
|
||||
status_code=200,
|
||||
content={
|
||||
"count": 0,
|
||||
"wrappedLen": 0,
|
||||
"hints": [],
|
||||
"entries": [],
|
||||
"skipped": True,
|
||||
"reason": "S2S_INJECT_REPORTS está deshabilitado en este despliegue; no se muestran reportes programados en la interfaz ni se envían al modelo.",
|
||||
},
|
||||
)
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
if not settings.S2S_REPORT_LIST:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="S2S_REPORT_LIST no está configurado en este despliegue.",
|
||||
)
|
||||
phone = (phoneNumber or "").strip() or None
|
||||
# 0) Login-by-phone if enabled. Bearer token used only if login succeeded
|
||||
# (same rule as WS connect / reasoning handlers — never-fail).
|
||||
login_token = None
|
||||
if settings.S2S_LOGIN_BY_PHONE and phone:
|
||||
login_token, _login_obj = await providers_caller._login_by_phone(phone)
|
||||
|
||||
reports_raw = await providers_caller._fetch_reports_list(phone, login_token)
|
||||
# Re-run the same resolver used for prompt injection → identical logic
|
||||
# guarantees UI and LLM see exactly the same filtered/sorted list.
|
||||
ctx_str = services_context._resolve_reports_context(reports_raw)
|
||||
if not ctx_str or not isinstance(reports_raw, list):
|
||||
return validated_json_response(
|
||||
{
|
||||
"count": 0,
|
||||
"wrappedLen": 0,
|
||||
"hints": [],
|
||||
"entries": [],
|
||||
"skipped": True,
|
||||
"reason": "Lista vacía, error del servicio de origen o ningún contenido disponible.",
|
||||
},
|
||||
ReportsResponse,
|
||||
)
|
||||
# Rebuild hints + normalized entries with the exact filtered output
|
||||
items_list: list = reports_raw
|
||||
# Re-derive filtered + sorted list (same as _resolve_reports_context) so
|
||||
# we don't accidentally expose raw unfiltered upstream payload to the UI.
|
||||
filtered: list[dict] = []
|
||||
for item in items_list:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
cleaned: dict = {}
|
||||
label_val: object = None
|
||||
if item.get("sourceScheduleLabel") is not None:
|
||||
label_val = item["sourceScheduleLabel"]
|
||||
elif isinstance(item.get("sourceSchedule"), dict):
|
||||
ss = item["sourceSchedule"]
|
||||
for k in ("label", "name", "title"):
|
||||
if ss.get(k) is not None:
|
||||
label_val = ss[k]
|
||||
break
|
||||
if label_val is not None:
|
||||
cleaned["sourceScheduleLabel"] = label_val
|
||||
date_val: object = None
|
||||
if item.get("date") is not None:
|
||||
date_val = item["date"]
|
||||
elif isinstance(item.get("sourceSchedule"), dict):
|
||||
ss = item["sourceSchedule"]
|
||||
for k in ("date", "scheduledAt", "publishedAt"):
|
||||
if ss.get(k) is not None:
|
||||
date_val = ss[k]
|
||||
break
|
||||
if date_val is not None:
|
||||
cleaned["date"] = date_val
|
||||
script_val: object = None
|
||||
for k in ("podcastScript", "script", "text", "content"):
|
||||
if item.get(k) is not None:
|
||||
script_val = item[k]
|
||||
break
|
||||
if script_val is not None:
|
||||
cleaned["podcastScript"] = script_val
|
||||
if cleaned:
|
||||
filtered.append(cleaned)
|
||||
filtered.sort(key=lambda r: services_context._parse_date_key(r.get("date")))
|
||||
# 2026-09-11 user fix: configure report quick-hint limits through .env.
|
||||
# Apply the SAME cap used by the LLM prompt resolver: after sort by newest,
|
||||
# keep only first S2S_REPORTS_QUICK_HINT_MAX entries. This keeps counts between
|
||||
# /api/user/reports UI JSON and the LLM prompt identical — avoids confusing
|
||||
# mismatches where the UI shows 20 hints but the prompt only saw 5.
|
||||
total_raw_ui = len(filtered)
|
||||
cap_ui = max(1, int(settings.S2S_REPORTS_QUICK_HINT_MAX or 5))
|
||||
truncated_ui_note: str | None = None
|
||||
if total_raw_ui > cap_ui:
|
||||
filtered = filtered[:cap_ui]
|
||||
truncated_ui_note = (
|
||||
f"Se muestran los {cap_ui} reportes más recientes de un total de {total_raw_ui}. "
|
||||
"Ajuste la variable de entorno S2S_REPORTS_QUICK_HINT_MAX para cambiar la cantidad."
|
||||
)
|
||||
import datetime as _dt
|
||||
|
||||
# Deduplicate by label for the UI JSON response only.
|
||||
# NOTE: the LLM prompt injection uses the full non-deduped list (different
|
||||
# dates = different content even when the scheduled report name repeats).
|
||||
# We only dedup entries_out, hints_list, and entries for the browser payload.
|
||||
seen_labels: set[str] = set()
|
||||
dedup_filtered: list[dict] = []
|
||||
for r in filtered:
|
||||
lbl_raw = r.get("sourceScheduleLabel")
|
||||
if lbl_raw is None:
|
||||
# Items with no label are kept as-is (can't meaningfully dedupe)
|
||||
dedup_filtered.append(r)
|
||||
continue
|
||||
key = str(lbl_raw).strip()
|
||||
if not key:
|
||||
dedup_filtered.append(r)
|
||||
continue
|
||||
if key in seen_labels:
|
||||
continue
|
||||
seen_labels.add(key)
|
||||
dedup_filtered.append(r)
|
||||
hints_list: list[str] = []
|
||||
entries_out: list[dict] = []
|
||||
for idx, r in enumerate(dedup_filtered, start=1):
|
||||
entries_out.append(r)
|
||||
label_piece = str(r["sourceScheduleLabel"]).strip() if r.get("sourceScheduleLabel") else ""
|
||||
date_piece = ""
|
||||
if r.get("date") is not None:
|
||||
raw_d = r["date"]
|
||||
try:
|
||||
if isinstance(raw_d, (int, float)) and not isinstance(raw_d, bool):
|
||||
ts_sec = float(raw_d)
|
||||
if ts_sec > 1e12:
|
||||
ts_sec = ts_sec / 1000.0
|
||||
dt = _dt.datetime.fromtimestamp(ts_sec, tz=_dt.UTC)
|
||||
date_piece = dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
else:
|
||||
s = str(raw_d).strip()
|
||||
if "T" in s and len(s) >= 16:
|
||||
date_piece = s.replace("T", " ")[:16]
|
||||
else:
|
||||
date_piece = s
|
||||
except Exception:
|
||||
date_piece = str(raw_d)[:32]
|
||||
if label_piece and date_piece:
|
||||
hints_list.append(f"• Reporte #{idx}: '{label_piece}' — programado para {date_piece}")
|
||||
elif label_piece:
|
||||
hints_list.append(f"• Reporte #{idx}: '{label_piece}'")
|
||||
elif date_piece:
|
||||
hints_list.append(f"• Reporte #{idx}: (sin título) — programado para {date_piece}")
|
||||
resp_body = {
|
||||
"count": len(entries_out),
|
||||
"rawCount": total_raw_ui,
|
||||
"cap": cap_ui,
|
||||
"wrappedLen": len(ctx_str),
|
||||
"hints": hints_list,
|
||||
# Strip the long podcastScript from the summary JSON to keep the
|
||||
# browser response lightweight (UI only needs label/date).
|
||||
"entries": [
|
||||
{
|
||||
"sourceScheduleLabel": r.get("sourceScheduleLabel"),
|
||||
"date": r.get("date"),
|
||||
}
|
||||
for r in entries_out
|
||||
],
|
||||
}
|
||||
if truncated_ui_note is not None:
|
||||
resp_body["truncated"] = True
|
||||
resp_body["note"] = truncated_ui_note
|
||||
return validated_json_response(resp_body, ReportsResponse)
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
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.core import urls as core_urls
|
||||
from vocero_api.models.responses import ConfigResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _effective_s2s_url(request: Request) -> str:
|
||||
return settings.SPEECH_TO_SPEECH_URL or core_urls._proxy_public_url(request)
|
||||
|
||||
|
||||
@router.get("/api/config", response_model=ConfigResponse)
|
||||
async def config(request: Request) -> JSONResponse:
|
||||
"""Client bootstrap: whether web search is available, whether the deploy runs
|
||||
behind a load balancer (so the browser uses the /api/session proxy),
|
||||
and whether the user may instead set a direct
|
||||
s2s server URL. The LB address itself is intentionally NOT included.
|
||||
|
||||
2026-09-24: new top-level `apipilot` flag. When True the deploy exposes the
|
||||
new `/api/apipilot/tools` and `/api/apipilot/execute` proxy endpoints (see
|
||||
docs/client/tool-calling.md) that the browser may call directly instead of
|
||||
hardcoding upstream tool URLs / names."""
|
||||
app_logging.logger.debug(
|
||||
"[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?"
|
||||
)
|
||||
primary_prompt = settings.S2S_PRIMARY_PROMPT
|
||||
out = {
|
||||
"search": bool(settings.SERPER_KEY),
|
||||
"lb": bool(settings.LOAD_BALANCER_URL),
|
||||
"allowDirect": not settings.LOAD_BALANCER_URL,
|
||||
"s2sUrl": _effective_s2s_url(request),
|
||||
"primaryPrompt": primary_prompt,
|
||||
"reasoningType": settings.S2S_REASONING_TYPE,
|
||||
"injectProfile": settings.S2S_INJECT_PROFILE,
|
||||
"debugPrompt": settings.S2S_DEBUG_PROMPT,
|
||||
"startSessionMode": settings.S2S_START_SESSION_MODE,
|
||||
"theme": settings.S2S_THEME,
|
||||
"apipilot": bool(settings.S2S_APIPILOT_TOOLS_URL),
|
||||
}
|
||||
app_logging.logger.debug(
|
||||
"[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s apipilot=%s",
|
||||
len(primary_prompt),
|
||||
settings.S2S_REASONING_TYPE,
|
||||
settings.S2S_START_SESSION_MODE or "-",
|
||||
settings.S2S_THEME or "-",
|
||||
out["apipilot"],
|
||||
)
|
||||
return validated_json_response(out, ConfigResponse)
|
||||
@@ -0,0 +1,92 @@
|
||||
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)
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
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.requests import QueueEndPayload
|
||||
from vocero_api.models.responses import AllocationResponse, QueueCancellationResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/api/session", response_model=AllocationResponse)
|
||||
async def session(request: Request) -> JSONResponse:
|
||||
"""Relay a session grant or queue ticket, keeping the LB address server-side.
|
||||
|
||||
The grant's `connect_url` contains the compute host and short-lived token the
|
||||
browser needs to connect directly to the allocated speech service."""
|
||||
client_ip = request.client.host if request.client else "?"
|
||||
if not settings.LOAD_BALANCER_URL:
|
||||
# No LB configured — this deploy is direct-mode only; the browser should
|
||||
# never call this. 404 so it's indistinguishable from a missing route.
|
||||
raise HTTPException(status_code=404, detail="No encontrado.")
|
||||
|
||||
app_logging.logger.debug("[EXT][LB] session: client=%s", client_ip)
|
||||
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/session"
|
||||
app_logging.logger.debug("[EXT][LB] session POST %s (handshake)", url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as http:
|
||||
lb = await http.post(url, headers={"Content-Type": "application/json"}, content="{}")
|
||||
except httpx.RequestError as exc:
|
||||
app_logging.logger.warning("Load balancer unreachable: %r", exc)
|
||||
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de voz.") # noqa: B904
|
||||
app_logging.logger.debug(
|
||||
"[EXT][LB] session RESP status=%s len=%dB",
|
||||
lb.status_code,
|
||||
len(lb.content or b""),
|
||||
)
|
||||
|
||||
# The queue is full: the LB replies 503 {state:"at_capacity"}. Relay it as-is
|
||||
# so the client shows a soft "try again shortly", not a hard error.
|
||||
if lb.status_code == 503:
|
||||
body = _safe_json(lb)
|
||||
if body.get("state") == "at_capacity":
|
||||
app_logging.logger.debug("[EXT][LB] session: at_capacity — queue full")
|
||||
return validated_json_response({"state": "at_capacity"}, AllocationResponse, status_code=503)
|
||||
|
||||
if lb.status_code != 200:
|
||||
# The LB's error body may name the reason (e.g. capacity); it carries no
|
||||
# secret, so relay a trimmed copy.
|
||||
app_logging.logger.warning("Session handshake failed %s: %s", lb.status_code, lb.text[:300])
|
||||
raise HTTPException(status_code=502, detail=f"No se pudo establecer la sesión ({lb.status_code}).")
|
||||
|
||||
data = lb.json()
|
||||
state = data.get("state", "?")
|
||||
connect_url = (data.get("connect_url") or data.get("connectUrl") or "")[:120]
|
||||
session_id = data.get("session_id") or data.get("sessionId")
|
||||
app_logging.logger.debug(
|
||||
"[EXT][LB] session PARSED state=%s sessionId=%s hasConnectUrl=%s connectUrl=%s",
|
||||
state,
|
||||
session_id,
|
||||
bool(connect_url),
|
||||
connect_url,
|
||||
)
|
||||
|
||||
# Busy pool: the LB queued us. Relay the ticket for subsequent polling.
|
||||
if data.get("state") == "queued":
|
||||
app_logging.logger.debug(
|
||||
"[EXT][LB] session: queued queueId=%s position=%s",
|
||||
data.get("queue_id") or data.get("queueId"),
|
||||
data.get("position"),
|
||||
)
|
||||
return validated_json_response(data, AllocationResponse)
|
||||
|
||||
|
||||
@router.get("/api/queue/{queue_id}", response_model=AllocationResponse)
|
||||
async def queue_status(queue_id: str, request: Request) -> JSONResponse:
|
||||
"""Relay a waiting ticket's position or its newly claimed session grant."""
|
||||
client_ip = request.client.host if request.client else "?"
|
||||
if not settings.LOAD_BALANCER_URL:
|
||||
raise HTTPException(status_code=404, detail="No encontrado.")
|
||||
|
||||
app_logging.logger.debug(
|
||||
"[EXT][LB] queue poll: client=%s queueId=%s",
|
||||
client_ip,
|
||||
queue_id[:16],
|
||||
)
|
||||
|
||||
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
|
||||
app_logging.logger.debug("[EXT][LB] queue GET %s", url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as http:
|
||||
lb = await http.get(url)
|
||||
except httpx.RequestError as exc:
|
||||
app_logging.logger.warning("Load balancer unreachable: %r", exc)
|
||||
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de voz.") # noqa: B904
|
||||
app_logging.logger.debug(
|
||||
"[EXT][LB] queue RESP status=%s len=%dB",
|
||||
lb.status_code,
|
||||
len(lb.content or b""),
|
||||
)
|
||||
|
||||
if lb.status_code == 404:
|
||||
# Ticket unknown/expired (reaped after we stopped polling). Tell the client
|
||||
# to start over rather than spin.
|
||||
app_logging.logger.debug("[EXT][LB] queue: queueId=%s EXPIRED (404)", queue_id[:16])
|
||||
return validated_json_response({"state": "expired"}, AllocationResponse, status_code=404)
|
||||
|
||||
if lb.status_code != 200:
|
||||
app_logging.logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300])
|
||||
raise HTTPException(status_code=502, detail=f"No se pudo consultar la cola de espera ({lb.status_code}).")
|
||||
|
||||
data = lb.json()
|
||||
state = data.get("state", "?")
|
||||
position = data.get("position")
|
||||
app_logging.logger.debug(
|
||||
"[EXT][LB] queue PARSED queueId=%s state=%s position=%s sessionId=%s",
|
||||
queue_id[:16],
|
||||
state,
|
||||
position,
|
||||
data.get("session_id") or data.get("sessionId"),
|
||||
)
|
||||
|
||||
return validated_json_response(data, AllocationResponse)
|
||||
|
||||
|
||||
@router.delete("/api/queue/{queue_id}", response_model=QueueCancellationResponse)
|
||||
async def queue_leave(queue_id: str) -> JSONResponse:
|
||||
"""Leave the queue from the explicit 'Leave queue' button (a real fetch)."""
|
||||
if not settings.LOAD_BALANCER_URL:
|
||||
raise HTTPException(status_code=404, detail="No encontrado.")
|
||||
app_logging.logger.debug("[EXT][LB] queue LEAVE (button) queueId=%s", queue_id[:16])
|
||||
await _lb_leave(queue_id)
|
||||
return validated_json_response({"ok": True}, QueueCancellationResponse)
|
||||
|
||||
|
||||
@router.post("/api/queue/end", response_model=QueueCancellationResponse)
|
||||
async def queue_end(request: Request) -> JSONResponse:
|
||||
"""Leave the queue on teardown/tab-close (navigator.sendBeacon, which can only
|
||||
POST). Body: { queueId }. Best-effort; the LB reaps the ticket on TTL anyway."""
|
||||
if not settings.LOAD_BALANCER_URL:
|
||||
raise HTTPException(status_code=404, detail="No encontrado.")
|
||||
qid = await _queue_id(request)
|
||||
if qid:
|
||||
app_logging.logger.debug("[EXT][LB] queue LEAVE (teardown) queueId=%s", qid[:16])
|
||||
await _lb_leave(qid)
|
||||
return validated_json_response({"ok": True}, QueueCancellationResponse)
|
||||
|
||||
|
||||
async def _lb_leave(queue_id: str) -> None:
|
||||
"""Best-effort: tell the LB to drop a waiting ticket."""
|
||||
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
|
||||
app_logging.logger.debug("[EXT][LB] queue DELETE %s", url)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as http:
|
||||
resp = await http.delete(url)
|
||||
app_logging.logger.debug(
|
||||
"[EXT][LB] queue DELETE RESP queueId=%s status=%s",
|
||||
queue_id[:16],
|
||||
resp.status_code,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
app_logging.logger.warning("Queue leave failed: %r", exc)
|
||||
|
||||
|
||||
def _safe_json(response: httpx.Response) -> dict[str, Any]:
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception:
|
||||
return {}
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
async def _queue_id(request: Request) -> Any:
|
||||
"""Pull `queueId` from a JSON body, tolerating sendBeacon's blob posts."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return ""
|
||||
return QueueEndPayload.model_validate(data).queueId if isinstance(data, dict) else ""
|
||||
Reference in New Issue
Block a user