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