5.6 KiB
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 untypedAny - 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.pylifespanstartup - Stored at
app.state.engine - NEVER instantiate
AgentEngineinside a route handler or per-request dependency
Middleware Stack (outermost → innermost, registration order matters)
-
TracingMiddleware- Read
X-Trace-IDheader; generateuuid4()if absent - Store in
request.state.trace_id - Add
X-Trace-IDto response headers - Create OTel HTTP span (method, path, status_code, duration_ms)
- Bind
trace_idtostructlogcontext for the request lifetime
- Read
-
LoggingMiddleware- Log request: method, path, content-length, trace_id
- Log response: status_code, duration_ms, trace_id
- Sanitize
Authorizationheader (log only first 12 chars +…) - NEVER log request body (may contain secrets)
-
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_MINUTEper tenant per minute - Exceeded → 429
ErrorResponse(code="RATE_LIMIT_EXCEEDED", …)
- Redis token-bucket keyed on
-
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_scoreMUST be serialised as a string to preserveDecimalprecision 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:
- Verify caller has
approve:writepermission - Load
PendingApprovalwheretrace_id = :trace_id AND status = 'pending'→ 404 if not found - Check
expired_at > now()→ 409{"code":"APPROVAL_EXPIRED"}if expired - Atomic DB transaction:
a.
PendingApproval.status← decision b.PendingApproval.decider_id←user.user_idc.PendingApproval.decided_at←now()d.TraceSession.status←"running"(if approved) or"failed" - 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 - 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 1via async engineredis:PINGvia aioredismilvus:list_collections()via pymilvus