Spaces:
Sleeping
Sleeping
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}
|