Phase 3 — WS frame + REST fallbacka
This commit is contained in:
@@ -16,8 +16,6 @@ import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -37,6 +35,8 @@ from app.schemas import (
|
||||
UserProfile,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/agents", tags=["agents"])
|
||||
|
||||
|
||||
|
||||
@@ -5,13 +5,19 @@ WebSocket chat is handled by the unified device WS endpoint (/api/v1/ws/device).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.brief_agent import run_home_brief, run_project_brief
|
||||
from app.core.deep_agent import run_home
|
||||
from app.core.llm import embed
|
||||
from app.core.memory_middleware import MemoryMiddleware
|
||||
from app.db import async_session
|
||||
from app.schemas import ChatRequest, UserProfile
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
@@ -45,6 +51,57 @@ async def chat(
|
||||
return JSONResponse(content={"response": response})
|
||||
|
||||
|
||||
class _BriefRequest(BaseModel):
|
||||
mode: Literal["home", "project"]
|
||||
project_id: str | None = None
|
||||
|
||||
|
||||
class _BriefResponse(BaseModel):
|
||||
response: str
|
||||
|
||||
|
||||
@router.post("/brief", response_model=_BriefResponse)
|
||||
async def brief(
|
||||
body: _BriefRequest,
|
||||
current_user: UserProfile = Depends(get_current_user),
|
||||
) -> _BriefResponse:
|
||||
"""REST fallback for brief when the device WebSocket is not ready."""
|
||||
if body.mode == "project":
|
||||
if not body.project_id:
|
||||
raise HTTPException(status_code=422, detail="project_id required for project mode")
|
||||
try:
|
||||
uuid.UUID(body.project_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="project_id must be a valid UUID")
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
async with async_session() as db:
|
||||
memory = MemoryMiddleware(db)
|
||||
memory_context = await memory.enrich_context(
|
||||
current_user.id,
|
||||
"",
|
||||
trace_id=request_id,
|
||||
session_id=request_id,
|
||||
)
|
||||
|
||||
context: dict = {
|
||||
"_debug": {"request_id": request_id, "user_id": current_user.id},
|
||||
**memory_context,
|
||||
}
|
||||
|
||||
chunks: list[str] = []
|
||||
if body.mode == "project":
|
||||
stream = run_project_brief(current_user.id, body.project_id, context) # type: ignore[arg-type]
|
||||
else:
|
||||
stream = run_home_brief(current_user.id, context)
|
||||
|
||||
async for event_type, data in stream:
|
||||
if event_type == "token" and data:
|
||||
chunks.append(str(data))
|
||||
|
||||
return _BriefResponse(response="".join(chunks))
|
||||
|
||||
|
||||
@router.post("/embed", response_model=_EmbedResponse)
|
||||
async def embed_text(
|
||||
body: _EmbedRequest,
|
||||
|
||||
@@ -42,6 +42,7 @@ from sqlalchemy import update
|
||||
from app.api.routes.agent_setup import handle_journey_message, handle_journey_start
|
||||
from app.config.settings import settings
|
||||
from app.core.agent_runner import trigger_pending_runs
|
||||
from app.core.brief_agent import run_home_brief, run_project_brief
|
||||
from app.core.deep_agent import run_floating_stream, run_home_stream
|
||||
from app.core.device_manager import device_manager
|
||||
from app.core.memory_middleware import MemoryMiddleware
|
||||
@@ -49,7 +50,7 @@ from app.core.output_formatter import StreamFormatter
|
||||
from app.core.ws_context import clear_client_executor, set_client_executor
|
||||
from app.db import async_session
|
||||
from app.models import AgentRunLog
|
||||
from app.schemas import WsFrameType
|
||||
from app.schemas import WsFrameType, WsStreamEnd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -158,6 +159,11 @@ async def _message_loop(websocket: WebSocket, user_id: str) -> None:
|
||||
_handle_floating_request(websocket, user_id, frame)
|
||||
)
|
||||
|
||||
elif frame_type == WsFrameType.brief_request:
|
||||
asyncio.create_task(
|
||||
_handle_brief_request(websocket, user_id, frame)
|
||||
)
|
||||
|
||||
elif frame_type == WsFrameType.journey_start:
|
||||
asyncio.create_task(
|
||||
_handle_journey_start(websocket, user_id, frame)
|
||||
@@ -325,6 +331,86 @@ async def _handle_floating_request(
|
||||
)
|
||||
|
||||
|
||||
async def _handle_brief_request(
|
||||
websocket: WebSocket,
|
||||
user_id: str,
|
||||
frame: dict,
|
||||
) -> None:
|
||||
"""Handle a brief_request frame — streams plain-text brief back on the socket.
|
||||
|
||||
No episode storage — briefs are not conversations.
|
||||
"""
|
||||
import uuid as _uuid
|
||||
|
||||
request_id = frame.get("request_id") or str(uuid4())
|
||||
session_id = frame.get("session_id") or str(uuid4())
|
||||
mode: str = frame.get("mode", "home")
|
||||
project_id: str | None = frame.get("project_id")
|
||||
|
||||
logger.info(
|
||||
"device_ws: brief_request_start user=%s req=%s mode=%s project_id=%s",
|
||||
user_id, request_id, mode, project_id,
|
||||
)
|
||||
|
||||
# Validate project_id for project mode before touching LLM.
|
||||
if mode == "project":
|
||||
try:
|
||||
if not project_id:
|
||||
raise ValueError("project_id required for project mode")
|
||||
_uuid.UUID(project_id)
|
||||
except (ValueError, AttributeError) as exc:
|
||||
logger.warning(
|
||||
"device_ws: brief_request invalid project_id user=%s req=%s: %s",
|
||||
user_id, request_id, exc,
|
||||
)
|
||||
await websocket.send_text(
|
||||
WsStreamEnd(request_id=request_id, error=str(exc)).model_dump_json()
|
||||
)
|
||||
return
|
||||
|
||||
# Enrich context with memory (no user message — use empty string as probe).
|
||||
async with async_session() as db:
|
||||
memory = MemoryMiddleware(db)
|
||||
memory_context = await memory.enrich_context(
|
||||
user_id,
|
||||
"",
|
||||
trace_id=request_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
context: dict = {
|
||||
"_debug": {"request_id": request_id, "session_id": session_id, "user_id": user_id},
|
||||
**memory_context,
|
||||
}
|
||||
|
||||
executor = await _make_ws_executor(websocket, user_id)
|
||||
set_client_executor(executor)
|
||||
try:
|
||||
if mode == "project":
|
||||
event_stream = run_project_brief(user_id, project_id, context) # type: ignore[arg-type]
|
||||
else:
|
||||
event_stream = run_home_brief(user_id, context)
|
||||
|
||||
formatter = StreamFormatter(request_id=request_id)
|
||||
async for ws_frame in formatter.format(event_stream):
|
||||
await websocket.send_text(ws_frame.model_dump_json())
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"device_ws: brief_request failed user=%s req=%s: %s",
|
||||
user_id, request_id, exc,
|
||||
)
|
||||
await websocket.send_text(
|
||||
WsStreamEnd(request_id=request_id, error=str(exc)).model_dump_json()
|
||||
)
|
||||
finally:
|
||||
clear_client_executor()
|
||||
|
||||
logger.info(
|
||||
"device_ws: brief_request_end user=%s req=%s mode=%s",
|
||||
user_id, request_id, mode,
|
||||
)
|
||||
|
||||
|
||||
# ── v4 Journey Handlers ─────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user