"""Conversation-aware HTTP reasoning with the same MCP executor as the relay.""" import asyncio from contextlib import suppress import httpx from fastapi import HTTPException, Request from fastapi.responses import JSONResponse from vocero_api.core import config as settings from vocero_api.models.mcp import IntegrationError from vocero_api.models.requests import ReasoningRequest from vocero_api.services import content_context, conversations, mcp_tools async def response_text(messages: list[dict[str, str]], headers: dict[str, str]) -> str: try: async with httpx.AsyncClient(timeout=30, follow_redirects=False) as client: response = await client.post( f"{settings.OPENAI_BASE_URL}/chat/completions", headers=headers, json={"model": settings.OPENAI_REASONING_MODEL, "messages": messages}, ) response.raise_for_status() text = response.json()["choices"][0]["message"]["content"] if isinstance(text, str) and text.strip() and len(text) <= 64000: return text.strip() except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError): pass raise IntegrationError(502, "El servicio de razonamiento no devolvió una respuesta válida.") async def run_turn( req: ReasoningRequest, conversation: conversations.Conversation, turn: mcp_tools.ToolTurn, headers: dict[str, str] ) -> str: catalog = await content_context.catalog(conversation.backend_token) turn.require_current() conversation.catalog = catalog base = settings.S2S_PRIMARY_PROMPT or req.instructions prior = [message for previous_turn in conversation.history for message in previous_turn] notes = f"User-provided reference notes:\n{req.user_context[:12000]}\n\n" if req.user_context else "" current = [{"role": "user", "content": notes + req.transcript.strip()}] while True: system = {"role": "system", "content": mcp_tools.instructions(base, conversation)} answer = await response_text([system, *prior, *current], headers) turn.require_current() chosen = mcp_tools.selection(answer) current.append({"role": "assistant", "content": answer}) if chosen is None: conversation.greeting_pending = False conversation.history.append(current) while ( len(conversation.history) > 6 or sum(len(message["content"]) for history in conversation.history for message in history) > 96000 ): conversation.history.pop(0) return answer name, arguments = chosen result = await turn.call(name, arguments) current.append({"role": "user", "content": mcp_tools.result_text(name, result)}) async def reply(req: ReasoningRequest, request: Request, headers: dict[str, str]) -> JSONResponse: conversation: conversations.Conversation | None = None try: conversation = conversations.resolve(request) if not req.transcript.strip() or len(req.transcript) > 8000: raise IntegrationError(400, "La consulta debe tener entre 1 y 8000 caracteres.") try: client_turn = int(request.headers.get("x-vocero-turn", "0")) except ValueError: raise IntegrationError(400, "El turno de la conversación no es válido.") from None if client_turn <= conversation.client_turn: raise IntegrationError(409, "Este turno ya fue reemplazado.") conversation.client_turn = client_turn turn = mcp_tools.ToolTurn(conversation, conversation.interrupt()) task = asyncio.create_task(run_turn(req, conversation, turn, headers)) conversation.pending = task async def watch_connection() -> None: while not task.done(): if await request.is_disconnected(): if conversation.pending is task: conversation.interrupt() else: task.cancel() return await asyncio.sleep(0.1) watcher = asyncio.create_task(watch_connection()) try: async with asyncio.timeout(180): answer = await task return JSONResponse({"answer": answer}, headers={"Cache-Control": "no-store"}) finally: watcher.cancel() with suppress(asyncio.CancelledError): await watcher if conversation.pending is task: conversation.pending = None except IntegrationError as exc: if exc.status == 401 and conversation is not None: conversations.store.remove(conversation.id) raise HTTPException(exc.status, exc.message) from None except TimeoutError: raise HTTPException(504, "La consulta tardó demasiado. Intenta con un período más corto.") from None except asyncio.CancelledError: return JSONResponse({"detail": "La consulta fue cancelada."}, status_code=409)