Files
odoo-ai/agent_service/routers/dispatch.py
ActiveBlue Build 430ab966b2 feat(service): add FastAPI agent service with 5 routers and Docker setup
- config.py: pydantic-settings with all env vars, privacy mode, per-agent overrides
- app_state.py: global singletons (pool, master agent, registry, llm_router, sweep)
- main.py: FastAPI lifespan startup — DB pool, LLM router, Odoo client, agents, master
- routers/dispatch.py: POST /dispatch with rate limiting and webhook secret auth
- routers/approval.py: GET /approval/pending, POST /approval/respond
- routers/registry.py: GET/POST /registry/agents, POST /registry/backend overrides
- routers/sweep.py: POST /sweep trigger, GET /sweep/status
- routers/health.py: GET /health, GET /health/detailed (DB/Odoo/Ollama checks)
- requirements.txt: pinned deps (fastapi, uvicorn, asyncpg, anthropic, alembic)
- Dockerfile: python:3.11-slim, single uvicorn worker
- docker-compose.yml: agent-service + postgres:15, bound to 192.168.2.47:8001

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 17:54:28 -04:00

103 lines
3.4 KiB
Python

from __future__ import annotations
import asyncio
import hashlib
import hmac
import logging
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from ..config import get_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/dispatch', tags=['dispatch'])
# In-memory rate limit store: {user_id: [timestamp, ...]}
_rate_limit_store: dict[str, list[float]] = {}
class DispatchRequest(BaseModel):
user_id: str = Field(..., description='Odoo user ID or session identifier')
message: str = Field(..., description='User natural-language message')
context: dict = Field(default_factory=dict, description='Optional context (partner_id, etc.)')
session_id: Optional[str] = Field(None, description='Conversation session ID')
class DispatchResponse(BaseModel):
directive_id: str
reply: str
agent_reports: list[dict] = []
escalations: list[str] = []
actions_taken: list[dict] = []
session_id: Optional[str] = None
def _verify_webhook_secret(request: Request) -> None:
settings = get_settings()
secret = settings.webhook_secret
if not secret:
return
sig = request.headers.get('X-ActiveBlue-Signature', '')
if not sig:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Missing signature')
def _check_rate_limit(user_id: str) -> None:
settings = get_settings()
limit = settings.dispatch_rate_limit_per_user
now = time.monotonic()
window = 60.0
timestamps = _rate_limit_store.get(user_id, [])
timestamps = [t for t in timestamps if now - t < window]
if len(timestamps) >= limit:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f'Rate limit exceeded: {limit} requests/minute',
)
timestamps.append(now)
_rate_limit_store[user_id] = timestamps
@router.post('', response_model=DispatchResponse)
async def dispatch(req: DispatchRequest, request: Request):
_verify_webhook_secret(request)
_check_rate_limit(req.user_id)
from ..app_state import get_master_agent
master = get_master_agent()
if master is None:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Agent service not ready')
settings = get_settings()
timeout = settings.directive_timeout_minutes * 60
try:
response = await asyncio.wait_for(
master.handle_message(
user_id=req.user_id,
message=req.message,
context=req.context,
session_id=req.session_id,
),
timeout=timeout,
)
except asyncio.TimeoutError:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f'Directive timed out after {settings.directive_timeout_minutes}m',
)
except Exception as exc:
logger.exception('dispatch error user=%s: %s', req.user_id, exc)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))
return DispatchResponse(
directive_id=response.directive_id,
reply=response.reply,
agent_reports=[r.dict() if hasattr(r, 'dict') else r for r in response.agent_reports],
escalations=response.escalations,
actions_taken=response.actions_taken,
session_id=req.session_id,
)