Spaces:
Sleeping
Sleeping
| """The System — grounded portfolio Q&A agent. | |
| A deliberately small FastAPI proxy with guardrails: | |
| - POST /chat {"messages":[{"role","content"},...]} -> {"reply": "..."} | |
| - GET /health | |
| Security model: the browser never sees an API key; keys live in HF Space | |
| secrets and are read from the environment. Client input is untrusted data — | |
| validated, sanitized, delimited, rate-limited, and never allowed to override | |
| the server-side system prompt. Interactive docs are disabled to keep the | |
| surface minimal. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| import time | |
| from fastapi import FastAPI, Request | |
| from fastapi.exceptions import RequestValidationError | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse | |
| from .guard import ChatRequest, ChatResponse, RateLimiter, client_ip, wrap_user_content | |
| from .llm import AllProvidersFailed, active_provider, generate_reply | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") | |
| log = logging.getLogger("system-agent") | |
| # Comma-separated exact origins; no wildcard, ever. | |
| ALLOWED_ORIGINS = [ | |
| o.strip() | |
| for o in os.environ.get( | |
| "ALLOWED_ORIGINS", | |
| "https://brej-29.github.io,http://localhost:5500,http://localhost:8321", | |
| ).split(",") | |
| if o.strip() | |
| ] | |
| MAX_BODY_BYTES = 16_384 # 12 msgs x 500 chars leaves ample headroom | |
| app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=ALLOWED_ORIGINS, | |
| allow_methods=["POST", "OPTIONS"], | |
| allow_headers=["Content-Type"], | |
| max_age=3600, | |
| ) | |
| limiter = RateLimiter() | |
| async def hardening(request: Request, call_next): | |
| started = time.monotonic() | |
| # Reject oversized payloads before parsing. | |
| length = request.headers.get("content-length") | |
| if length and length.isdigit() and int(length) > MAX_BODY_BYTES: | |
| response = JSONResponse(status_code=413, content={"error": "payload too large"}) | |
| else: | |
| response = await call_next(request) | |
| response.headers["X-Content-Type-Options"] = "nosniff" | |
| response.headers["Cache-Control"] = "no-store" | |
| response.headers["Referrer-Policy"] = "no-referrer" | |
| # Privacy-respecting access log: no message content, no full IPs. | |
| log.info( | |
| "method=%s path=%s status=%s latency_ms=%d", | |
| request.method, | |
| request.url.path, | |
| response.status_code, | |
| (time.monotonic() - started) * 1000, | |
| ) | |
| return response | |
| async def validation_handler(request: Request, exc: RequestValidationError): | |
| # Uniform contract: every failure body is {"error": "..."}; details are | |
| # intentionally generic so probing reveals nothing about internals. | |
| return JSONResponse(status_code=422, content={"error": "invalid request format"}) | |
| async def unhandled_handler(request: Request, exc: Exception): | |
| log.error("unhandled error: %s", type(exc).__name__) | |
| return JSONResponse(status_code=500, content={"error": "internal error"}) | |
| async def health(): | |
| return {"status": "ok", "provider": active_provider()} | |
| async def chat(request: Request, payload: ChatRequest): | |
| ip = client_ip(request.headers, request.client.host if request.client else "unknown") | |
| allowed, reason = limiter.check(ip) | |
| if not allowed: | |
| return JSONResponse(status_code=429, content={"error": reason}) | |
| # Sanitize + delimit every user turn; assistant turns are echoes of our own | |
| # prior output but get sanitized anyway (defense in depth). | |
| messages = [ | |
| { | |
| "role": m.role, | |
| "content": wrap_user_content(m.content) if m.role == "user" else m.content[:1600], | |
| } | |
| for m in payload.messages | |
| ] | |
| try: | |
| reply = await generate_reply(messages) | |
| except AllProvidersFailed: | |
| return JSONResponse(status_code=503, content={"error": "SYSTEM LINK UNSTABLE"}) | |
| return ChatResponse(reply=reply) | |