File size: 11,658 Bytes
e280d04
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
"""
Company-knowledge API routes β€” Phase 6 (docs/HARDENING.md first-customer UX).

Mounted by api/server.py:
    from api.knowledge import router as knowledge_router
    app.include_router(knowledge_router)

Endpoints (bearer-gated by the default auth middleware β€” api/auth.py):
    POST /knowledge/docs                β€” multipart upload (md/txt/pdf,
                                          <= 5 MB) β†’ doc dict   [admin|sme]
    GET  /knowledge/docs                β€” org's docs, summaries only [any role]
    GET  /knowledge/docs/{id}           β€” full decrypted text    [admin|sme]
    POST /knowledge/drafts              β€” {docIds, title} β†’ generated L5
                                          draft (blueprint + decrypted items
                                          for review)            [admin|sme]
    GET  /knowledge/drafts              β€” org's drafts [any role; items (T2)
                                          included only for admin|sme]
    POST /knowledge/drafts/{id}/approve β€” {note} β†’ sign-off dict [sme ONLY]
    POST /knowledge/drafts/{id}/reject  β€” {note} β†’ sign-off dict [sme ONLY]
    POST /knowledge/drafts/{id}/exam    β€” {candidateSpec?, agentId?,
                                          dryRun=true} β†’ {jobId}; job-backed
                                          like /atp/exams/run    [admin|sme]

Tenancy (docs/TENANCY.md β€” this is T2 "crown jewels" data):
  * org_id comes ONLY from verified request state (request.state.org_id, set
    by the auth middleware from the JWT) β€” never from body/query. All storage
    is org-scoped + encrypted at rest in atp/knowledge.py.
  * Uploads are stored as encrypted DB columns ONLY β€” no file ever lands on
    disk, and in particular never under the public /videos or /media
    prefixes (leak surface #4).
  * Cross-tenant/unknown ids β†’ the same 404 (no existence oracle, #6).

Separation of duties: admins (or SMEs) manage docs and generate drafts, but
ONLY role 'sme' can approve/reject β€” an admin must not be able to sign off
the exam content they themselves assembled. The BU_AUTH_DISABLED=1 dev
bypass (which runs every request as org-demo with role 'admin') is exempted
from that one gate so the demo flow keeps working end-to-end; with auth
enabled there is no exemption.
"""

from __future__ import annotations

from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from pydantic import BaseModel

from api.auth import require_role
from atp import knowledge

router = APIRouter(prefix="/knowledge")

#: Upload size cap (bytes) β€” Phase 6 contract: 5 MB.
MAX_UPLOAD_BYTES = 5 * 1024 * 1024


# ── Helpers ──────────────────────────────────────────────────────────────────

def _org(request: Request) -> str:
    """Org for this request β€” ONLY from verified auth state (TENANCY.md Β§4
    layer 1). 'org-demo' when auth is disabled / legacy single-admin token."""
    return getattr(request.state, "org_id", None) or "org-demo"


def _user(request: Request) -> str:
    return getattr(request.state, "user", None) or "anon"


def require_sme(request: Request) -> str:
    """Sign-off gate: role 'sme' ONLY (separation of duties β€” admins manage,
    SMEs sign). Dev exception: the BU_AUTH_DISABLED=1 bypass carries role
    'admin' for every request (api/auth.py), so it is allowed through here β€”
    otherwise the demo could never exercise approval. With auth enabled the
    gate is strict."""
    role = getattr(request.state, "role", None)
    if role is None:
        raise HTTPException(
            status_code=401, detail="authentication required",
            headers={"WWW-Authenticate": "Bearer"},
        )
    from api import auth as _auth
    if role != "sme" and not _auth.AUTH_DISABLED:
        raise HTTPException(
            403, "draft sign-off requires role 'sme' (separation of duties: "
                 "admins manage documents and drafts; SMEs sign)")
    return role


def _http_errors(fn, *args, **kwargs):
    """Run a knowledge-module call, translating its typed errors to HTTP.

    UnsupportedDocumentError β†’ 415, KnowledgeError β†’ 400, DraftStateError β†’
    409. The fail-loud missing-DATA_ENCRYPTION_KEY RuntimeError from
    atp/crypto.py becomes a 503 with an actionable message (T2 content is
    NEVER written or served as plaintext instead)."""
    try:
        return fn(*args, **kwargs)
    except knowledge.UnsupportedDocumentError as e:
        raise HTTPException(415, str(e)) from None
    except knowledge.KnowledgeError as e:
        raise HTTPException(400, str(e)) from None
    except knowledge.DraftStateError as e:
        raise HTTPException(409, str(e)) from None
    except RuntimeError as e:
        if "DATA_ENCRYPTION_KEY" in str(e):
            raise HTTPException(
                503, "DATA_ENCRYPTION_KEY is not configured on this "
                     "deployment β€” company-knowledge content is T2 and is "
                     "only ever stored/served encrypted (docs/TENANCY.md)",
            ) from None
        raise


# ── Documents ────────────────────────────────────────────────────────────────

@router.post("/docs", dependencies=[Depends(require_role("admin", "sme"))])
async def upload_doc(request: Request, file: UploadFile = File(...)):
    """Upload one company document (md/txt/pdf, <= 5 MB) into the org vault.

    Content is encrypted under the org's subkey before it touches the DB; the
    response returns metadata + the heuristic summary, not the full text.
    """
    if knowledge.doc_kind(file.filename, file.content_type) is None:
        raise HTTPException(
            415, f"unsupported document type (filename={file.filename!r}, "
                 f"content-type={file.content_type!r}) β€” allowed: .md, .txt, "
                 f".pdf")
    data = await file.read(MAX_UPLOAD_BYTES + 1)
    if len(data) > MAX_UPLOAD_BYTES:
        raise HTTPException(
            413, f"file exceeds the {MAX_UPLOAD_BYTES // (1024 * 1024)} MB "
                 f"upload cap")
    if not data:
        raise HTTPException(400, "empty upload")
    return _http_errors(
        knowledge.ingest_doc, _org(request), file.filename,
        file.content_type, data, uploaded_by=_user(request))


@router.get("/docs", dependencies=[Depends(require_role())])
def get_docs(request: Request):
    """Org's vault docs, latest first β€” decrypted SUMMARIES only (any
    authenticated role; full text stays behind the admin|sme route)."""
    return {"docs": _http_errors(knowledge.list_docs, _org(request))}


@router.get("/docs/{doc_id}",
            dependencies=[Depends(require_role("admin", "sme"))])
def get_doc(doc_id: str, request: Request):
    """One doc with the full decrypted text (admin|sme). Cross-tenant ids
    404 with the SAME message as unknown ids (TENANCY.md #6)."""
    doc = _http_errors(knowledge.get_doc, _org(request), doc_id)
    if doc is None:
        raise HTTPException(404, f"doc {doc_id} not found")
    return doc


# ── L5 drafts ────────────────────────────────────────────────────────────────

class DraftBody(BaseModel):
    docIds: list[str]
    title: str = ""


@router.post("/drafts", dependencies=[Depends(require_role("admin", "sme"))])
def create_draft(body: DraftBody, request: Request):
    """Generate a deterministic L5 exam draft from the given vault docs
    (blueprint sections = doc titles; >= 8 recall/apply items). The response
    includes the DECRYPTED items so the reviewer can read them β€” this route
    is admin|sme for exactly that reason."""
    return _http_errors(
        knowledge.generate_l5_draft, _org(request), body.docIds,
        created_by=_user(request), title=body.title)


@router.get("/drafts")
def get_drafts(request: Request, role: str = Depends(require_role())):
    """Org's drafts, latest first (any authenticated role). The decrypted
    items (T2 β€” verbatim company passages) are included only for admin|sme;
    viewers get blueprint + status metadata."""
    include_items = role in ("admin", "sme")
    return {"drafts": _http_errors(
        knowledge.list_drafts, _org(request), include_items=include_items)}


class ReviewBody(BaseModel):
    note: str = ""


@router.post("/drafts/{draft_id}/approve",
             dependencies=[Depends(require_sme)])
def approve_draft(draft_id: str, body: ReviewBody, request: Request):
    """SME sign-off (role sme ONLY): flips the draft to 'approved', appends a
    signed 'sme_signoff' evidence row to the org chain, and returns the
    evidence id + examCertRef + examRunnable flag."""
    out = _http_errors(
        knowledge.approve_draft, _org(request), draft_id,
        reviewer=_user(request), note=body.note)
    if out is None:
        raise HTTPException(404, f"draft {draft_id} not found")
    return out


@router.post("/drafts/{draft_id}/reject",
             dependencies=[Depends(require_sme)])
def reject_draft(draft_id: str, body: ReviewBody, request: Request):
    """SME rejection (role sme ONLY) β€” same evidence trail as approval,
    payload outcome 'rejected'."""
    out = _http_errors(
        knowledge.reject_draft, _org(request), draft_id,
        reviewer=_user(request), note=body.note)
    if out is None:
        raise HTTPException(404, f"draft {draft_id} not found")
    return out


# ── Exam over the approved draft bank ────────────────────────────────────────

class DraftExamBody(BaseModel):
    candidateSpec: str | None = None   # agents/backend.get_backend spec string
    agentId: str | None = None
    dryRun: bool = True                # deterministic stub model (CI-safe)


@router.post("/drafts/{draft_id}/exam",
             dependencies=[Depends(require_role("admin", "sme"))])
def run_draft_exam(draft_id: str, body: DraftExamBody, request: Request):
    """Run the certification exam against the org's APPROVED draft bank.

    Job-backed exactly like POST /atp/exams/run: returns {jobId}; poll
    GET /jobs/{jobId} β€” when status='done' the result is the award dict.
    The bank is resolved through atp/exams.py's Phase 6 loader path
    (ORG_CERT_PREFIX ref β†’ atp/knowledge.py in-memory bank; judge/candidate
    separation and evidence signing apply unchanged).
    """
    from agents import jobs

    org = _org(request)
    draft = _http_errors(knowledge.get_draft, org, draft_id)
    if draft is None:
        raise HTTPException(404, f"draft {draft_id} not found")
    if draft["status"] != "approved":
        raise HTTPException(
            409, f"draft {draft_id} is '{draft['status']}' β€” only an "
                 f"SME-approved draft can be examined")

    cert_ref = knowledge.EXAM_CERT_PREFIX + draft_id
    candidate_spec = body.candidateSpec
    agent_id = body.agentId
    dry_run = body.dryRun

    def _task():
        from atp import exams
        return exams.run_exam(
            cert_ref,
            candidate_spec,
            org_id=org,
            dry_run=dry_run,
            agent_id=agent_id,
        )

    job_id = jobs.submit("l5_draft_exam", _task, org_id=org)
    # jobId is the documented key; job_id keeps BU_API.runJob() compatible.
    return {"jobId": job_id, "job_id": job_id, "certRef": cert_ref}