File size: 4,176 Bytes
3bea99a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 | """
Policy Copilot over HTTP: the third path of this Space, next to /detect and /file.
POST /copilot/parse {"text", "context", "answers"} -> ParseResponse (copilot/pipeline.py)
GET /copilot/examples sample requests for the dashboard's empty state
Same `Authorization: Bearer <API_SECRET>` as /detect. The CLOAK server is the only intended
caller (server/src/modules/org/routes/copilot.ts): it checks the admin's session, builds the
tenant context from its database, and validates the answer again before the dashboard sees it.
What this path does and does not do:
- stateless; nothing is stored, nothing is written, no network calls
- the admin's text is never logged (they paste real values into it): only the request id,
the outcome and the latency
- bounded: text <= MAX_TEXT_CHARS, whole body <= MAX_BODY_BYTES (enforced in app.py before the
body is read), and the context is trimmed again by copilot/platform/context.py
- isolated: if the copilot cannot load, only /copilot/* answers 503; /detect and /file are
unaffected, exactly like docclass.py
"""
from __future__ import annotations
import logging
import time
from typing import Any, Callable
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field
log = logging.getLogger("cloak.copilot")
# The dashboard box allows 2,000 characters; the server cuts to the same length.
MAX_TEXT_CHARS = 2_000
# A large tenant's context (groups, rules, custom block lists with their entries, policies)
# stays well under this. Checked from Content-Length in app.py, before the body is read.
MAX_BODY_BYTES = 2_000_000
_parse: Callable[..., dict] | None = None
_examples: Callable[[], list[str]] | None = None
_snapshot: str | None = None
LOAD_ERROR: str | None = None
def load() -> None:
"""Import the copilot and warm its vocabulary. Never raises; a failure is reported by /health."""
global _parse, _examples, _snapshot, LOAD_ERROR
try:
from copilot.pipeline import examples, parse
from copilot.platform import catalog
warm = parse("block aadhaar in chatgpt", {}) # builds the vocabulary index once
if warm.get("status") == "error":
raise RuntimeError("warm-up request failed")
_parse, _examples = parse, examples
_snapshot = catalog.snapshot()["_source"]["syncedAt"]
LOAD_ERROR = None
log.info("policy copilot ready (platform snapshot %s)", _snapshot)
except Exception as exc: # noqa: BLE001 - any failure leaves only this path dark
_parse = _examples = None
LOAD_ERROR = f"{type(exc).__name__}: {exc}"
log.exception("policy copilot failed to load")
def status() -> dict:
return {"loaded": _parse is not None, "snapshot": _snapshot, "error": LOAD_ERROR,
"max_text_chars": MAX_TEXT_CHARS}
class ParseRequest(BaseModel):
# Unknown fields are refused, so a caller bug shows up as a 422, not a silently ignored key.
model_config = ConfigDict(extra="forbid")
text: str = Field(..., min_length=1, max_length=MAX_TEXT_CHARS)
context: dict[str, Any] = Field(default_factory=dict)
answers: dict[str, Any] = Field(default_factory=dict)
def _ready() -> Callable[..., dict]:
if _parse is None:
raise HTTPException(status_code=503, detail="Policy Copilot is not loaded")
return _parse
def build_router(verify: Callable[[Request], None]) -> APIRouter:
router = APIRouter(prefix="/copilot")
@router.post("/parse")
def parse_route(body: ParseRequest, request: Request):
verify(request)
parse = _ready()
t0 = time.perf_counter()
out = parse(body.text, body.context, body.answers) # never raises: errors become status "error"
log.info("copilot parse %s status=%s drafts=%d %.1fms", out.get("requestId"), out.get("status"),
len(out.get("drafts") or []), (time.perf_counter() - t0) * 1000)
return out
@router.get("/examples")
def examples_route(request: Request):
verify(request)
_ready()
return {"examples": _examples() if _examples else []}
return router
|