Files

5.6 KiB

API Layer — Submodule Instructions

FastAPI Patterns

  • All route handlers: async def
  • Return types: always a declared Pydantic response model — NEVER raw dict, None, or untyped Any
  • Error format (ALL error responses, without exception):
    class ErrorResponse(BaseModel):
        code: str          # machine-readable, e.g. "TOOL_NOT_ALLOWED"
        message: str       # human-readable Chinese message
        trace_id: str
        details: dict = Field(default_factory=dict)
    
  • HTTP status codes:
    Situation Code
    Success 200
    Async task accepted 202
    Client validation error 400
    Missing / invalid JWT 401
    Insufficient permission 403
    Resource not found 404
    State conflict (e.g. already decided) 409
    Pydantic validation failure 422
    Dependency unhealthy 503
    Unhandled server error 500

Settings Access

# CORRECT
from core.config import get_settings
s = get_settings()

# WRONG — do not import a module-level `settings` variable
from core.config import settings   # symbol does not exist; will ImportError

AgentEngine Lifecycle

  • Initialised ONCE in api/main.py lifespan startup
  • Stored at app.state.engine
  • NEVER instantiate AgentEngine inside a route handler or per-request dependency

Middleware Stack (outermost → innermost, registration order matters)

  1. TracingMiddleware

    • Read X-Trace-ID header; generate uuid4() if absent
    • Store in request.state.trace_id
    • Add X-Trace-ID to response headers
    • Create OTel HTTP span (method, path, status_code, duration_ms)
    • Bind trace_id to structlog context for the request lifetime
  2. LoggingMiddleware

    • Log request: method, path, content-length, trace_id
    • Log response: status_code, duration_ms, trace_id
    • Sanitize Authorization header (log only first 12 chars + …)
    • NEVER log request body (may contain secrets)
  3. RateLimitMiddleware

    • Redis token-bucket keyed on tenant_id (extracted from JWT claim without full JWT validation — just base64-decode the payload)
    • Limit: settings.REDIS_RATE_LIMIT_PER_MINUTE per tenant per minute
    • Exceeded → 429 ErrorResponse(code="RATE_LIMIT_EXCEEDED", …)
  4. CORS

    • ENVIRONMENT=development → allow_origins=["*"]
    • ENVIRONMENT=production → allow_origins=settings.ALLOWED_ORIGINS

Authentication is handled by require_auth FastAPI Depends, NOT a global middleware, so that /health, /docs, /redoc, /openapi.json remain public.

RBAC Permission Matrix

ROLE_PERMISSIONS: dict[str, set[str]] = {
    "admin":   {"chat:create","chat:read","approve:write",
                "trace:read","user:manage"},
    "manager": {"chat:create","chat:read","approve:write","trace:read"},
    "analyst": {"chat:create","chat:read","trace:read"},
    "viewer":  {"chat:read","trace:read"},
}

Usage pattern:

@router.post("/approve/{trace_id}")
async def approve(
    trace_id: str,
    body: ApprovalRequest,
    user: UserContext = Depends(require_auth),
    _: None = Depends(require_permission("approve:write")),
    db: AsyncSession = Depends(get_db),
): ...

SSE Event Contract (/api/v1/chat)

All server-sent events MUST conform to this schema:

event: node_complete
data: {"node":"ask","status":"success","duration_ms":234,"trace_id":"…"}

event: approval_required
data: {
  "trace_id":"…","step_id":"…",
  "action_description":"…","risk_level":"high",
  "approver_role":"manager","params_preview":{…}
}

event: final_result
data: {
  "output_type":"document|text|insufficient_data|clarification_needed",
  "content":"…",
  "citations":[…],
  "evidence_score":"0.8700",   ← Decimal serialised as string
  "evidence_level":"sufficient",
  "trace_id":"…"
}

event: error
data: {"code":"TOOL_FAILED","message":"…","trace_id":"…"}

data: [DONE]

Notes:

  • evidence_score MUST be serialised as a string to preserve Decimal precision in JSON (avoids float rounding)
  • The [DONE] terminator MUST always be sent, even after an error
  • Do NOT send any events after [DONE]

Approval Endpoint Rules (/api/v1/approve/{trace_id})

Exact sequence — do not reorder:

  1. Verify caller has approve:write permission
  2. Load PendingApproval where trace_id = :trace_id AND status = 'pending' → 404 if not found
  3. Check expired_at > now() → 409 {"code":"APPROVAL_EXPIRED"} if expired
  4. Atomic DB transaction: a. PendingApproval.status ← decision b. PendingApproval.decider_id ← user.user_id c. PendingApproval.decided_at ← now() d. TraceSession.status ← "running" (if approved) or "failed"
  5. Fire-and-forget resume (do NOT await completion in the request): asyncio.create_task(engine.resume(session_id, Command(resume=…))) with structured error logging on task failure
  6. Return 202: {"status":"resuming","trace_id":"…"}

Health Endpoint Contract (GET /health)

class HealthResponse(BaseModel):
    status: Literal["ok", "degraded"]
    version: str
    environment: str
    services: dict[str, bool]   # {"db": True, "redis": True, "milvus": False, …}
    checked_at: datetime

# Any service False → HTTP 503 (body still JSON, allows LB to see detail)
# All services True → HTTP 200

Real connectivity checks (not just config presence):

  • db : SELECT 1 via async engine
  • redis : PING via aioredis
  • milvus: list_collections() via pymilvus