Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- atp/__init__.py +23 -0
- atp/crypto.py +139 -0
- atp/db.py +312 -0
- atp/exams.py +435 -0
- atp/items/README.md +63 -0
- atp/items/atp-l1-core.json +96 -0
- atp/items/atp-l4-math.json +152 -0
- atp/knowledge.py +641 -0
- atp/licensing.py +508 -0
- atp/reportcard.py +697 -0
- atp/seed.py +0 -0
- atp/signing.py +237 -0
- atp/store.py +406 -0
- atp/tenant_db.py +219 -0
atp/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ATP β the Agentic Training Platform data + store layer.
|
| 3 |
+
|
| 4 |
+
`atp.get_data()` returns the full `window.ATP_DATA` payload (seed, cached).
|
| 5 |
+
The store also exposes append-only HITL reward logging and the compose /
|
| 6 |
+
train-to-order request log.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from .store import (
|
| 10 |
+
get_data,
|
| 11 |
+
append_hitl,
|
| 12 |
+
read_hitl,
|
| 13 |
+
append_request,
|
| 14 |
+
read_requests,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"get_data",
|
| 19 |
+
"append_hitl",
|
| 20 |
+
"read_hitl",
|
| 21 |
+
"append_request",
|
| 22 |
+
"read_requests",
|
| 23 |
+
]
|
atp/crypto.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Envelope encryption for T2 ("crown jewels") columns β docs/TENANCY.md.
|
| 3 |
+
|
| 4 |
+
Key model (leak surface #10 β one key must not unlock all orgs):
|
| 5 |
+
|
| 6 |
+
* Master key: `DATA_ENCRYPTION_KEY` env var, read at CALL time (never at
|
| 7 |
+
import). Any high-entropy string; its utf-8 bytes are the HKDF input key
|
| 8 |
+
material. Only ever supplied via env β never stored in the repo or DB.
|
| 9 |
+
* Per-org subkey: HKDF-SHA256(master, info=org_id) β 32 bytes β urlsafe-b64
|
| 10 |
+
β Fernet key. Deterministic, so no key table is needed; compromise of one
|
| 11 |
+
derived key does not reveal the master or any sibling org's key.
|
| 12 |
+
* Rotation (re-wrap): stand up the new master alongside the old
|
| 13 |
+
(`DATA_ENCRYPTION_KEY_OLD` by operator convention), then for each T2 row:
|
| 14 |
+
decrypt with the old master's per-org subkey, encrypt with the new one,
|
| 15 |
+
write back. `enc1:` is the version tag β a future algorithm/rotation
|
| 16 |
+
scheme bumps to `enc2:` so mixed-version tables stay readable.
|
| 17 |
+
|
| 18 |
+
Wire format: `'enc1:' + Fernet token` stored in TEXT columns. Values WITHOUT
|
| 19 |
+
the prefix are legacy plaintext and read through unchanged β decryption of
|
| 20 |
+
legacy rows never requires a key, so pre-encryption databases keep working.
|
| 21 |
+
|
| 22 |
+
Fail-loud: encrypting anything, or decrypting an `enc1:` token, without
|
| 23 |
+
`DATA_ENCRYPTION_KEY` raises RuntimeError immediately (no silent plaintext
|
| 24 |
+
writes, no silent empty reads). A token that fails authentication (tampered,
|
| 25 |
+
or decrypted under the wrong org's subkey) raises ValueError.
|
| 26 |
+
|
| 27 |
+
Public:
|
| 28 |
+
encrypt_for_org(org_id, data) -> 'enc1:β¦' str (data: bytes or str)
|
| 29 |
+
decrypt_for_org(org_id, value) -> bytes | None (plaintext passthrough)
|
| 30 |
+
decrypt_text_for_org(org_id, value) -> str | None (utf-8 convenience)
|
| 31 |
+
is_encrypted(value) -> bool
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import base64
|
| 37 |
+
import os
|
| 38 |
+
import threading
|
| 39 |
+
|
| 40 |
+
from cryptography.fernet import Fernet, InvalidToken
|
| 41 |
+
from cryptography.hazmat.primitives import hashes
|
| 42 |
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
| 43 |
+
|
| 44 |
+
ENC_PREFIX = "enc1:"
|
| 45 |
+
|
| 46 |
+
_LOCK = threading.Lock()
|
| 47 |
+
# (master_bytes, org_id) -> Fernet. Keyed by master so tests / rotation that
|
| 48 |
+
# change the env var never serve a stale subkey.
|
| 49 |
+
_FERNET_CACHE: dict[tuple[bytes, str], Fernet] = {}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _master() -> bytes:
|
| 53 |
+
"""Return the master key bytes, failing loudly when encryption was
|
| 54 |
+
requested but no key is configured."""
|
| 55 |
+
key = os.environ.get("DATA_ENCRYPTION_KEY", "")
|
| 56 |
+
if not key.strip():
|
| 57 |
+
raise RuntimeError(
|
| 58 |
+
"DATA_ENCRYPTION_KEY is not set but an encrypt/decrypt of T2 "
|
| 59 |
+
"data was requested. Refusing to continue (docs/TENANCY.md data "
|
| 60 |
+
"class T2). Set DATA_ENCRYPTION_KEY in the deployment "
|
| 61 |
+
"environment (see .env.example); legacy plaintext values still "
|
| 62 |
+
"read through without it."
|
| 63 |
+
)
|
| 64 |
+
return key.strip().encode("utf-8")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _fernet_for_org(org_id: str) -> Fernet:
|
| 68 |
+
"""Per-org Fernet from HKDF-SHA256(master, info=org_id)."""
|
| 69 |
+
if not org_id or not isinstance(org_id, str):
|
| 70 |
+
raise ValueError("org_id must be a non-empty string")
|
| 71 |
+
master = _master()
|
| 72 |
+
cache_key = (master, org_id)
|
| 73 |
+
f = _FERNET_CACHE.get(cache_key)
|
| 74 |
+
if f is None:
|
| 75 |
+
with _LOCK:
|
| 76 |
+
f = _FERNET_CACHE.get(cache_key)
|
| 77 |
+
if f is None:
|
| 78 |
+
derived = HKDF(
|
| 79 |
+
algorithm=hashes.SHA256(),
|
| 80 |
+
length=32,
|
| 81 |
+
salt=None, # HKDF treats absent salt as zeros β fine:
|
| 82 |
+
# uniqueness comes from info=org_id
|
| 83 |
+
info=org_id.encode("utf-8"),
|
| 84 |
+
).derive(master)
|
| 85 |
+
f = Fernet(base64.urlsafe_b64encode(derived))
|
| 86 |
+
_FERNET_CACHE[cache_key] = f
|
| 87 |
+
return f
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def is_encrypted(value: str | bytes | None) -> bool:
|
| 91 |
+
"""True when `value` carries the enc1 envelope prefix."""
|
| 92 |
+
if isinstance(value, bytes):
|
| 93 |
+
return value.startswith(ENC_PREFIX.encode("ascii"))
|
| 94 |
+
if isinstance(value, str):
|
| 95 |
+
return value.startswith(ENC_PREFIX)
|
| 96 |
+
return False
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def encrypt_for_org(org_id: str, data: bytes | str) -> str:
|
| 100 |
+
"""Encrypt `data` under `org_id`'s subkey β 'enc1:<fernet token>'.
|
| 101 |
+
|
| 102 |
+
Fails loudly (RuntimeError) when DATA_ENCRYPTION_KEY is unset β callers
|
| 103 |
+
must never fall back to writing plaintext for T2 fields.
|
| 104 |
+
"""
|
| 105 |
+
if isinstance(data, str):
|
| 106 |
+
data = data.encode("utf-8")
|
| 107 |
+
if not isinstance(data, bytes):
|
| 108 |
+
raise TypeError(f"encrypt_for_org expects bytes or str, got {type(data).__name__}")
|
| 109 |
+
token = _fernet_for_org(org_id).encrypt(data)
|
| 110 |
+
return ENC_PREFIX + token.decode("ascii")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def decrypt_for_org(org_id: str, value: str | bytes | None) -> bytes | None:
|
| 114 |
+
"""Decrypt an 'enc1:' envelope; pass legacy plaintext through as bytes.
|
| 115 |
+
|
| 116 |
+
* None β None.
|
| 117 |
+
* 'enc1:β¦' β decrypted bytes (RuntimeError without a key; ValueError if
|
| 118 |
+
the token is tampered with or belongs to a different org's subkey).
|
| 119 |
+
* anything else β returned unchanged as bytes (str is utf-8 encoded):
|
| 120 |
+
pre-encryption rows keep reading without DATA_ENCRYPTION_KEY.
|
| 121 |
+
"""
|
| 122 |
+
if value is None:
|
| 123 |
+
return None
|
| 124 |
+
if not is_encrypted(value):
|
| 125 |
+
return value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
| 126 |
+
token = value[len(ENC_PREFIX):] if isinstance(value, str) else value[len(ENC_PREFIX):].decode("ascii")
|
| 127 |
+
try:
|
| 128 |
+
return _fernet_for_org(org_id).decrypt(token.encode("ascii") if isinstance(token, str) else token)
|
| 129 |
+
except InvalidToken:
|
| 130 |
+
raise ValueError(
|
| 131 |
+
f"enc1 token failed to decrypt under org {org_id!r}: wrong org "
|
| 132 |
+
"subkey, wrong master key, or tampered ciphertext"
|
| 133 |
+
) from None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def decrypt_text_for_org(org_id: str, value: str | bytes | None) -> str | None:
|
| 137 |
+
"""decrypt_for_org, decoded as utf-8 (T2 columns are TEXT)."""
|
| 138 |
+
out = decrypt_for_org(org_id, value)
|
| 139 |
+
return None if out is None else out.decode("utf-8")
|
atp/db.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DB access layer β SQLAlchemy Core engine, tiny SQL helpers, migration runner.
|
| 3 |
+
|
| 4 |
+
One engine per process (module-level singleton), configured by env:
|
| 5 |
+
|
| 6 |
+
DATABASE_URL β full SQLAlchemy URL. Unset β SQLite (see below).
|
| 7 |
+
Postgres: postgresql+psycopg2://user:pass@host:5432/db
|
| 8 |
+
(bare postgres:// / postgresql:// URLs, as handed out by
|
| 9 |
+
Render/Heroku, are normalized to the psycopg2 driver).
|
| 10 |
+
BRAIN_DB β SQLite path used when DATABASE_URL is unset
|
| 11 |
+
(default data/brain_university.db; parent dir created).
|
| 12 |
+
BU_DB_POOL_SIZE β Postgres pool size (default 5; max_overflow fixed at 5).
|
| 13 |
+
|
| 14 |
+
Public:
|
| 15 |
+
get_engine() -> Engine lazy singleton
|
| 16 |
+
is_postgres() -> bool True when the engine talks to Postgres
|
| 17 |
+
query(sql, params) -> list[dict] SELECTs; named params (:name), both dialects
|
| 18 |
+
execute(sql, params) -> ExecResult (.rowcount, .lastrowid)
|
| 19 |
+
run_migrations() -> list[str] apply pending migrations/ files (idempotent)
|
| 20 |
+
reset_engine() dispose the singleton (tests / env changes)
|
| 21 |
+
|
| 22 |
+
Writing SQL that works on BOTH dialects:
|
| 23 |
+
* Bind values with SQLAlchemy named params (:name) β never string-format
|
| 24 |
+
values into SQL. `query`/`execute` wrap the SQL in sqlalchemy.text().
|
| 25 |
+
* For INSERT ids, append `RETURNING <id_col>` β both Postgres and SQLite
|
| 26 |
+
>= 3.35 (Python 3.11's bundled build) support it, and `execute()` surfaces
|
| 27 |
+
the first returned column as `.lastrowid`. Plain INSERTs also populate
|
| 28 |
+
`.lastrowid` on SQLite; on Postgres use RETURNING.
|
| 29 |
+
|
| 30 |
+
Migrations live in migrations/ as `NNN_name[.pg|.sqlite].sql`. For a given
|
| 31 |
+
base name (`NNN_name`) the file suffixed with the current dialect wins over
|
| 32 |
+
the plain `.sql` one. Applied base names are tracked in `schema_migrations`;
|
| 33 |
+
each file is applied inside a single transaction, so a failed migration
|
| 34 |
+
leaves the DB untouched (Postgres; SQLite DDL is best-effort transactional).
|
| 35 |
+
|
| 36 |
+
Statement-separator convention (also documented at the top of each migration
|
| 37 |
+
file): a line containing only `--;;` splits the file into statements β needed
|
| 38 |
+
because trigger bodies contain ';'. Files without any `--;;` line are split
|
| 39 |
+
on plain ';'.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
from __future__ import annotations
|
| 43 |
+
|
| 44 |
+
import os
|
| 45 |
+
import re
|
| 46 |
+
import time
|
| 47 |
+
from pathlib import Path
|
| 48 |
+
from typing import NamedTuple
|
| 49 |
+
|
| 50 |
+
from sqlalchemy import create_engine, text
|
| 51 |
+
from sqlalchemy.engine import Engine, make_url
|
| 52 |
+
from sqlalchemy.pool import NullPool
|
| 53 |
+
|
| 54 |
+
PROJECT_ROOT = Path(__file__).parent.parent
|
| 55 |
+
MIGRATIONS_DIR = PROJECT_ROOT / "migrations"
|
| 56 |
+
DEFAULT_SQLITE_DB = PROJECT_ROOT / "data" / "brain_university.db"
|
| 57 |
+
|
| 58 |
+
STATEMENT_MARKER = "--;;"
|
| 59 |
+
|
| 60 |
+
_ENGINE: Engine | None = None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class ExecResult(NamedTuple):
|
| 64 |
+
"""Outcome of a write statement.
|
| 65 |
+
|
| 66 |
+
rowcount β rows affected (UPDATE/DELETE; driver-dependent for INSERT).
|
| 67 |
+
lastrowid β SQLite: rowid of the last INSERT. Postgres: only populated
|
| 68 |
+
when the statement uses RETURNING (first column of the first
|
| 69 |
+
returned row) β RETURNING also works on SQLite >= 3.35, so
|
| 70 |
+
`INSERT ... RETURNING id` is the portable way to get new ids.
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
rowcount: int
|
| 74 |
+
lastrowid: int | None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ββ Engine ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 78 |
+
|
| 79 |
+
def _database_url() -> str:
|
| 80 |
+
url = os.environ.get("DATABASE_URL", "").strip()
|
| 81 |
+
if url:
|
| 82 |
+
# Render/Heroku hand out postgres:// β SQLAlchemy 2.x needs the
|
| 83 |
+
# explicit driver segment.
|
| 84 |
+
if url.startswith("postgres://"):
|
| 85 |
+
url = "postgresql+psycopg2://" + url[len("postgres://"):]
|
| 86 |
+
elif url.startswith("postgresql://"):
|
| 87 |
+
url = "postgresql+psycopg2://" + url[len("postgresql://"):]
|
| 88 |
+
return url
|
| 89 |
+
# No DATABASE_URL β SQLite. Honour BRAIN_DB (already set by Dockerfile /
|
| 90 |
+
# render.yaml) so deployments keep pointing at the same file as before.
|
| 91 |
+
path = Path(os.environ.get("BRAIN_DB") or DEFAULT_SQLITE_DB)
|
| 92 |
+
return f"sqlite:///{path}"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def get_engine() -> Engine:
|
| 96 |
+
"""Return the process-wide engine, creating it on first use."""
|
| 97 |
+
global _ENGINE
|
| 98 |
+
if _ENGINE is None:
|
| 99 |
+
url = make_url(_database_url())
|
| 100 |
+
if url.get_backend_name() == "sqlite":
|
| 101 |
+
if url.database and url.database != ":memory:":
|
| 102 |
+
Path(url.database).parent.mkdir(parents=True, exist_ok=True)
|
| 103 |
+
# NullPool + check_same_thread=False: FastAPI serves requests on
|
| 104 |
+
# a thread pool; a fresh short-lived connection per operation is
|
| 105 |
+
# the safe pattern for SQLite.
|
| 106 |
+
_ENGINE = create_engine(
|
| 107 |
+
url,
|
| 108 |
+
poolclass=NullPool,
|
| 109 |
+
connect_args={"check_same_thread": False},
|
| 110 |
+
)
|
| 111 |
+
else:
|
| 112 |
+
_ENGINE = create_engine(
|
| 113 |
+
url,
|
| 114 |
+
pool_pre_ping=True, # survive dropped/idle-closed connections
|
| 115 |
+
pool_size=int(os.environ.get("BU_DB_POOL_SIZE", "5")),
|
| 116 |
+
max_overflow=5,
|
| 117 |
+
)
|
| 118 |
+
return _ENGINE
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def is_postgres() -> bool:
|
| 122 |
+
return get_engine().dialect.name == "postgresql"
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def reset_engine() -> None:
|
| 126 |
+
"""Dispose the singleton so the next call rebuilds it from env (tests)."""
|
| 127 |
+
global _ENGINE
|
| 128 |
+
if _ENGINE is not None:
|
| 129 |
+
_ENGINE.dispose()
|
| 130 |
+
_ENGINE = None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ββ SQL helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 134 |
+
|
| 135 |
+
def query(sql: str, params: dict | None = None) -> list[dict]:
|
| 136 |
+
"""Run a SELECT (named params `:name`) and return rows as list[dict]."""
|
| 137 |
+
with get_engine().connect() as conn:
|
| 138 |
+
rows = conn.execute(text(sql), params or {}).mappings().all()
|
| 139 |
+
return [dict(r) for r in rows]
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def execute(sql: str, params: dict | None = None) -> ExecResult:
|
| 143 |
+
"""Run a write statement in its own transaction. See ExecResult."""
|
| 144 |
+
with get_engine().begin() as conn:
|
| 145 |
+
result = conn.execute(text(sql), params or {})
|
| 146 |
+
lastrowid: int | None = None
|
| 147 |
+
if result.returns_rows: # INSERT ... RETURNING <id> (both dialects)
|
| 148 |
+
row = result.first()
|
| 149 |
+
if row is not None:
|
| 150 |
+
lastrowid = row[0]
|
| 151 |
+
else:
|
| 152 |
+
try:
|
| 153 |
+
lastrowid = result.lastrowid # meaningful on SQLite
|
| 154 |
+
except Exception: # noqa: BLE001 β drivers without lastrowid
|
| 155 |
+
lastrowid = None
|
| 156 |
+
return ExecResult(rowcount=result.rowcount, lastrowid=lastrowid)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ββ Migrations ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 160 |
+
|
| 161 |
+
# NNN_name.sql | NNN_name.pg.sql | NNN_name.sqlite.sql
|
| 162 |
+
_MIGRATION_RE = re.compile(r"^(?P<base>\d+_\w+?)(?:\.(?P<dialect>pg|sqlite))?\.sql$")
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def run_migrations() -> list[str]:
|
| 166 |
+
"""Apply every unapplied migration file, in order. Returns applied names.
|
| 167 |
+
|
| 168 |
+
Idempotent: applied base names are recorded in schema_migrations, so a
|
| 169 |
+
second call is a no-op. Each migration runs in a single transaction
|
| 170 |
+
together with its schema_migrations bookkeeping row.
|
| 171 |
+
"""
|
| 172 |
+
engine = get_engine()
|
| 173 |
+
with engine.begin() as conn:
|
| 174 |
+
conn.exec_driver_sql(
|
| 175 |
+
"CREATE TABLE IF NOT EXISTS schema_migrations ("
|
| 176 |
+
" name TEXT PRIMARY KEY,"
|
| 177 |
+
" applied_at DOUBLE PRECISION NOT NULL"
|
| 178 |
+
")"
|
| 179 |
+
)
|
| 180 |
+
already = {r["name"] for r in query("SELECT name FROM schema_migrations")}
|
| 181 |
+
|
| 182 |
+
applied: list[str] = []
|
| 183 |
+
for base, path in _discover_migrations():
|
| 184 |
+
if base in already:
|
| 185 |
+
continue
|
| 186 |
+
statements = _split_statements(path.read_text())
|
| 187 |
+
with engine.begin() as conn:
|
| 188 |
+
# Raw DBAPI cursor, no parameters: exec_driver_sql() hands the
|
| 189 |
+
# driver an (empty) params object, which makes psycopg2 run its
|
| 190 |
+
# %-interpolation over the SQL β any literal '%' in a migration
|
| 191 |
+
# (e.g. plpgsql RAISE '%' placeholders in 002_atp.pg.sql) then
|
| 192 |
+
# raises TypeError. cursor.execute(sql) with no params skips
|
| 193 |
+
# interpolation entirely, on both drivers.
|
| 194 |
+
cursor = conn.connection.cursor()
|
| 195 |
+
try:
|
| 196 |
+
for stmt in statements:
|
| 197 |
+
cursor.execute(stmt)
|
| 198 |
+
finally:
|
| 199 |
+
cursor.close()
|
| 200 |
+
conn.execute(
|
| 201 |
+
text("INSERT INTO schema_migrations (name, applied_at)"
|
| 202 |
+
" VALUES (:name, :ts)"),
|
| 203 |
+
{"name": base, "ts": time.time()},
|
| 204 |
+
)
|
| 205 |
+
applied.append(base)
|
| 206 |
+
return applied
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _discover_migrations() -> list[tuple[str, Path]]:
|
| 210 |
+
"""Return [(base_name, path)] sorted by base name.
|
| 211 |
+
|
| 212 |
+
For each base name, the file suffixed with the current dialect
|
| 213 |
+
(.pg.sql / .sqlite.sql) is preferred over the shared .sql one. A base
|
| 214 |
+
that only ships the OTHER dialect's file is an error β migrations must
|
| 215 |
+
cover whichever dialect the deployment runs on.
|
| 216 |
+
"""
|
| 217 |
+
if not MIGRATIONS_DIR.is_dir():
|
| 218 |
+
return []
|
| 219 |
+
dialect = "pg" if is_postgres() else "sqlite"
|
| 220 |
+
by_base: dict[str, dict[str | None, Path]] = {}
|
| 221 |
+
for p in MIGRATIONS_DIR.iterdir():
|
| 222 |
+
m = _MIGRATION_RE.match(p.name)
|
| 223 |
+
if not m:
|
| 224 |
+
continue
|
| 225 |
+
by_base.setdefault(m.group("base"), {})[m.group("dialect")] = p
|
| 226 |
+
|
| 227 |
+
out: list[tuple[str, Path]] = []
|
| 228 |
+
for base in sorted(by_base):
|
| 229 |
+
files = by_base[base]
|
| 230 |
+
path = files.get(dialect) or files.get(None)
|
| 231 |
+
if path is None:
|
| 232 |
+
raise RuntimeError(
|
| 233 |
+
f"migration {base!r} has no file for dialect {dialect!r} "
|
| 234 |
+
f"(found: {sorted(p.name for p in files.values())})"
|
| 235 |
+
)
|
| 236 |
+
out.append((base, path))
|
| 237 |
+
return out
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _split_statements(sql: str) -> list[str]:
|
| 241 |
+
"""Split migration SQL into individual statements.
|
| 242 |
+
|
| 243 |
+
If the file contains any line that is exactly `--;;`, those lines are the
|
| 244 |
+
only separators (lets trigger/function bodies keep their internal ';').
|
| 245 |
+
Otherwise the file is split on top-level ';' β ignoring ';' inside `--`
|
| 246 |
+
line comments, `/* */` block comments, and 'quoted strings'. Files with
|
| 247 |
+
BEGIN...END trigger bodies or $$-quoted function bodies MUST use the
|
| 248 |
+
marker. Chunks that are empty or comment-only are dropped.
|
| 249 |
+
"""
|
| 250 |
+
lines = sql.splitlines()
|
| 251 |
+
if any(line.strip() == STATEMENT_MARKER for line in lines):
|
| 252 |
+
chunks: list[str] = []
|
| 253 |
+
current: list[str] = []
|
| 254 |
+
for line in lines:
|
| 255 |
+
if line.strip() == STATEMENT_MARKER:
|
| 256 |
+
chunks.append("\n".join(current))
|
| 257 |
+
current = []
|
| 258 |
+
else:
|
| 259 |
+
current.append(line)
|
| 260 |
+
chunks.append("\n".join(current))
|
| 261 |
+
else:
|
| 262 |
+
chunks = _split_on_semicolons(sql)
|
| 263 |
+
|
| 264 |
+
statements: list[str] = []
|
| 265 |
+
for chunk in chunks:
|
| 266 |
+
meaningful = "\n".join(
|
| 267 |
+
line for line in chunk.splitlines()
|
| 268 |
+
if line.strip() and not line.strip().startswith("--")
|
| 269 |
+
)
|
| 270 |
+
if meaningful.strip():
|
| 271 |
+
statements.append(chunk.strip())
|
| 272 |
+
return statements
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _split_on_semicolons(sql: str) -> list[str]:
|
| 276 |
+
"""Split on ';' outside comments and string literals (both dialects)."""
|
| 277 |
+
chunks: list[str] = []
|
| 278 |
+
buf: list[str] = []
|
| 279 |
+
i, n = 0, len(sql)
|
| 280 |
+
while i < n:
|
| 281 |
+
two = sql[i:i + 2]
|
| 282 |
+
if two == "--": # line comment β runs to end of line
|
| 283 |
+
j = sql.find("\n", i)
|
| 284 |
+
j = n if j == -1 else j
|
| 285 |
+
buf.append(sql[i:j])
|
| 286 |
+
i = j
|
| 287 |
+
elif two == "/*": # block comment
|
| 288 |
+
j = sql.find("*/", i + 2)
|
| 289 |
+
j = n if j == -1 else j + 2
|
| 290 |
+
buf.append(sql[i:j])
|
| 291 |
+
i = j
|
| 292 |
+
elif sql[i] == "'": # string literal ('' is the escaped quote)
|
| 293 |
+
j = i + 1
|
| 294 |
+
while j < n:
|
| 295 |
+
if sql[j] == "'":
|
| 296 |
+
if sql[j + 1:j + 2] == "'":
|
| 297 |
+
j += 2
|
| 298 |
+
continue
|
| 299 |
+
break
|
| 300 |
+
j += 1
|
| 301 |
+
j = min(j + 1, n)
|
| 302 |
+
buf.append(sql[i:j])
|
| 303 |
+
i = j
|
| 304 |
+
elif sql[i] == ";":
|
| 305 |
+
chunks.append("".join(buf))
|
| 306 |
+
buf = []
|
| 307 |
+
i += 1
|
| 308 |
+
else:
|
| 309 |
+
buf.append(sql[i])
|
| 310 |
+
i += 1
|
| 311 |
+
chunks.append("".join(buf))
|
| 312 |
+
return chunks
|
atp/exams.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
atp/exams.py β Phase 3 certification engine (docs/HARDENING.md).
|
| 3 |
+
|
| 4 |
+
Turns the ATP standard's promises (docs/ATP.md Β§2) into enforced code:
|
| 5 |
+
|
| 6 |
+
blueprint β item bank (atp/items/<cert_id>.json, human-editable)
|
| 7 |
+
β candidate answers via agents.backend.get_backend(candidate_spec)
|
| 8 |
+
β judge from a DIFFERENT model family β family_of(judge) !=
|
| 9 |
+
family_of(candidate) is checked BEFORE any model call, else
|
| 10 |
+
SeparationError (principle 2)
|
| 11 |
+
β verdict vs the cert's passCriteria (overall + perSection)
|
| 12 |
+
β evidence rows, append-only + HMAC-signed with a per-org
|
| 13 |
+
prev_hash chain via atp/signing.py (principles 1 & 3)
|
| 14 |
+
β award row in atp_cert_awards.
|
| 15 |
+
|
| 16 |
+
Reproducibility semantics: every evidence payload records the cert's
|
| 17 |
+
passCriteria seed and temperature (0). Backends are expected to decode
|
| 18 |
+
deterministically (greedy/temperature-0 β the HF/local backends already pin
|
| 19 |
+
do_sample=False / temperature 0.0); a run's `runs` repeats exist to surface
|
| 20 |
+
any residual nondeterminism, and each repeat's judged score is kept in
|
| 21 |
+
run_scores with the item score as their mean. runs = min(passCriteria.runs, 3)
|
| 22 |
+
to keep real exams affordable.
|
| 23 |
+
|
| 24 |
+
Dry-run mode (CI): `dry_run=True` routes the judge to 'dryrun:judge' and a
|
| 25 |
+
missing candidate to 'dryrun:candidate' β both DryRunBackend, deterministic
|
| 26 |
+
pure functions of the prompts, mapped to distinct synthetic families so the
|
| 27 |
+
separation check passes. Same inputs β same payloads β same sigs (given the
|
| 28 |
+
same EVIDENCE_SIGNING_KEY and chain position).
|
| 29 |
+
|
| 30 |
+
All DB access goes through atp/tenant_db.py (org-scoped; RLS on Postgres).
|
| 31 |
+
|
| 32 |
+
Public:
|
| 33 |
+
run_exam(cert_id, candidate_spec=None, org_id='org-demo',
|
| 34 |
+
dry_run=False, agent_id=None) -> award dict
|
| 35 |
+
SeparationError / UnknownCertError / MissingItemBankError
|
| 36 |
+
|
| 37 |
+
CLI:
|
| 38 |
+
BU_AUTH_DISABLED=1 python3 -m atp.exams atp-l4-math --dry-run --verify-chain
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
from __future__ import annotations
|
| 42 |
+
|
| 43 |
+
import json
|
| 44 |
+
import os
|
| 45 |
+
import threading
|
| 46 |
+
import time
|
| 47 |
+
from pathlib import Path
|
| 48 |
+
|
| 49 |
+
from agents.backend import family_of, get_backend
|
| 50 |
+
from atp import db, signing, store, tenant_db
|
| 51 |
+
|
| 52 |
+
ITEMS_DIR = Path(__file__).parent / "items"
|
| 53 |
+
HARNESS_VERSION = "atp-exams@2026.1-p3"
|
| 54 |
+
DEFAULT_JUDGE_SPEC = "claude-haiku-4-5" # env ATP_JUDGE_SPEC overrides
|
| 55 |
+
MAX_RUNS = 3 # cap on passCriteria.runs per item
|
| 56 |
+
|
| 57 |
+
_ITEM_KEYS = ("id", "section", "type", "prompt", "expected", "points")
|
| 58 |
+
|
| 59 |
+
_MIGRATED = False
|
| 60 |
+
_MIGRATE_LOCK = threading.Lock()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class SeparationError(RuntimeError):
|
| 64 |
+
"""Judge and candidate resolve to the same model family (principle 2)."""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class UnknownCertError(ValueError):
|
| 68 |
+
"""cert_id does not exist in the seed (atp/seed.py CERTS)."""
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class MissingItemBankError(FileNotFoundError):
|
| 72 |
+
"""No usable item bank for the cert β blueprints-before-commissioning."""
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _now() -> str:
|
| 76 |
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _ensure_db() -> None:
|
| 80 |
+
"""Idempotently apply migrations so a fresh SQLite file just works."""
|
| 81 |
+
global _MIGRATED
|
| 82 |
+
if _MIGRATED:
|
| 83 |
+
return
|
| 84 |
+
with _MIGRATE_LOCK:
|
| 85 |
+
if not _MIGRATED:
|
| 86 |
+
db.run_migrations()
|
| 87 |
+
_MIGRATED = True
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ββ Cert + item bank loading βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 91 |
+
|
| 92 |
+
#: Phase 6 extension point (docs/HARDENING.md β first-customer UX).
|
| 93 |
+
#: Cert refs of the form f"{ORG_CERT_PREFIX}<draft_id>" resolve through
|
| 94 |
+
#: atp/knowledge.py: SME-approved company-knowledge drafts whose item banks
|
| 95 |
+
#: are T2 (encrypted at rest, docs/TENANCY.md) and are therefore materialized
|
| 96 |
+
#: IN MEMORY β never written under atp/items/ or any shared directory.
|
| 97 |
+
#: Plain cert ids keep the pre-Phase-6 seed + items-file behavior unchanged;
|
| 98 |
+
#: callers that don't pass org_id (or deployments without atp/knowledge.py)
|
| 99 |
+
#: are byte-for-byte the old path. Must equal knowledge.EXAM_CERT_PREFIX.
|
| 100 |
+
ORG_CERT_PREFIX = "org-l5-draft:"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _org_bank(cert_id: str, org_id: str | None) -> dict | None:
|
| 104 |
+
"""Resolve an org-scoped draft bank, or None for the classic path."""
|
| 105 |
+
if not org_id or not cert_id.startswith(ORG_CERT_PREFIX):
|
| 106 |
+
return None
|
| 107 |
+
try:
|
| 108 |
+
from atp import knowledge # lazy β optional Phase 6 module
|
| 109 |
+
except ImportError: # pragma: no cover β knowledge not deployed
|
| 110 |
+
return None
|
| 111 |
+
return knowledge.load_org_item_bank(org_id, cert_id)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _load_cert(cert_id: str, org_id: str | None = None) -> dict:
|
| 115 |
+
org_bank = _org_bank(cert_id, org_id)
|
| 116 |
+
if org_bank is not None:
|
| 117 |
+
return org_bank["cert"]
|
| 118 |
+
if cert_id.startswith(ORG_CERT_PREFIX):
|
| 119 |
+
# Same error whether the draft is unknown, unapproved, or belongs to
|
| 120 |
+
# another org β no cross-tenant existence oracle (TENANCY.md #6).
|
| 121 |
+
raise UnknownCertError(
|
| 122 |
+
f"unknown cert {cert_id!r} β no SME-approved company-knowledge "
|
| 123 |
+
f"draft with this ref in the caller's org (atp/knowledge.py)"
|
| 124 |
+
)
|
| 125 |
+
for cert in store.get_data()["CERTS"]:
|
| 126 |
+
if cert["id"] == cert_id:
|
| 127 |
+
return cert
|
| 128 |
+
raise UnknownCertError(
|
| 129 |
+
f"unknown cert {cert_id!r} β certs are defined in atp/seed.py CERTS"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _load_item_bank(cert: dict, org_id: str | None = None) -> list[dict]:
|
| 134 |
+
"""Load + validate the cert's item bank against its blueprint.
|
| 135 |
+
|
| 136 |
+
Classic path: atp/items/<cert_id>.json. Phase 6 org path (ORG_CERT_PREFIX
|
| 137 |
+
refs + org_id): the in-memory bank from atp/knowledge.py β same
|
| 138 |
+
validation, same errors.
|
| 139 |
+
"""
|
| 140 |
+
cert_id = cert["id"]
|
| 141 |
+
org_bank = _org_bank(cert_id, org_id)
|
| 142 |
+
if org_bank is not None:
|
| 143 |
+
source = f"org bank {cert_id}"
|
| 144 |
+
items = org_bank.get("items") or []
|
| 145 |
+
if not items:
|
| 146 |
+
raise MissingItemBankError(f"item bank {source} has an empty 'items' list")
|
| 147 |
+
else:
|
| 148 |
+
path = ITEMS_DIR / f"{cert_id}.json"
|
| 149 |
+
if not path.is_file():
|
| 150 |
+
raise MissingItemBankError(
|
| 151 |
+
f"no item bank at {path} β a cert cannot be examined before its "
|
| 152 |
+
f"blueprint has a bank (blueprints-before-commissioning, "
|
| 153 |
+
f"docs/ATP.md Β§7). See atp/items/README.md."
|
| 154 |
+
)
|
| 155 |
+
bank = json.loads(path.read_text(encoding="utf-8"))
|
| 156 |
+
items = bank.get("items") or []
|
| 157 |
+
if not items:
|
| 158 |
+
raise MissingItemBankError(f"item bank {path} has an empty 'items' list")
|
| 159 |
+
source = str(path)
|
| 160 |
+
|
| 161 |
+
section_names = {s["name"] for s in cert["blueprint"]["sections"]}
|
| 162 |
+
for item in items:
|
| 163 |
+
missing = [k for k in _ITEM_KEYS if k not in item]
|
| 164 |
+
if missing:
|
| 165 |
+
raise ValueError(
|
| 166 |
+
f"item bank {source}: item {item.get('id', '?')!r} is missing "
|
| 167 |
+
f"key(s) {missing} (format: atp/items/README.md)"
|
| 168 |
+
)
|
| 169 |
+
if item["section"] not in section_names:
|
| 170 |
+
raise ValueError(
|
| 171 |
+
f"item bank {source}: item {item['id']!r} names unknown section "
|
| 172 |
+
f"{item['section']!r} β blueprint sections are "
|
| 173 |
+
f"{sorted(section_names)}"
|
| 174 |
+
)
|
| 175 |
+
uncovered = section_names - {i["section"] for i in items}
|
| 176 |
+
if uncovered:
|
| 177 |
+
raise ValueError(
|
| 178 |
+
f"item bank {source} has no items for blueprint section(s) "
|
| 179 |
+
f"{sorted(uncovered)} β passCriteria.perSection would be "
|
| 180 |
+
f"unverifiable"
|
| 181 |
+
)
|
| 182 |
+
return items
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ββ Judge prompt + defensive parsing βββββββββββββββββββββββββββββββββββββββββ
|
| 186 |
+
|
| 187 |
+
_JUDGE_SYSTEM = (
|
| 188 |
+
"You are the certification judge for the ATP (Agentic Training Platform) "
|
| 189 |
+
"exam harness. Grade the candidate's answer strictly against the "
|
| 190 |
+
"reference answer/criteria and the rubric. Respond with ONLY a strict "
|
| 191 |
+
"JSON object β no prose, no code fences: "
|
| 192 |
+
'{"score": <float between 0 and 1>, "rationale": "<one short sentence>"}'
|
| 193 |
+
)
|
| 194 |
+
_JUDGE_SYSTEM_RETRY = (
|
| 195 |
+
_JUDGE_SYSTEM
|
| 196 |
+
+ " Your previous reply was not parseable JSON. Output the JSON object "
|
| 197 |
+
"and NOTHING else."
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _judge_user(cert: dict, item: dict, answer: str) -> str:
|
| 202 |
+
rubric = "; ".join(
|
| 203 |
+
f"{r['axis']} (weight {r['weight']}): {r['desc']}"
|
| 204 |
+
for r in cert.get("rubric", [])
|
| 205 |
+
)
|
| 206 |
+
return (
|
| 207 |
+
f"Exam: {cert['label']}\n"
|
| 208 |
+
f"Rubric: {rubric}\n\n"
|
| 209 |
+
f"Question [{item['section']} / {item['type']}]:\n{item['prompt']}\n\n"
|
| 210 |
+
f"Reference answer / grading criteria (not shown to candidate):\n"
|
| 211 |
+
f"{item['expected']}\n\n"
|
| 212 |
+
f"Candidate answer:\n{answer}\n\n"
|
| 213 |
+
'Return strict JSON only: {"score": <float 0..1>, "rationale": "<one sentence>"}'
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _parse_judge(raw: str) -> dict | None:
|
| 218 |
+
"""Defensive parse: first '{' .. last '}', score must be a number in 0..1."""
|
| 219 |
+
try:
|
| 220 |
+
start = raw.index("{")
|
| 221 |
+
end = raw.rindex("}") + 1
|
| 222 |
+
obj = json.loads(raw[start:end])
|
| 223 |
+
score = float(obj["score"])
|
| 224 |
+
except (ValueError, KeyError, TypeError, AttributeError):
|
| 225 |
+
return None
|
| 226 |
+
if not (0.0 <= score <= 1.0):
|
| 227 |
+
return None
|
| 228 |
+
return {"score": score, "rationale": str(obj.get("rationale", ""))}
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# ββ Evidence writing (signed, chained, org-scoped) βββββββββββββββββββββββββββ
|
| 232 |
+
# All evidence rows for a run are accumulated in memory during the (slow)
|
| 233 |
+
# model-call loop and appended in ONE signing.append_chain() call β a single
|
| 234 |
+
# serialized transaction, so concurrent runs can't interleave the chain and
|
| 235 |
+
# a crash mid-run leaves no partially signed exam on disk.
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ββ The exam loop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 239 |
+
|
| 240 |
+
def run_exam(cert_id: str, candidate_spec: str | None = None,
|
| 241 |
+
org_id: str = tenant_db.DEFAULT_ORG, dry_run: bool = False,
|
| 242 |
+
agent_id: str | None = None) -> dict:
|
| 243 |
+
"""Run a full certification exam and return the award dict.
|
| 244 |
+
|
| 245 |
+
Raises UnknownCertError / MissingItemBankError / SeparationError before
|
| 246 |
+
any model call; SigningKeyError surfaces from atp/signing.py if evidence
|
| 247 |
+
cannot be signed.
|
| 248 |
+
"""
|
| 249 |
+
# org_id also feeds the Phase 6 loader path (ORG_CERT_PREFIX refs only;
|
| 250 |
+
# seed cert ids are unaffected by the extra argument).
|
| 251 |
+
cert = _load_cert(cert_id, org_id)
|
| 252 |
+
items = _load_item_bank(cert, org_id)
|
| 253 |
+
|
| 254 |
+
candidate = candidate_spec or "dryrun:candidate"
|
| 255 |
+
judge = "dryrun:judge" if dry_run else os.environ.get(
|
| 256 |
+
"ATP_JUDGE_SPEC", DEFAULT_JUDGE_SPEC)
|
| 257 |
+
|
| 258 |
+
# Principle 2 β judge/candidate separation, enforced BEFORE any model call.
|
| 259 |
+
if family_of(judge) == family_of(candidate):
|
| 260 |
+
raise SeparationError(
|
| 261 |
+
f"judge/candidate separation violated: judge {judge!r} and "
|
| 262 |
+
f"candidate {candidate!r} both resolve to model family "
|
| 263 |
+
f"{family_of(judge)!r} (docs/ATP.md Β§2 principle 2). Set "
|
| 264 |
+
f"ATP_JUDGE_SPEC to a different family, or pass a different "
|
| 265 |
+
f"candidate."
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
pc = cert["passCriteria"]
|
| 269 |
+
runs = max(1, min(int(pc.get("runs", 1)), MAX_RUNS))
|
| 270 |
+
seed = int(pc.get("seed", 0))
|
| 271 |
+
temperature = pc.get("temperature", 0)
|
| 272 |
+
|
| 273 |
+
_ensure_db()
|
| 274 |
+
candidate_backend = get_backend(candidate)
|
| 275 |
+
judge_backend = get_backend(judge)
|
| 276 |
+
|
| 277 |
+
candidate_system = (
|
| 278 |
+
f"You are sitting the {cert['label']} certification exam "
|
| 279 |
+
f"({cert['domain']}, layer L{cert['layer']}). Answer each question "
|
| 280 |
+
f"precisely and show your work. Reproducibility: decoding is pinned "
|
| 281 |
+
f"to temperature {temperature}, seed {seed} "
|
| 282 |
+
f"(harness {HARNESS_VERSION})."
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
agent = agent_id or candidate
|
| 286 |
+
ev_records: list[dict] = [] # appended to the chain in one txn
|
| 287 |
+
graded: list[tuple[dict, list[float], bool]] = []
|
| 288 |
+
|
| 289 |
+
for item in items:
|
| 290 |
+
run_scores: list[float] = []
|
| 291 |
+
flagged = False
|
| 292 |
+
for _ in range(runs):
|
| 293 |
+
answer = candidate_backend.complete(
|
| 294 |
+
candidate_system, item["prompt"], max_tokens=800)
|
| 295 |
+
judge_prompt = _judge_user(cert, item, answer)
|
| 296 |
+
verdict = _parse_judge(
|
| 297 |
+
judge_backend.complete(_JUDGE_SYSTEM, judge_prompt,
|
| 298 |
+
max_tokens=300))
|
| 299 |
+
if verdict is None: # retry once, stricter instruction
|
| 300 |
+
verdict = _parse_judge(
|
| 301 |
+
judge_backend.complete(_JUDGE_SYSTEM_RETRY, judge_prompt,
|
| 302 |
+
max_tokens=300))
|
| 303 |
+
if verdict is None: # still unparseable β item flagged
|
| 304 |
+
flagged = True
|
| 305 |
+
run_scores.append(0.0)
|
| 306 |
+
else:
|
| 307 |
+
run_scores.append(round(verdict["score"], 4))
|
| 308 |
+
|
| 309 |
+
payload = {
|
| 310 |
+
"certId": cert_id,
|
| 311 |
+
"itemId": item["id"],
|
| 312 |
+
"run_scores": run_scores,
|
| 313 |
+
"judge": judge,
|
| 314 |
+
"candidate": candidate,
|
| 315 |
+
"seed": seed,
|
| 316 |
+
"temperature": temperature,
|
| 317 |
+
}
|
| 318 |
+
ev_records.append({"ts": _now(), "agent_id": agent,
|
| 319 |
+
"cert_id": cert_id, "kind": "exam_item",
|
| 320 |
+
"payload": payload})
|
| 321 |
+
graded.append((item, run_scores, flagged))
|
| 322 |
+
|
| 323 |
+
# ββ Aggregate: points-weighted per section, blueprint-weighted overall ββ
|
| 324 |
+
item_bar = float(pc.get("perSection", 0.7)) # per-item pass bar
|
| 325 |
+
per_section: dict[str, list[tuple[float, float]]] = {}
|
| 326 |
+
breakdown = {"passed": 0, "failed": 0, "flagged": 0}
|
| 327 |
+
for item, run_scores, flagged in graded:
|
| 328 |
+
mean = sum(run_scores) / len(run_scores)
|
| 329 |
+
per_section.setdefault(item["section"], []).append(
|
| 330 |
+
(mean, float(item["points"])))
|
| 331 |
+
if flagged:
|
| 332 |
+
breakdown["flagged"] += 1
|
| 333 |
+
elif mean >= item_bar:
|
| 334 |
+
breakdown["passed"] += 1
|
| 335 |
+
else:
|
| 336 |
+
breakdown["failed"] += 1
|
| 337 |
+
|
| 338 |
+
section_scores = {
|
| 339 |
+
name: round(sum(s * p for s, p in vals) / sum(p for _, p in vals), 4)
|
| 340 |
+
for name, vals in per_section.items()
|
| 341 |
+
}
|
| 342 |
+
weights = {s["name"]: float(s["weight"])
|
| 343 |
+
for s in cert["blueprint"]["sections"]}
|
| 344 |
+
weight_sum = sum(weights[name] for name in section_scores)
|
| 345 |
+
overall = round(
|
| 346 |
+
sum(section_scores[name] * weights[name] for name in section_scores)
|
| 347 |
+
/ weight_sum, 4)
|
| 348 |
+
|
| 349 |
+
verdict_str = (
|
| 350 |
+
"pass"
|
| 351 |
+
if overall >= float(pc["overall"])
|
| 352 |
+
and all(v >= float(pc["perSection"]) for v in section_scores.values())
|
| 353 |
+
and breakdown["flagged"] == 0
|
| 354 |
+
else "fail"
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
# ββ Summary evidence row (kind=benchmark_run) βββββββββββββββββββββββββββ
|
| 358 |
+
summary_payload = {
|
| 359 |
+
"certId": cert_id,
|
| 360 |
+
"overall": overall,
|
| 361 |
+
"sectionScores": section_scores,
|
| 362 |
+
"verdict": verdict_str,
|
| 363 |
+
"itemBreakdown": breakdown,
|
| 364 |
+
"judge": judge,
|
| 365 |
+
"candidate": candidate,
|
| 366 |
+
"seed": seed,
|
| 367 |
+
"temperature": temperature,
|
| 368 |
+
"runs": runs,
|
| 369 |
+
"harness": HARNESS_VERSION,
|
| 370 |
+
}
|
| 371 |
+
ev_records.append({"ts": _now(), "agent_id": agent, "cert_id": cert_id,
|
| 372 |
+
"kind": "benchmark_run", "payload": summary_payload})
|
| 373 |
+
|
| 374 |
+
# One serialized, atomic chain append for the whole run.
|
| 375 |
+
evidence_ids = [row_id for row_id, _sig in
|
| 376 |
+
signing.append_chain(org_id, ev_records)]
|
| 377 |
+
|
| 378 |
+
# ββ Award row βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 379 |
+
ts = _now()
|
| 380 |
+
res = tenant_db.insert_scoped("atp_cert_awards", {
|
| 381 |
+
"ts": ts,
|
| 382 |
+
"agent_id": agent,
|
| 383 |
+
"cert_id": cert_id,
|
| 384 |
+
"score": overall,
|
| 385 |
+
"section_scores": json.dumps(section_scores, sort_keys=True),
|
| 386 |
+
"item_breakdown": json.dumps(breakdown, sort_keys=True),
|
| 387 |
+
"evidence_ids": json.dumps(evidence_ids),
|
| 388 |
+
}, org_id)
|
| 389 |
+
|
| 390 |
+
return {
|
| 391 |
+
"agentId": agent,
|
| 392 |
+
"certId": cert_id,
|
| 393 |
+
"ts": ts,
|
| 394 |
+
"score": overall,
|
| 395 |
+
"verdict": verdict_str,
|
| 396 |
+
"sectionScores": section_scores,
|
| 397 |
+
"itemBreakdown": breakdown,
|
| 398 |
+
"evidenceIds": evidence_ids,
|
| 399 |
+
"judge": judge,
|
| 400 |
+
"candidate": candidate,
|
| 401 |
+
"runs": runs,
|
| 402 |
+
"seed": seed,
|
| 403 |
+
"temperature": temperature,
|
| 404 |
+
"harness": HARNESS_VERSION,
|
| 405 |
+
"awardRowId": res.lastrowid,
|
| 406 |
+
"dryRun": bool(dry_run),
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
# ββ CLI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 411 |
+
|
| 412 |
+
if __name__ == "__main__":
|
| 413 |
+
import argparse
|
| 414 |
+
|
| 415 |
+
parser = argparse.ArgumentParser(
|
| 416 |
+
description="Run an ATP certification exam (Phase 3 engine).")
|
| 417 |
+
parser.add_argument("cert_id", help="e.g. atp-l4-math")
|
| 418 |
+
parser.add_argument("--candidate", default=None,
|
| 419 |
+
help="candidate backend spec (default: dryrun:candidate)")
|
| 420 |
+
parser.add_argument("--org", default=tenant_db.DEFAULT_ORG)
|
| 421 |
+
parser.add_argument("--agent-id", default=None,
|
| 422 |
+
help="agent id recorded on evidence/award rows "
|
| 423 |
+
"(default: the candidate spec)")
|
| 424 |
+
parser.add_argument("--dry-run", action="store_true",
|
| 425 |
+
help="deterministic stub judge/candidate β no model "
|
| 426 |
+
"calls, no cost (CI mode)")
|
| 427 |
+
parser.add_argument("--verify-chain", action="store_true",
|
| 428 |
+
help="verify the org's evidence chain after the run")
|
| 429 |
+
args = parser.parse_args()
|
| 430 |
+
|
| 431 |
+
award = run_exam(args.cert_id, args.candidate, org_id=args.org,
|
| 432 |
+
dry_run=args.dry_run, agent_id=args.agent_id)
|
| 433 |
+
print(json.dumps(award, indent=2))
|
| 434 |
+
if args.verify_chain:
|
| 435 |
+
print(json.dumps(signing.verify_chain(args.org), indent=2))
|
atp/items/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ATP item banks β `atp/items/*.json`
|
| 2 |
+
|
| 3 |
+
One JSON file per certification, named exactly after the cert id in
|
| 4 |
+
`atp/seed.py` (e.g. `atp-l4-math.json` for cert `atp-l4-math`). These are the
|
| 5 |
+
concrete exam questions behind each cert's blueprint. They are **generated +
|
| 6 |
+
human-editable**: SMEs edit these files directly; no build step.
|
| 7 |
+
|
| 8 |
+
**Blueprints-before-commissioning invariant** (docs/ATP.md Β§7): an exam can
|
| 9 |
+
only run for a cert whose item bank exists β `atp/exams.py` raises
|
| 10 |
+
`MissingItemBankError` otherwise. Adding a cert to the seed without adding its
|
| 11 |
+
bank here means it cannot be examined, by design.
|
| 12 |
+
|
| 13 |
+
## File format
|
| 14 |
+
|
| 15 |
+
```json
|
| 16 |
+
{
|
| 17 |
+
"certId": "atp-l4-math", // must equal the filename + seed cert id
|
| 18 |
+
"label": "ATP-L4.math@2026.1", // cosmetic, copied from the cert
|
| 19 |
+
"version": "2026.1", // bump when the bank changes materially
|
| 20 |
+
"notes": "free-text for SMEs",
|
| 21 |
+
"items": [ ... ] // the bank β see below
|
| 22 |
+
}
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
Each item:
|
| 26 |
+
|
| 27 |
+
| key | type | meaning |
|
| 28 |
+
|------------|--------|---------|
|
| 29 |
+
| `id` | str | stable unique id, `<cert-shorthand>-<sec>-NN` (e.g. `l4m-sym-01`). Never reuse or renumber a shipped id β evidence rows reference it forever. |
|
| 30 |
+
| `section` | str | MUST match one of the cert blueprint's `sections[].name` in `atp/seed.py` **verbatim** (copy-paste it). Unknown sections fail exam load. |
|
| 31 |
+
| `type` | str | one of `recall` \| `apply` \| `synthesize` \| `adversarial`, and it should be one of that section's `itemTypes` in the blueprint. |
|
| 32 |
+
| `prompt` | str | the question exactly as shown to the candidate model. Self-contained: include any passage/data inline. |
|
| 33 |
+
| `expected` | str | reference answer **and/or grading criteria** for the judge model. State what full credit requires and name known-wrong answers where useful. Never shown to the candidate. |
|
| 34 |
+
| `points` | int | weight of the item *within its section* (section scores are points-weighted; sections are then combined by the blueprint's section weights). |
|
| 35 |
+
|
| 36 |
+
## Validation at exam load (`atp/exams.py`)
|
| 37 |
+
|
| 38 |
+
* file exists and has a non-empty `items` list;
|
| 39 |
+
* every item carries all six keys;
|
| 40 |
+
* every item's `section` is a blueprint section of the cert;
|
| 41 |
+
* every blueprint section has at least one item (otherwise the
|
| 42 |
+
`passCriteria.perSection` bar would be unverifiable).
|
| 43 |
+
|
| 44 |
+
## How SMEs edit
|
| 45 |
+
|
| 46 |
+
1. Edit the cert's JSON in place (any editor; keep it valid JSON β trailing
|
| 47 |
+
commas are not).
|
| 48 |
+
2. **Add** items freely (new ids, correct `section`/`type`); **retire** items
|
| 49 |
+
by deleting them β but never recycle a deleted id.
|
| 50 |
+
3. Keep `expected` judge-ready: it is pasted into the judge prompt as the
|
| 51 |
+
reference. Write criteria ("full credit requiresβ¦"), not just an answer,
|
| 52 |
+
for synthesize/adversarial items.
|
| 53 |
+
4. Adversarial items should contain a trap (flawed premise, tempting wrong
|
| 54 |
+
method) and `expected` must say what falling into the trap looks like.
|
| 55 |
+
5. Bump `version`, then sanity-check with a dry run (no model calls, no cost):
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
BU_AUTH_DISABLED=1 python3 -m atp.exams atp-l4-math --dry-run --verify-chain
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
L5 (company-knowledge) banks additionally require SME sign-off on the cert
|
| 62 |
+
itself (`smeSignoff` in `atp/seed.py`) before awards are meaningful β the
|
| 63 |
+
bank alone is not enough (docs/ATP.md Β§1, L5 row).
|
atp/items/atp-l1-core.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"certId": "atp-l1-core",
|
| 3 |
+
"label": "ATP-L1.core@2026.1",
|
| 4 |
+
"version": "2026.1",
|
| 5 |
+
"notes": "Item bank for the L1 Core Skills cert (Exeter-modeled K-12 competencies). Sections MUST match the cert blueprint in atp/seed.py verbatim. See atp/items/README.md for the format and editing rules.",
|
| 6 |
+
"items": [
|
| 7 |
+
{
|
| 8 |
+
"id": "l1c-num-01",
|
| 9 |
+
"section": "Numeracy",
|
| 10 |
+
"type": "recall",
|
| 11 |
+
"prompt": "What is 7 x 8?",
|
| 12 |
+
"expected": "56. Exact answer required.",
|
| 13 |
+
"points": 1
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"id": "l1c-num-02",
|
| 17 |
+
"section": "Numeracy",
|
| 18 |
+
"type": "apply",
|
| 19 |
+
"prompt": "Compute 3/4 + 1/6, giving the answer as a fraction in lowest terms. Show the common-denominator step.",
|
| 20 |
+
"expected": "11/12. Common denominator 12: 3/4 = 9/12 and 1/6 = 2/12, so 9/12 + 2/12 = 11/12 (already in lowest terms). The common-denominator step must be shown.",
|
| 21 |
+
"points": 2
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"id": "l1c-num-03",
|
| 25 |
+
"section": "Numeracy",
|
| 26 |
+
"type": "apply",
|
| 27 |
+
"prompt": "A jacket costs $80 and is discounted by 15%. What is the sale price? Show the calculation.",
|
| 28 |
+
"expected": "$68. Discount is 0.15 x 80 = $12, so 80 - 12 = 68 (equivalently 0.85 x 80). The working must be shown, not just the final number.",
|
| 29 |
+
"points": 2
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"id": "l1c-red-01",
|
| 33 |
+
"section": "Reading comprehension",
|
| 34 |
+
"type": "recall",
|
| 35 |
+
"prompt": "Passage: 'Honeybees tell hive-mates where food is through a waggle dance. The angle of the dance relative to vertical encodes the direction of the food relative to the sun, while the duration of the waggle encodes the distance.' Question: according to the passage, what does the DURATION of the waggle encode?",
|
| 36 |
+
"expected": "The distance to the food. (Direction is encoded by the angle, not the duration β answers citing direction are wrong.)",
|
| 37 |
+
"points": 1
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"id": "l1c-red-02",
|
| 41 |
+
"section": "Reading comprehension",
|
| 42 |
+
"type": "apply",
|
| 43 |
+
"prompt": "Passage: 'Maria watered her ferns every morning, yet their leaves kept yellowing. When she finally moved the pots away from the bright south-facing window, the ferns recovered within two weeks β same watering schedule as before.' Question: what most likely caused the yellowing, and which detail in the passage supports that inference?",
|
| 44 |
+
"expected": "Too much direct sunlight (not under-watering): the ferns recovered when moved away from the bright window while the watering schedule stayed identical, so light exposure is the variable that changed. Both the inference and the supporting detail are required.",
|
| 45 |
+
"points": 2
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"id": "l1c-red-03",
|
| 49 |
+
"section": "Reading comprehension",
|
| 50 |
+
"type": "apply",
|
| 51 |
+
"prompt": "Passage: 'The town library began opening on Sundays last year. Weekend visits doubled, late returns fell by a third, and β to the surprise of the council β weekday visits rose too, as families who discovered the library on Sundays started coming back midweek.' Question: state the main idea of the passage in one sentence.",
|
| 52 |
+
"expected": "Opening the library on Sundays improved usage overall β not just on weekends, but on weekdays as well. A main-idea answer must cover the spillover effect; restating only one statistic is insufficient.",
|
| 53 |
+
"points": 2
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"id": "l1c-cmp-01",
|
| 57 |
+
"section": "Composition",
|
| 58 |
+
"type": "synthesize",
|
| 59 |
+
"prompt": "Write a paragraph of exactly three sentences arguing that schools should have vegetable gardens. Sentence 1 must state the claim, sentence 2 must give one concrete supporting reason, sentence 3 must conclude.",
|
| 60 |
+
"expected": "Grading criteria, not a fixed text: (1) exactly three sentences; (2) first sentence states the claim plainly; (3) second sentence gives one concrete, relevant reason (e.g. hands-on science learning, nutrition); (4) third sentence concludes without introducing a new argument. Grammatical, coherent prose.",
|
| 61 |
+
"points": 3
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"id": "l1c-cmp-02",
|
| 65 |
+
"section": "Composition",
|
| 66 |
+
"type": "synthesize",
|
| 67 |
+
"prompt": "Rewrite this run-on sentence as correct English, preserving all of its information: 'The experiment failed we forgot to label the samples so we had to start over.'",
|
| 68 |
+
"expected": "Any grammatical fix that keeps all three facts, e.g. 'The experiment failed because we forgot to label the samples, so we had to start over.' or 'The experiment failed. We had forgotten to label the samples, so we had to start over.' Must eliminate the comma-splice/run-on and preserve failure, cause, and restart.",
|
| 69 |
+
"points": 2
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"id": "l1c-sci-01",
|
| 73 |
+
"section": "Scientific & chronological reasoning",
|
| 74 |
+
"type": "apply",
|
| 75 |
+
"prompt": "A student wants to test whether a fertilizer makes bean plants grow taller. She plants 10 beans with fertilizer and 10 beans without, keeping light, water, soil, and pot size identical for all 20. Which group is the control, and why is it needed?",
|
| 76 |
+
"expected": "The 10 plants WITHOUT fertilizer are the control group. It is needed as the baseline: without it, any growth in the fertilized group could not be attributed to the fertilizer rather than to the shared conditions. Both identification and justification required.",
|
| 77 |
+
"points": 2
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"id": "l1c-sci-02",
|
| 81 |
+
"section": "Scientific & chronological reasoning",
|
| 82 |
+
"type": "adversarial",
|
| 83 |
+
"prompt": "In summer, both ice-cream sales and drowning deaths rise sharply, and the two track each other closely all year. A report concludes that ice cream causes drowning. Explain the error in this conclusion and identify the more plausible explanation.",
|
| 84 |
+
"expected": "Correlation is not causation: both variables are driven by a confounding third factor β hot weather brings more swimming (hence more drownings) AND more ice-cream sales. The answer must name the confounder (season/heat) and reject the causal claim; simply saying 'correlation isn't causation' without the confounder is partial credit at best.",
|
| 85 |
+
"points": 3
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"id": "l1c-sci-03",
|
| 89 |
+
"section": "Scientific & chronological reasoning",
|
| 90 |
+
"type": "apply",
|
| 91 |
+
"prompt": "Place these four events in chronological order, earliest first: the first telephone call (Bell), the moon landing, the printing press (Gutenberg), the first public telegraph line (Morse).",
|
| 92 |
+
"expected": "Printing press (c. 1440) -> telegraph (1844) -> telephone (1876) -> moon landing (1969). All four in the correct order required; exact years are not required but wreck nothing if given correctly.",
|
| 93 |
+
"points": 2
|
| 94 |
+
}
|
| 95 |
+
]
|
| 96 |
+
}
|
atp/items/atp-l4-math.json
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"certId": "atp-l4-math",
|
| 3 |
+
"label": "ATP-L4.math@2026.1",
|
| 4 |
+
"version": "2026.1",
|
| 5 |
+
"notes": "Item bank for the L4 Math cert (world-knowledge engines: CAS interop). Sections MUST match the cert blueprint in atp/seed.py verbatim. See atp/items/README.md for the format and editing rules.",
|
| 6 |
+
"items": [
|
| 7 |
+
{
|
| 8 |
+
"id": "l4m-sym-01",
|
| 9 |
+
"section": "Symbolic manipulation (CAS interop)",
|
| 10 |
+
"type": "apply",
|
| 11 |
+
"prompt": "Expand (x + 2)^3 completely. Show the intermediate binomial steps.",
|
| 12 |
+
"expected": "x^3 + 6x^2 + 12x + 8. Full credit requires the binomial expansion steps (coefficients 1,3,3,1 with powers of 2) or an equivalent two-stage multiplication.",
|
| 13 |
+
"points": 2
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"id": "l4m-sym-02",
|
| 17 |
+
"section": "Symbolic manipulation (CAS interop)",
|
| 18 |
+
"type": "apply",
|
| 19 |
+
"prompt": "Factor x^4 - 5x^2 + 4 completely over the integers.",
|
| 20 |
+
"expected": "(x - 1)(x + 1)(x - 2)(x + 2). Substitution u = x^2 gives (u - 1)(u - 4), then each difference of squares splits. All four linear factors required.",
|
| 21 |
+
"points": 2
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"id": "l4m-sym-03",
|
| 25 |
+
"section": "Symbolic manipulation (CAS interop)",
|
| 26 |
+
"type": "apply",
|
| 27 |
+
"prompt": "Simplify the rational expression (x^2 - 9) / (x^2 - x - 6) and state every excluded value of x.",
|
| 28 |
+
"expected": "(x + 3)/(x + 2), with x != 3 and x != -2 excluded (zeros of the ORIGINAL denominator). Numerator factors as (x-3)(x+3), denominator as (x-3)(x+2). Both exclusions must be stated.",
|
| 29 |
+
"points": 3
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"id": "l4m-sym-04",
|
| 33 |
+
"section": "Symbolic manipulation (CAS interop)",
|
| 34 |
+
"type": "apply",
|
| 35 |
+
"prompt": "Decompose (3x + 5) / ((x + 1)(x + 2)) into partial fractions.",
|
| 36 |
+
"expected": "2/(x + 1) + 1/(x + 2). Setting 3x + 5 = A(x + 2) + B(x + 1): x = -1 gives A = 2, x = -2 gives B = 1.",
|
| 37 |
+
"points": 3
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"id": "l4m-sym-05",
|
| 41 |
+
"section": "Symbolic manipulation (CAS interop)",
|
| 42 |
+
"type": "apply",
|
| 43 |
+
"prompt": "Differentiate f(x) = x^2 sin(x) with respect to x, naming the rule used.",
|
| 44 |
+
"expected": "f'(x) = 2x sin(x) + x^2 cos(x), by the product rule. Both terms required; naming the product rule required for full rigor credit.",
|
| 45 |
+
"points": 2
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"id": "l4m-prf-01",
|
| 49 |
+
"section": "Proof & synthesis",
|
| 50 |
+
"type": "synthesize",
|
| 51 |
+
"prompt": "Prove that sqrt(2) is irrational.",
|
| 52 |
+
"expected": "Proof by contradiction: assume sqrt(2) = p/q in lowest terms, so p^2 = 2q^2, hence p is even (p = 2k), then 4k^2 = 2q^2 gives q^2 = 2k^2 so q is also even β contradicting lowest terms. Must state the lowest-terms assumption and derive the parity contradiction; no skipped steps.",
|
| 53 |
+
"points": 4
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"id": "l4m-prf-02",
|
| 57 |
+
"section": "Proof & synthesis",
|
| 58 |
+
"type": "synthesize",
|
| 59 |
+
"prompt": "Prove by mathematical induction that 1 + 3 + 5 + ... + (2n - 1) = n^2 for all integers n >= 1.",
|
| 60 |
+
"expected": "Base case n = 1: LHS = 1 = 1^2. Inductive step: assume sum to k terms = k^2; adding the next odd number (2k + 1) gives k^2 + 2k + 1 = (k + 1)^2. Both the base case and an explicitly stated inductive hypothesis are required.",
|
| 61 |
+
"points": 4
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"id": "l4m-prf-03",
|
| 65 |
+
"section": "Proof & synthesis",
|
| 66 |
+
"type": "adversarial",
|
| 67 |
+
"prompt": "Claim: if f is differentiable and f'(c) = 0, then f has a local maximum or local minimum at c. Prove the claim or refute it.",
|
| 68 |
+
"expected": "The claim is FALSE. Counterexample: f(x) = x^3 at c = 0 has f'(0) = 0 but is strictly increasing, so x = 0 is neither a local max nor a local min (it is an inflection point). A single valid counterexample suffices; a 'proof' of the claim scores 0 on correctness.",
|
| 69 |
+
"points": 4
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"id": "l4m-prf-04",
|
| 73 |
+
"section": "Proof & synthesis",
|
| 74 |
+
"type": "adversarial",
|
| 75 |
+
"prompt": "The following argument concludes 2 = 1. Identify the exact flawed step and explain why it is invalid. Let a = b. Then a^2 = ab; a^2 - b^2 = ab - b^2; (a - b)(a + b) = b(a - b); a + b = b; 2b = b; therefore 2 = 1.",
|
| 76 |
+
"expected": "The step dividing both sides by (a - b) is invalid: since a = b, a - b = 0, and division by zero is undefined. Every other step is legal. The answer must locate the cancellation of (a - b) specifically, not just say 'division by zero somewhere'.",
|
| 77 |
+
"points": 3
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"id": "l4m-prf-05",
|
| 81 |
+
"section": "Proof & synthesis",
|
| 82 |
+
"type": "synthesize",
|
| 83 |
+
"prompt": "Prove that there are infinitely many prime numbers.",
|
| 84 |
+
"expected": "Euclid's argument: suppose p1..pk were all the primes; let N = p1*p2*...*pk + 1. N leaves remainder 1 on division by every pi, so any prime factor of N (which exists since N > 1) is a prime not in the list β contradiction. Must note N need not itself be prime, only that its prime factor is new.",
|
| 85 |
+
"points": 4
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"id": "l4m-eng-01",
|
| 89 |
+
"section": "Engine tool-use (SymPy/CAS)",
|
| 90 |
+
"type": "apply",
|
| 91 |
+
"prompt": "Write the exact SymPy code to solve x^2 - 5x + 6 = 0 for x, and state the result the engine returns.",
|
| 92 |
+
"expected": "import sympy; x = sympy.symbols('x'); sympy.solve(x**2 - 5*x + 6, x) β returns [2, 3]. Code must be runnable (symbol declared, correct API) and the reported roots must be exactly 2 and 3.",
|
| 93 |
+
"points": 3
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"id": "l4m-eng-02",
|
| 97 |
+
"section": "Engine tool-use (SymPy/CAS)",
|
| 98 |
+
"type": "apply",
|
| 99 |
+
"prompt": "Using SymPy, compute the indefinite integral of x*exp(x) with respect to x. Give the code and the engine's result.",
|
| 100 |
+
"expected": "sympy.integrate(x*sympy.exp(x), x) returns (x - 1)*exp(x). Answer must report the engine output faithfully β writing x*exp(x) - exp(x) is equivalent and acceptable; omitting the code or misreading the result is not.",
|
| 101 |
+
"points": 3
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"id": "l4m-eng-03",
|
| 105 |
+
"section": "Engine tool-use (SymPy/CAS)",
|
| 106 |
+
"type": "apply",
|
| 107 |
+
"prompt": "Using SymPy, evaluate the limit of sin(x)/x as x approaches 0. Give the code and the result.",
|
| 108 |
+
"expected": "sympy.limit(sympy.sin(x)/x, x, 0) returns 1. Full credit requires the limit() call (not a numeric approximation) and the exact value 1.",
|
| 109 |
+
"points": 2
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"id": "l4m-eng-04",
|
| 113 |
+
"section": "Engine tool-use (SymPy/CAS)",
|
| 114 |
+
"type": "apply",
|
| 115 |
+
"prompt": "Using SymPy's Matrix class, invert the matrix [[2, 1], [1, 1]]. Give the code and the exact inverse.",
|
| 116 |
+
"expected": "sympy.Matrix([[2, 1], [1, 1]]).inv() returns Matrix([[1, -1], [-1, 2]]) (determinant is 1). The exact integer inverse is required; a decimal/numeric inverse shows the engine was not used faithfully.",
|
| 117 |
+
"points": 3
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"id": "l4m-num-01",
|
| 121 |
+
"section": "Numerical stability",
|
| 122 |
+
"type": "apply",
|
| 123 |
+
"prompt": "In float64 arithmetic, computing 1 - cos(x) directly for very small x (say x = 1e-8) loses almost all significant digits. Explain why, and give a numerically stable equivalent expression.",
|
| 124 |
+
"expected": "Catastrophic cancellation: cos(x) is approximately 1 - x^2/2, so 1 - cos(x) subtracts two nearly equal numbers and the leading digits cancel, leaving only rounding noise. Stable rewrite: 2*sin(x/2)^2 (exactly equal analytically, no subtraction of near-equal quantities).",
|
| 125 |
+
"points": 3
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"id": "l4m-num-02",
|
| 129 |
+
"section": "Numerical stability",
|
| 130 |
+
"type": "adversarial",
|
| 131 |
+
"prompt": "Solving x^2 - 1e8*x + 1 = 0 with the textbook quadratic formula in float64 returns 0.0 for the small root. Diagnose the failure and give a stable procedure that recovers both roots accurately.",
|
| 132 |
+
"expected": "For the small root the formula computes (1e8 - sqrt(1e16 - 4))/2: sqrt(1e16 - 4) rounds to 1e8 in float64, so the subtraction cancels catastrophically to 0. Stable: compute the large root x1 = (-b - sign(b)*sqrt(b^2 - 4ac)) / (2a) (no cancellation), then the small root via Vieta: x2 = c/(a*x1) = 1e-8. Both the diagnosis (cancellation in the subtraction) and the Vieta rescue are required.",
|
| 133 |
+
"points": 4
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"id": "l4m-num-03",
|
| 137 |
+
"section": "Numerical stability",
|
| 138 |
+
"type": "adversarial",
|
| 139 |
+
"prompt": "True or false: IEEE-754 float64 addition is associative. Justify with a concrete counterexample evaluated in float64.",
|
| 140 |
+
"expected": "False. Counterexample: (1e16 + 1.0) - 1e16 evaluates to 0.0 because 1e16 + 1.0 rounds back to 1e16 (the spacing between adjacent float64 values at 1e16 is 2), while 1e16 - 1e16 + 1.0 evaluates to 1.0. Any counterexample with correct float64 rounding behaviour is acceptable.",
|
| 141 |
+
"points": 3
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"id": "l4m-num-04",
|
| 145 |
+
"section": "Numerical stability",
|
| 146 |
+
"type": "apply",
|
| 147 |
+
"prompt": "Naive Gaussian elimination WITHOUT pivoting fails badly on the system [[1e-20, 1], [1, 1]] * [x, y] = [1, 2] in float64. Explain the mechanism and name the standard fix.",
|
| 148 |
+
"expected": "Using the tiny pivot 1e-20 produces the multiplier 1e20; the row update computes 1 - 1e20 which rounds to -1e20, wiping out the original coefficients (the true data is absorbed into rounding error), yielding x = 0 instead of x approximately 1. Fix: partial pivoting β swap rows so the largest-magnitude entry in the column is the pivot, keeping multipliers bounded by 1.",
|
| 149 |
+
"points": 3
|
| 150 |
+
}
|
| 151 |
+
]
|
| 152 |
+
}
|
atp/knowledge.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Company-knowledge ingestion + L5 exam drafting β Phase 6 (docs/HARDENING.md).
|
| 3 |
+
|
| 4 |
+
Pipeline (first-customer UX contract):
|
| 5 |
+
|
| 6 |
+
upload (md/txt/pdf) ββ> vault_docs row [T2: content encrypted]
|
| 7 |
+
β
|
| 8 |
+
βββ> generate_l5_draft() [deterministic heuristic
|
| 9 |
+
blueprint + >=8 items β NO LLM dependency]
|
| 10 |
+
β
|
| 11 |
+
SME sign-off βββββ΄ββ> approve_draft() ['sme_signoff' evidence
|
| 12 |
+
β on the signed chain]
|
| 13 |
+
βββ> load_org_item_bank() [in-memory bank consumed
|
| 14 |
+
by atp/exams.py via its Phase 6 loader
|
| 15 |
+
ORG_CERT_PREFIX path] -> run_exam -> award
|
| 16 |
+
|
| 17 |
+
Tenancy & data class (docs/TENANCY.md β T2 "crown jewels"):
|
| 18 |
+
|
| 19 |
+
* Every row is org-scoped; all SQL here goes through atp/tenant_db.py
|
| 20 |
+
scoped helpers and carries a `:org` bind by construction (`_scoped()`
|
| 21 |
+
below asserts it). vault_docs / l5_drafts are not (yet) listed in
|
| 22 |
+
tenant_db.TENANT_TABLES β that registry lives in a file owned by the
|
| 23 |
+
tenancy workstream β so the DAL's parse guard does not fire for them;
|
| 24 |
+
the local assert plus Postgres FORCE RLS (migration 007) close the gap.
|
| 25 |
+
* Document content, summaries, and draft ITEMS are encrypted at rest via
|
| 26 |
+
atp/crypto.py encrypt_for_org (per-org HKDF subkey, 'enc1:' envelope).
|
| 27 |
+
The draft BLUEPRINT (section names/weights only) stays plaintext so
|
| 28 |
+
listings render without a decrypt.
|
| 29 |
+
* Approved banks are materialized IN MEMORY only. They are NEVER written
|
| 30 |
+
to atp/items/ or any shared directory (leak surfaces #4 and #7: no
|
| 31 |
+
plaintext T2 on disk, nothing under public media prefixes).
|
| 32 |
+
|
| 33 |
+
Separation of duties: admins manage docs and drafts; only SMEs sign.
|
| 34 |
+
approve_draft / reject_draft record the reviewer decision as a signed
|
| 35 |
+
'sme_signoff' evidence row (atp/signing.py append_chain), making sign-off
|
| 36 |
+
tamper-evident even though l5_drafts itself is mutable by design.
|
| 37 |
+
|
| 38 |
+
Public:
|
| 39 |
+
ingest_doc(org_id, filename, mime, content_bytes, uploaded_by) -> doc dict
|
| 40 |
+
list_docs(org_id) -> [doc dict] (summaries only)
|
| 41 |
+
get_doc(org_id, doc_id) -> doc dict | None (full text)
|
| 42 |
+
generate_l5_draft(org_id, doc_ids, created_by, title='') -> draft dict
|
| 43 |
+
list_drafts(org_id, include_items=False) -> [draft dict]
|
| 44 |
+
get_draft(org_id, draft_id, include_items=False) -> draft dict | None
|
| 45 |
+
approve_draft(org_id, draft_id, reviewer, note='') -> sign-off dict
|
| 46 |
+
reject_draft(org_id, draft_id, reviewer, note='') -> sign-off dict
|
| 47 |
+
load_org_item_bank(org_id, cert_ref) -> {cert, items} | None
|
| 48 |
+
UnsupportedDocumentError / KnowledgeError / DraftStateError
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
from __future__ import annotations
|
| 52 |
+
|
| 53 |
+
import io
|
| 54 |
+
import json
|
| 55 |
+
import re
|
| 56 |
+
import threading
|
| 57 |
+
import time
|
| 58 |
+
import uuid
|
| 59 |
+
from pathlib import Path
|
| 60 |
+
|
| 61 |
+
from atp import crypto, db, signing, tenant_db
|
| 62 |
+
|
| 63 |
+
#: Cert refs for approved drafts: f"{EXAM_CERT_PREFIX}{draft_id}". Must equal
|
| 64 |
+
#: atp/exams.py ORG_CERT_PREFIX (defined there β the loader checks the prefix
|
| 65 |
+
#: BEFORE importing this module; asserted equal in load_org_item_bank).
|
| 66 |
+
EXAM_CERT_PREFIX = "org-l5-draft:"
|
| 67 |
+
|
| 68 |
+
#: Seed cert whose passCriteria/rubric an org draft inherits β the L5
|
| 69 |
+
#: company-knowledge archetype in atp/seed.py.
|
| 70 |
+
L5_TEMPLATE_CERT_ID = "atp-l5-company-ops"
|
| 71 |
+
|
| 72 |
+
SUMMARY_WORDS = 40 # heuristic summary length (first N words)
|
| 73 |
+
MIN_ITEMS = 8 # a draft bank must have at least this many items
|
| 74 |
+
MIN_SECTIONS = 2 # ... spread across at least this many sections
|
| 75 |
+
MAX_UNITS_PER_DOC = 8 # cap: at most this many (heading, passage) units/doc
|
| 76 |
+
MIN_UNIT_CHARS = 40 # passages shorter than this are too thin to examine
|
| 77 |
+
MAX_EXPECTED_CHARS = 1200 # cap pasted passage length in judge references
|
| 78 |
+
|
| 79 |
+
_MIGRATED = False
|
| 80 |
+
_MIGRATE_LOCK = threading.Lock()
|
| 81 |
+
|
| 82 |
+
# (org_id, draft_id) -> materialized bank dict. In-memory ONLY (T2: approved
|
| 83 |
+
# banks never land as plaintext on shared disk); repopulated lazily from the
|
| 84 |
+
# encrypted l5_drafts row after a restart. Entries are only ever created for
|
| 85 |
+
# status='approved' drafts, and approve/reject are terminal transitions, so
|
| 86 |
+
# no invalidation is needed.
|
| 87 |
+
_BANK_CACHE: dict[tuple[str, str], dict] = {}
|
| 88 |
+
_BANK_LOCK = threading.Lock()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class KnowledgeError(ValueError):
|
| 92 |
+
"""Invalid ingestion/drafting input (unknown doc ids, corpus too thin)."""
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class UnsupportedDocumentError(KnowledgeError):
|
| 96 |
+
"""Unsupported document type β the API maps this to HTTP 415."""
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class DraftStateError(RuntimeError):
|
| 100 |
+
"""A draft transition was attempted from the wrong status (HTTP 409)."""
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _now() -> str:
|
| 104 |
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _ensure_db() -> None:
|
| 108 |
+
"""Idempotently apply migrations so a fresh SQLite file just works
|
| 109 |
+
(same idiom as atp/exams.py)."""
|
| 110 |
+
global _MIGRATED
|
| 111 |
+
if _MIGRATED:
|
| 112 |
+
return
|
| 113 |
+
with _MIGRATE_LOCK:
|
| 114 |
+
if not _MIGRATED:
|
| 115 |
+
db.run_migrations()
|
| 116 |
+
_MIGRATED = True
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _scoped(sql: str) -> str:
|
| 120 |
+
"""Belt-and-braces layer-2 check for THIS module's tenant tables.
|
| 121 |
+
|
| 122 |
+
vault_docs / l5_drafts are not in tenant_db.TENANT_TABLES (that frozenset
|
| 123 |
+
lives in atp/tenant_db.py, owned by the tenancy workstream β follow-up:
|
| 124 |
+
add both there), so scoped_query/scoped_execute would not force the
|
| 125 |
+
':org' bind for them. Assert it locally instead; on Postgres the
|
| 126 |
+
migration-007 FORCE RLS policies are the deny-by-default backstop.
|
| 127 |
+
"""
|
| 128 |
+
if ":org" not in sql:
|
| 129 |
+
raise tenant_db.TenantScopeError(
|
| 130 |
+
f"knowledge-module statement lacks an ':org' bind: {sql!r}")
|
| 131 |
+
return sql
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ββ Text extraction (md/txt native; pdf via pypdf when importable) ββββββββββ
|
| 135 |
+
|
| 136 |
+
_EXT_KIND = {".md": "md", ".markdown": "md", ".txt": "txt", ".pdf": "pdf"}
|
| 137 |
+
_MIME_KIND = {
|
| 138 |
+
"text/markdown": "md",
|
| 139 |
+
"text/x-markdown": "md",
|
| 140 |
+
"text/plain": "txt",
|
| 141 |
+
"application/pdf": "pdf",
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def doc_kind(filename: str | None, mime: str | None) -> str | None:
|
| 146 |
+
"""'md' | 'txt' | 'pdf' from the filename extension (authoritative),
|
| 147 |
+
falling back to the declared MIME type; None when neither is allowed."""
|
| 148 |
+
ext = Path(filename or "").suffix.lower()
|
| 149 |
+
if ext in _EXT_KIND:
|
| 150 |
+
return _EXT_KIND[ext]
|
| 151 |
+
if ext: # an extension we do NOT allow β don't let MIME override it
|
| 152 |
+
return None
|
| 153 |
+
return _MIME_KIND.get((mime or "").split(";")[0].strip().lower())
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _extract_text(filename: str | None, mime: str | None,
|
| 157 |
+
content_bytes: bytes) -> str:
|
| 158 |
+
kind = doc_kind(filename, mime)
|
| 159 |
+
if kind is None:
|
| 160 |
+
raise UnsupportedDocumentError(
|
| 161 |
+
f"unsupported document type (filename={filename!r}, "
|
| 162 |
+
f"mime={mime!r}) β allowed: .md, .txt, .pdf")
|
| 163 |
+
if kind == "pdf":
|
| 164 |
+
try:
|
| 165 |
+
from pypdf import PdfReader # optional dep (requirements.txt)
|
| 166 |
+
except ImportError:
|
| 167 |
+
raise UnsupportedDocumentError(
|
| 168 |
+
"PDF ingestion requires the 'pypdf' package, which is not "
|
| 169 |
+
"installed on this deployment β upload .md or .txt instead"
|
| 170 |
+
) from None
|
| 171 |
+
try:
|
| 172 |
+
reader = PdfReader(io.BytesIO(content_bytes))
|
| 173 |
+
text = "\n\n".join((page.extract_text() or "")
|
| 174 |
+
for page in reader.pages)
|
| 175 |
+
except Exception as e: # noqa: BLE001 β pypdf raises many types
|
| 176 |
+
raise KnowledgeError(f"could not parse PDF: {e}") from None
|
| 177 |
+
else:
|
| 178 |
+
text = content_bytes.decode("utf-8", errors="replace")
|
| 179 |
+
text = text.strip()
|
| 180 |
+
if not text:
|
| 181 |
+
raise KnowledgeError("document contains no extractable text")
|
| 182 |
+
return text
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+(?P<h>.+?)\s*#*\s*$")
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _title_from(text: str, filename: str | None) -> str:
|
| 189 |
+
"""First markdown heading, else the cleaned filename stem."""
|
| 190 |
+
for line in text.splitlines()[:20]:
|
| 191 |
+
m = _HEADING_RE.match(line)
|
| 192 |
+
if m:
|
| 193 |
+
return m.group("h").strip()
|
| 194 |
+
stem = Path(filename or "document").stem
|
| 195 |
+
return re.sub(r"[_\-]+", " ", stem).strip() or "document"
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _summarize(text: str) -> str:
|
| 199 |
+
"""Deterministic heuristic summary: the first SUMMARY_WORDS words of the
|
| 200 |
+
body (heading markers stripped)."""
|
| 201 |
+
words = re.sub(r"^\s{0,3}#{1,6}\s+", "", text, flags=re.MULTILINE).split()
|
| 202 |
+
out = " ".join(words[:SUMMARY_WORDS])
|
| 203 |
+
return out + (" β¦" if len(words) > SUMMARY_WORDS else "")
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _split_units(text: str) -> list[tuple[str, str]]:
|
| 207 |
+
"""Split a document into (heading, passage) units, deterministically.
|
| 208 |
+
|
| 209 |
+
Blocks are separated by blank lines; a block that is a single markdown
|
| 210 |
+
heading line sets the current heading for the passages that follow.
|
| 211 |
+
Headingless documents fall back to positional labels ('passage N').
|
| 212 |
+
Passages shorter than MIN_UNIT_CHARS are skipped as unexaminable.
|
| 213 |
+
"""
|
| 214 |
+
units: list[tuple[str, str]] = []
|
| 215 |
+
heading: str | None = None
|
| 216 |
+
n_anon = 0
|
| 217 |
+
for block in re.split(r"\n\s*\n", text):
|
| 218 |
+
block = block.strip()
|
| 219 |
+
if not block:
|
| 220 |
+
continue
|
| 221 |
+
lines = block.splitlines()
|
| 222 |
+
m = _HEADING_RE.match(lines[0])
|
| 223 |
+
if m:
|
| 224 |
+
heading = m.group("h").strip()[:80]
|
| 225 |
+
block = "\n".join(lines[1:]).strip()
|
| 226 |
+
if not block:
|
| 227 |
+
continue
|
| 228 |
+
if len(block) < MIN_UNIT_CHARS:
|
| 229 |
+
continue
|
| 230 |
+
if heading is None:
|
| 231 |
+
n_anon += 1
|
| 232 |
+
label = f"passage {n_anon}"
|
| 233 |
+
else:
|
| 234 |
+
label = heading
|
| 235 |
+
units.append((label, block))
|
| 236 |
+
return units
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
# ββ Documents ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 240 |
+
|
| 241 |
+
_DOC_LIST_COLS = "id, title, filename, mime, uploaded_by, created_at, status"
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def _doc_public(row: dict, org_id: str, *, content: bool = False) -> dict:
|
| 245 |
+
out = {
|
| 246 |
+
"id": row["id"],
|
| 247 |
+
"title": row["title"],
|
| 248 |
+
"filename": row.get("filename"),
|
| 249 |
+
"mime": row.get("mime"),
|
| 250 |
+
"status": row["status"],
|
| 251 |
+
"uploadedBy": row.get("uploaded_by"),
|
| 252 |
+
"createdAt": row.get("created_at"),
|
| 253 |
+
"summary": crypto.decrypt_text_for_org(org_id, row.get("summary_enc")),
|
| 254 |
+
}
|
| 255 |
+
if content:
|
| 256 |
+
out["content"] = crypto.decrypt_text_for_org(org_id, row.get("content_enc"))
|
| 257 |
+
return out
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def ingest_doc(org_id: str, filename: str | None, mime: str | None,
|
| 261 |
+
content_bytes: bytes, uploaded_by: str) -> dict:
|
| 262 |
+
"""Extract text, encrypt it under the org's subkey, store a vault_docs
|
| 263 |
+
row, and return the public doc dict (summary decrypted for the caller).
|
| 264 |
+
|
| 265 |
+
Raises UnsupportedDocumentError (bad type / pypdf missing β HTTP 415),
|
| 266 |
+
KnowledgeError (unparseable/empty β 400), RuntimeError from atp/crypto.py
|
| 267 |
+
when DATA_ENCRYPTION_KEY is unset (T2 is never written as plaintext).
|
| 268 |
+
"""
|
| 269 |
+
_ensure_db()
|
| 270 |
+
text = _extract_text(filename, mime, content_bytes)
|
| 271 |
+
doc_id = "doc_" + uuid.uuid4().hex[:12]
|
| 272 |
+
row = {
|
| 273 |
+
"id": doc_id,
|
| 274 |
+
"title": _title_from(text, filename),
|
| 275 |
+
"filename": filename,
|
| 276 |
+
"mime": mime,
|
| 277 |
+
"content_enc": crypto.encrypt_for_org(org_id, text),
|
| 278 |
+
"summary_enc": crypto.encrypt_for_org(org_id, _summarize(text)),
|
| 279 |
+
"uploaded_by": uploaded_by,
|
| 280 |
+
"created_at": _now(),
|
| 281 |
+
"status": "uploaded",
|
| 282 |
+
}
|
| 283 |
+
tenant_db.scoped_execute(org_id, _scoped(
|
| 284 |
+
"INSERT INTO vault_docs (id, org_id, title, filename, mime, "
|
| 285 |
+
" content_enc, summary_enc, uploaded_by, created_at, status) "
|
| 286 |
+
"VALUES (:id, :org, :title, :filename, :mime, :content_enc, "
|
| 287 |
+
" :summary_enc, :uploaded_by, :created_at, :status)"),
|
| 288 |
+
{"org": org_id, **row})
|
| 289 |
+
return _doc_public({**row}, org_id)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def list_docs(org_id: str) -> list[dict]:
|
| 293 |
+
"""Org's vault docs, latest first β summaries decrypted, NO content."""
|
| 294 |
+
_ensure_db()
|
| 295 |
+
rows = tenant_db.scoped_query(org_id, _scoped(
|
| 296 |
+
f"SELECT {_DOC_LIST_COLS}, summary_enc FROM vault_docs "
|
| 297 |
+
f"WHERE org_id = :org ORDER BY created_at DESC, id DESC"),
|
| 298 |
+
{"org": org_id})
|
| 299 |
+
return [_doc_public(r, org_id) for r in rows]
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _doc_row(org_id: str, doc_id: str) -> dict | None:
|
| 303 |
+
rows = tenant_db.scoped_query(org_id, _scoped(
|
| 304 |
+
"SELECT * FROM vault_docs WHERE org_id = :org AND id = :id LIMIT 1"),
|
| 305 |
+
{"org": org_id, "id": doc_id})
|
| 306 |
+
return rows[0] if rows else None
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def get_doc(org_id: str, doc_id: str) -> dict | None:
|
| 310 |
+
"""One doc with the FULL decrypted text, or None (cross-tenant ids are
|
| 311 |
+
indistinguishable from unknown ones β TENANCY.md leak surface #6)."""
|
| 312 |
+
_ensure_db()
|
| 313 |
+
row = _doc_row(org_id, doc_id)
|
| 314 |
+
return None if row is None else _doc_public(row, org_id, content=True)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
# ββ L5 draft generation (deterministic heuristic β no LLM dependency) βββββββ
|
| 318 |
+
|
| 319 |
+
def _items_for_doc(doc_idx: int, title: str, units: list[tuple[str, str]]
|
| 320 |
+
) -> list[dict]:
|
| 321 |
+
"""Recall + apply item pair per (heading, passage) unit. Pure function of
|
| 322 |
+
its inputs β same docs in the same order always produce the same items."""
|
| 323 |
+
items: list[dict] = []
|
| 324 |
+
for u_idx, (heading, passage) in enumerate(units[:MAX_UNITS_PER_DOC], 1):
|
| 325 |
+
passage = passage[:MAX_EXPECTED_CHARS]
|
| 326 |
+
base = f"l5d-{doc_idx:02d}-{u_idx:02d}"
|
| 327 |
+
items.append({
|
| 328 |
+
"id": f"{base}-r",
|
| 329 |
+
"section": title,
|
| 330 |
+
"type": "recall",
|
| 331 |
+
"prompt": (f'According to "{title}", what is stated about '
|
| 332 |
+
f'"{heading}"?'),
|
| 333 |
+
"expected": (f'Full credit requires accurately restating the '
|
| 334 |
+
f'substance of this passage from "{title}" '
|
| 335 |
+
f'(section: {heading}); contradicting it or '
|
| 336 |
+
f'inventing unstated policy is wrong:\n{passage}'),
|
| 337 |
+
"points": 2,
|
| 338 |
+
})
|
| 339 |
+
items.append({
|
| 340 |
+
"id": f"{base}-a",
|
| 341 |
+
"section": title,
|
| 342 |
+
"type": "apply",
|
| 343 |
+
"prompt": (f'A teammate must follow the guidance in "{title}" '
|
| 344 |
+
f'regarding "{heading}". Explain, step by step, how '
|
| 345 |
+
f'to apply what the document states, and note when '
|
| 346 |
+
f'to escalate instead of guessing.'),
|
| 347 |
+
"expected": (f'Full credit requires steps grounded ONLY in this '
|
| 348 |
+
f'passage from "{title}" (section: {heading}) β no '
|
| 349 |
+
f'invented policy; ambiguous cases must escalate:\n'
|
| 350 |
+
f'{passage}'),
|
| 351 |
+
"points": 3,
|
| 352 |
+
})
|
| 353 |
+
return items
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def _draft_public(row: dict, org_id: str, *, include_items: bool) -> dict:
|
| 357 |
+
blueprint = json.loads(row["blueprint_json"])
|
| 358 |
+
out = {
|
| 359 |
+
"id": row["id"],
|
| 360 |
+
"certLabel": row["cert_label"],
|
| 361 |
+
"blueprint": blueprint,
|
| 362 |
+
"docIds": json.loads(row["doc_ids_json"]),
|
| 363 |
+
"status": row["status"],
|
| 364 |
+
"createdBy": row.get("created_by"),
|
| 365 |
+
"createdAt": row.get("created_at"),
|
| 366 |
+
"reviewedBy": row.get("reviewed_by"),
|
| 367 |
+
"reviewedAt": row.get("reviewed_at"),
|
| 368 |
+
"reviewNote": row.get("review_note"),
|
| 369 |
+
"itemCount": sum(int(s.get("items", 0))
|
| 370 |
+
for s in blueprint.get("sections", [])),
|
| 371 |
+
"examCertRef": EXAM_CERT_PREFIX + row["id"],
|
| 372 |
+
}
|
| 373 |
+
if include_items:
|
| 374 |
+
out["items"] = json.loads(
|
| 375 |
+
crypto.decrypt_text_for_org(org_id, row["items_json_enc"]))
|
| 376 |
+
return out
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def generate_l5_draft(org_id: str, doc_ids: list[str], created_by: str,
|
| 380 |
+
title: str = "") -> dict:
|
| 381 |
+
"""Deterministic heuristic L5 exam draft from the org's vault docs.
|
| 382 |
+
|
| 383 |
+
Blueprint sections = the docs' titles (one section per doc, weights
|
| 384 |
+
proportional to item count, normalized to sum 1). Items = a recall +
|
| 385 |
+
apply pair per (heading, passage) unit of each doc. Requires >= 2 docs
|
| 386 |
+
with distinct titles and >= 8 items total, else KnowledgeError.
|
| 387 |
+
|
| 388 |
+
Blueprint is stored plaintext; ITEMS (T2 β verbatim company passages)
|
| 389 |
+
are encrypted under the org subkey. Returns the draft dict with items
|
| 390 |
+
DECRYPTED for immediate SME review (the route role-gates that).
|
| 391 |
+
"""
|
| 392 |
+
_ensure_db()
|
| 393 |
+
if not doc_ids:
|
| 394 |
+
raise KnowledgeError("docIds must be a non-empty list")
|
| 395 |
+
if len(set(doc_ids)) != len(doc_ids):
|
| 396 |
+
raise KnowledgeError("docIds contains duplicates")
|
| 397 |
+
|
| 398 |
+
docs: list[dict] = []
|
| 399 |
+
missing: list[str] = []
|
| 400 |
+
for doc_id in doc_ids:
|
| 401 |
+
row = _doc_row(org_id, doc_id)
|
| 402 |
+
if row is None:
|
| 403 |
+
missing.append(doc_id)
|
| 404 |
+
else:
|
| 405 |
+
docs.append(row)
|
| 406 |
+
if missing:
|
| 407 |
+
# Cross-tenant ids fail EXACTLY like unknown ones (TENANCY.md #6);
|
| 408 |
+
# only ids the caller itself supplied are echoed back.
|
| 409 |
+
raise KnowledgeError(f"unknown doc id(s): {sorted(missing)}")
|
| 410 |
+
|
| 411 |
+
items: list[dict] = []
|
| 412 |
+
sections: list[dict] = []
|
| 413 |
+
seen_titles: set[str] = set()
|
| 414 |
+
for d_idx, row in enumerate(docs, 1):
|
| 415 |
+
title_d = row["title"]
|
| 416 |
+
if title_d in seen_titles:
|
| 417 |
+
raise KnowledgeError(
|
| 418 |
+
f"two selected docs share the title {title_d!r} β blueprint "
|
| 419 |
+
f"sections are doc titles and must be distinct")
|
| 420 |
+
seen_titles.add(title_d)
|
| 421 |
+
text = crypto.decrypt_text_for_org(org_id, row["content_enc"])
|
| 422 |
+
units = _split_units(text)
|
| 423 |
+
doc_items = _items_for_doc(d_idx, title_d, units)
|
| 424 |
+
if not doc_items:
|
| 425 |
+
raise KnowledgeError(
|
| 426 |
+
f"doc {row['id']} ({title_d!r}) yields no examinable "
|
| 427 |
+
f"passages (needs paragraphs of >= {MIN_UNIT_CHARS} chars)")
|
| 428 |
+
items.extend(doc_items)
|
| 429 |
+
sections.append({"name": title_d, "items": len(doc_items),
|
| 430 |
+
"itemTypes": ["recall", "apply"]})
|
| 431 |
+
|
| 432 |
+
if len(sections) < MIN_SECTIONS:
|
| 433 |
+
raise KnowledgeError(
|
| 434 |
+
f"an L5 draft needs >= {MIN_SECTIONS} blueprint sections "
|
| 435 |
+
f"(= distinct docs); got {len(sections)}")
|
| 436 |
+
if len(items) < MIN_ITEMS:
|
| 437 |
+
raise KnowledgeError(
|
| 438 |
+
f"an L5 draft needs >= {MIN_ITEMS} items; these docs only "
|
| 439 |
+
f"yield {len(items)} β upload richer documents")
|
| 440 |
+
|
| 441 |
+
# Weights proportional to item count, normalized to sum exactly 1
|
| 442 |
+
# (the last section absorbs the rounding remainder).
|
| 443 |
+
total = len(items)
|
| 444 |
+
for s in sections[:-1]:
|
| 445 |
+
s["weight"] = round(s["items"] / total, 6)
|
| 446 |
+
sections[-1]["weight"] = round(1.0 - sum(s["weight"]
|
| 447 |
+
for s in sections[:-1]), 6)
|
| 448 |
+
|
| 449 |
+
draft_id = "l5d_" + uuid.uuid4().hex[:12]
|
| 450 |
+
blueprint = {"sections": sections, "totalItems": total}
|
| 451 |
+
row = {
|
| 452 |
+
"id": draft_id,
|
| 453 |
+
"cert_label": (title or "").strip() or "ATP-L5 Company Knowledge (draft)",
|
| 454 |
+
"blueprint_json": json.dumps(blueprint, sort_keys=True),
|
| 455 |
+
"items_json_enc": crypto.encrypt_for_org(org_id, json.dumps(items)),
|
| 456 |
+
"doc_ids_json": json.dumps(list(doc_ids)),
|
| 457 |
+
"status": "draft",
|
| 458 |
+
"created_by": created_by,
|
| 459 |
+
"created_at": _now(),
|
| 460 |
+
}
|
| 461 |
+
tenant_db.scoped_execute(org_id, _scoped(
|
| 462 |
+
"INSERT INTO l5_drafts (id, org_id, cert_label, blueprint_json, "
|
| 463 |
+
" items_json_enc, doc_ids_json, status, created_by, created_at) "
|
| 464 |
+
"VALUES (:id, :org, :cert_label, :blueprint_json, :items_json_enc, "
|
| 465 |
+
" :doc_ids_json, :status, :created_by, :created_at)"),
|
| 466 |
+
{"org": org_id, **row})
|
| 467 |
+
|
| 468 |
+
# uploaded -> drafted (docs already 'drafted'/'signed' keep their status).
|
| 469 |
+
for doc_id in doc_ids:
|
| 470 |
+
tenant_db.scoped_execute(org_id, _scoped(
|
| 471 |
+
"UPDATE vault_docs SET status = 'drafted' "
|
| 472 |
+
"WHERE org_id = :org AND id = :id AND status = 'uploaded'"),
|
| 473 |
+
{"org": org_id, "id": doc_id})
|
| 474 |
+
|
| 475 |
+
return _draft_public(row, org_id, include_items=True)
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def list_drafts(org_id: str, include_items: bool = False) -> list[dict]:
|
| 479 |
+
"""Org's L5 drafts, latest first. Items (T2) only when include_items β
|
| 480 |
+
the route restricts that to admin|sme."""
|
| 481 |
+
_ensure_db()
|
| 482 |
+
rows = tenant_db.scoped_query(org_id, _scoped(
|
| 483 |
+
"SELECT * FROM l5_drafts WHERE org_id = :org "
|
| 484 |
+
"ORDER BY created_at DESC, id DESC"),
|
| 485 |
+
{"org": org_id})
|
| 486 |
+
return [_draft_public(r, org_id, include_items=include_items) for r in rows]
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def _draft_row(org_id: str, draft_id: str) -> dict | None:
|
| 490 |
+
rows = tenant_db.scoped_query(org_id, _scoped(
|
| 491 |
+
"SELECT * FROM l5_drafts WHERE org_id = :org AND id = :id LIMIT 1"),
|
| 492 |
+
{"org": org_id, "id": draft_id})
|
| 493 |
+
return rows[0] if rows else None
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
def get_draft(org_id: str, draft_id: str,
|
| 497 |
+
include_items: bool = False) -> dict | None:
|
| 498 |
+
_ensure_db()
|
| 499 |
+
row = _draft_row(org_id, draft_id)
|
| 500 |
+
return None if row is None else _draft_public(
|
| 501 |
+
row, org_id, include_items=include_items)
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
# ββ SME sign-off (separation of duties; evidence on the signed chain) βββββββ
|
| 505 |
+
|
| 506 |
+
def _review(org_id: str, draft_id: str, reviewer: str, note: str,
|
| 507 |
+
to_status: str, outcome: str) -> dict | None:
|
| 508 |
+
"""Shared approve/reject transition: guarded UPDATE from 'draft', then a
|
| 509 |
+
signed 'sme_signoff' evidence row. Returns None for unknown/cross-org
|
| 510 |
+
ids; DraftStateError when the draft is not in status 'draft'."""
|
| 511 |
+
_ensure_db()
|
| 512 |
+
row = _draft_row(org_id, draft_id)
|
| 513 |
+
if row is None:
|
| 514 |
+
return None
|
| 515 |
+
if row["status"] != "draft":
|
| 516 |
+
raise DraftStateError(
|
| 517 |
+
f"draft {draft_id} is '{row['status']}' β only status 'draft' "
|
| 518 |
+
f"can be {outcome}")
|
| 519 |
+
ts = _now()
|
| 520 |
+
res = tenant_db.scoped_execute(org_id, _scoped(
|
| 521 |
+
"UPDATE l5_drafts SET status = :to, reviewed_by = :rev, "
|
| 522 |
+
" reviewed_at = :ts, review_note = :note "
|
| 523 |
+
"WHERE org_id = :org AND id = :id AND status = 'draft'"),
|
| 524 |
+
{"org": org_id, "id": draft_id, "to": to_status,
|
| 525 |
+
"rev": reviewer, "ts": ts, "note": note})
|
| 526 |
+
if res.rowcount == 0: # lost a race β same answer as the pre-check
|
| 527 |
+
raise DraftStateError(
|
| 528 |
+
f"draft {draft_id} was reviewed concurrently β reload it")
|
| 529 |
+
|
| 530 |
+
cert_ref = EXAM_CERT_PREFIX + draft_id
|
| 531 |
+
doc_ids = json.loads(row["doc_ids_json"])
|
| 532 |
+
# The sign-off itself is the tamper-evident record (l5_drafts is mutable
|
| 533 |
+
# by design β migration 007 header): one HMAC-signed row on the org's
|
| 534 |
+
# evidence chain. NOTE: payload carries reviewer/note/doc ids β metadata,
|
| 535 |
+
# never document content or items (atp_evidence is not encrypted).
|
| 536 |
+
payload = {"draftId": draft_id, "reviewer": reviewer, "note": note,
|
| 537 |
+
"docIds": doc_ids, "certLabel": row["cert_label"],
|
| 538 |
+
"outcome": outcome}
|
| 539 |
+
(evidence_id, _sig), = signing.append_chain(org_id, [{
|
| 540 |
+
"ts": ts, "agent_id": reviewer, "cert_id": cert_ref,
|
| 541 |
+
"kind": "sme_signoff", "payload": payload,
|
| 542 |
+
}])
|
| 543 |
+
|
| 544 |
+
if to_status == "approved":
|
| 545 |
+
for doc_id in doc_ids: # drafted -> signed
|
| 546 |
+
tenant_db.scoped_execute(org_id, _scoped(
|
| 547 |
+
"UPDATE vault_docs SET status = 'signed' "
|
| 548 |
+
"WHERE org_id = :org AND id = :id"),
|
| 549 |
+
{"org": org_id, "id": doc_id})
|
| 550 |
+
# Materialize the bank in memory now (also happens lazily on demand).
|
| 551 |
+
load_org_item_bank(org_id, cert_ref)
|
| 552 |
+
|
| 553 |
+
return {
|
| 554 |
+
"draftId": draft_id,
|
| 555 |
+
"status": to_status,
|
| 556 |
+
"outcome": outcome,
|
| 557 |
+
"reviewedBy": reviewer,
|
| 558 |
+
"reviewedAt": ts,
|
| 559 |
+
"note": note,
|
| 560 |
+
"evidenceId": evidence_id,
|
| 561 |
+
"examCertRef": cert_ref,
|
| 562 |
+
"examRunnable": to_status == "approved",
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
def approve_draft(org_id: str, draft_id: str, reviewer: str,
|
| 567 |
+
note: str = "") -> dict | None:
|
| 568 |
+
"""SME approval: draft -> approved, 'sme_signoff' evidence appended,
|
| 569 |
+
covered docs flip to 'signed', and the exam bank becomes loadable via
|
| 570 |
+
load_org_item_bank (cert ref in the returned 'examCertRef')."""
|
| 571 |
+
return _review(org_id, draft_id, reviewer, note, "approved", "approved")
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def reject_draft(org_id: str, draft_id: str, reviewer: str,
|
| 575 |
+
note: str = "") -> dict | None:
|
| 576 |
+
"""SME rejection: draft -> rejected, with the SAME evidence trail
|
| 577 |
+
(kind 'sme_signoff', payload.outcome = 'rejected')."""
|
| 578 |
+
return _review(org_id, draft_id, reviewer, note, "rejected", "rejected")
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
# ββ Exam extension point (consumed by atp/exams.py's Phase 6 loader path) βββ
|
| 582 |
+
|
| 583 |
+
def _template_cert() -> dict:
|
| 584 |
+
"""passCriteria/rubric archetype for org L5 drafts, deep-copied from the
|
| 585 |
+
seed L5 cert so the T0 seed is never mutated."""
|
| 586 |
+
from atp import store
|
| 587 |
+
for cert in store.get_data().get("CERTS", []):
|
| 588 |
+
if cert.get("id") == L5_TEMPLATE_CERT_ID:
|
| 589 |
+
return json.loads(json.dumps(cert)) # cheap deep copy
|
| 590 |
+
# Defensive fallback β the seed always ships this cert.
|
| 591 |
+
return {"passCriteria": {"overall": 0.9, "perSection": 0.85, "runs": 2,
|
| 592 |
+
"temperature": 0, "seed": 5051,
|
| 593 |
+
"judgeSeparation": True},
|
| 594 |
+
"rubric": []}
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
def load_org_item_bank(org_id: str, cert_ref: str) -> dict | None:
|
| 598 |
+
"""Materialize an org's APPROVED draft bank in memory for atp/exams.py.
|
| 599 |
+
|
| 600 |
+
cert_ref: f"{EXAM_CERT_PREFIX}<draft_id>". Returns
|
| 601 |
+
{"cert": <synthetic cert dict>, "items": [item, ...]}
|
| 602 |
+
or None when the ref does not name an approved draft of THIS org (a
|
| 603 |
+
cross-tenant ref is indistinguishable from an unknown one β #6).
|
| 604 |
+
|
| 605 |
+
The synthetic cert carries the draft's own blueprint (sections = doc
|
| 606 |
+
titles) plus the passCriteria/rubric of the seed L5 template cert, so
|
| 607 |
+
run_exam()'s aggregation and verdict logic work unchanged. Decrypted
|
| 608 |
+
items live only in this process's memory (_BANK_CACHE) β never on disk.
|
| 609 |
+
"""
|
| 610 |
+
if not cert_ref.startswith(EXAM_CERT_PREFIX):
|
| 611 |
+
return None
|
| 612 |
+
draft_id = cert_ref[len(EXAM_CERT_PREFIX):]
|
| 613 |
+
key = (org_id, draft_id)
|
| 614 |
+
with _BANK_LOCK:
|
| 615 |
+
cached = _BANK_CACHE.get(key)
|
| 616 |
+
if cached is not None:
|
| 617 |
+
return cached
|
| 618 |
+
|
| 619 |
+
_ensure_db()
|
| 620 |
+
row = _draft_row(org_id, draft_id)
|
| 621 |
+
if row is None or row["status"] != "approved":
|
| 622 |
+
return None
|
| 623 |
+
items = json.loads(crypto.decrypt_text_for_org(org_id, row["items_json_enc"]))
|
| 624 |
+
template = _template_cert()
|
| 625 |
+
cert = {
|
| 626 |
+
"id": cert_ref,
|
| 627 |
+
"label": row["cert_label"],
|
| 628 |
+
"sentence": (f"Org-scoped L5 company-knowledge draft "
|
| 629 |
+
f"({row['cert_label']}), SME-approved."),
|
| 630 |
+
"layer": 5,
|
| 631 |
+
"domain": "Company Knowledge",
|
| 632 |
+
"blueprint": json.loads(row["blueprint_json"]),
|
| 633 |
+
"rubric": template.get("rubric", []),
|
| 634 |
+
"passCriteria": template.get("passCriteria", {}),
|
| 635 |
+
"smeSignoff": [{"name": row.get("reviewed_by"),
|
| 636 |
+
"ts": row.get("reviewed_at")}],
|
| 637 |
+
}
|
| 638 |
+
bank = {"cert": cert, "items": items, "draftId": draft_id}
|
| 639 |
+
with _BANK_LOCK:
|
| 640 |
+
_BANK_CACHE[key] = bank
|
| 641 |
+
return bank
|
atp/licensing.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent licensing β issue / verify / revoke / meter (Phase 4, docs/HARDENING.md).
|
| 3 |
+
|
| 4 |
+
Licenses are T1 org-scoped data (docs/TENANCY.md): every read/write goes
|
| 5 |
+
through atp/tenant_db.py (org from the verified request principal, NEVER from
|
| 6 |
+
a payload), and on Postgres migration 006 adds FORCE row-level security with
|
| 7 |
+
the 005 policy pattern. Stripe is OPTIONAL by design: without STRIPE_API_KEY
|
| 8 |
+
the platform still issues 'manual' licenses (design partners pay by invoice)
|
| 9 |
+
β stripe_customer/stripe_subscription stay NULL and gating/metering/
|
| 10 |
+
revocation work identically. Stripe only adds self-serve checkout on top.
|
| 11 |
+
|
| 12 |
+
License-key format (shown ONCE at issue time; only key_id is persisted):
|
| 13 |
+
|
| 14 |
+
atpk_<key_id>.<sig>
|
| 15 |
+
key_id = 24 hex chars (secrets.token_hex(12))
|
| 16 |
+
sig = HMAC-SHA256(LICENSE_SIGNING_KEY, key_id) hex, first 32 chars
|
| 17 |
+
|
| 18 |
+
Verification model β the key alone NEVER suffices:
|
| 19 |
+
|
| 20 |
+
check_license() first verifies the HMAC in constant time (cheap, no DB
|
| 21 |
+
hit for forged keys), then loads the row by key_id and enforces
|
| 22 |
+
status == 'active' AND (expires_at IS NULL OR in the future). Revocation
|
| 23 |
+
and expiry are DB STATE β a revoked/expired row fails on the very next
|
| 24 |
+
call regardless of a validly signed key. Rows whose expires_at has passed
|
| 25 |
+
are auto-flipped to status='expired' on check (`licenses` is mutable by
|
| 26 |
+
design; `usage_events` + the atp_evidence chain are the append-only
|
| 27 |
+
billing/audit record).
|
| 28 |
+
|
| 29 |
+
Key resolution (mirrors atp/signing.py, fail-loud):
|
| 30 |
+
|
| 31 |
+
LICENSE_SIGNING_KEY required whenever keys are issued/verified with
|
| 32 |
+
auth enabled β LicenseSigningKeyError if unset.
|
| 33 |
+
BU_AUTH_DISABLED=1 (dev) fallback: derive a stable key from
|
| 34 |
+
BU_AUTH_SECRET when set, else a process-
|
| 35 |
+
ephemeral random key with a loud warning
|
| 36 |
+
(keys then don't verify across restarts).
|
| 37 |
+
|
| 38 |
+
Public:
|
| 39 |
+
issue_license(org_id, agent_id, kind, expires_at=None,
|
| 40 |
+
stripe_customer=None, stripe_subscription=None)
|
| 41 |
+
-> {license, licenseKey}
|
| 42 |
+
check_license(license_key) -> {ok, license?, reason?}
|
| 43 |
+
revoke_license(org_id, license_id, reason) -> license dict (idempotent)
|
| 44 |
+
record_usage(org_id, license_id, agent_id, endpoint,
|
| 45 |
+
tokens_in, tokens_out, status) -> usage-event dict
|
| 46 |
+
usage_summary(org_id, license_id=None)
|
| 47 |
+
-> {calls, tokensIn, tokensOut, byAgent}
|
| 48 |
+
list_licenses(org_id) -> [license dict] latest-first
|
| 49 |
+
|
| 50 |
+
Returned license dicts are camelCase and NEVER contain key material: the
|
| 51 |
+
secret half of the key is not stored anywhere; `keyId` (the public identifier
|
| 52 |
+
half, useless without LICENSE_SIGNING_KEY) is included so an admin can match
|
| 53 |
+
a key in hand to its row. Like atp/store.py, returned dicts do not grow an
|
| 54 |
+
org key.
|
| 55 |
+
"""
|
| 56 |
+
|
| 57 |
+
from __future__ import annotations
|
| 58 |
+
|
| 59 |
+
import hashlib
|
| 60 |
+
import hmac
|
| 61 |
+
import os
|
| 62 |
+
import secrets
|
| 63 |
+
import sys
|
| 64 |
+
import time
|
| 65 |
+
import uuid
|
| 66 |
+
from datetime import datetime, timezone
|
| 67 |
+
|
| 68 |
+
from sqlalchemy import text
|
| 69 |
+
|
| 70 |
+
from atp import db, store, tenant_db
|
| 71 |
+
|
| 72 |
+
#: License-key prefix (public, greppable β like Stripe's sk_/pk_).
|
| 73 |
+
KEY_PREFIX = "atpk_"
|
| 74 |
+
|
| 75 |
+
#: Hex chars of the HMAC kept in the key (128 bits of the 256-bit MAC).
|
| 76 |
+
_SIG_LEN = 32
|
| 77 |
+
|
| 78 |
+
#: Domain-separation tag for the BU_AUTH_SECRET-derived dev key β distinct
|
| 79 |
+
#: from atp/signing.py's tag so evidence and license keys never share a key.
|
| 80 |
+
_DERIVE_TAG = b"bu-license-signing-v1:"
|
| 81 |
+
|
| 82 |
+
VALID_KINDS = frozenset({"evaluation", "production"})
|
| 83 |
+
|
| 84 |
+
_EPHEMERAL_KEY: bytes | None = None
|
| 85 |
+
_WARNED = False
|
| 86 |
+
_MIGRATIONS_DONE = False
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class LicensingError(RuntimeError):
|
| 90 |
+
"""Base class for licensing failures."""
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class LicenseSigningKeyError(LicensingError):
|
| 94 |
+
"""Key issue/verify was requested but no usable signing key is set."""
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class UnknownAgentError(LicensingError):
|
| 98 |
+
"""agent_id does not exist in the marketplace seed."""
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class AgentNotLicensableError(LicensingError):
|
| 102 |
+
"""Agent exists but its seed says licensing.available is false."""
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class LicenseNotFoundError(LicensingError):
|
| 106 |
+
"""No license with that id is visible to the caller's org."""
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ββ Signing key (mirrors atp/signing.py) ββββββββββββββββββββββββββββββββββββ
|
| 110 |
+
|
| 111 |
+
def _signing_key() -> bytes:
|
| 112 |
+
"""Resolve the HMAC key (see module docstring for the precedence)."""
|
| 113 |
+
global _EPHEMERAL_KEY, _WARNED
|
| 114 |
+
key = os.environ.get("LICENSE_SIGNING_KEY", "").strip()
|
| 115 |
+
if key:
|
| 116 |
+
return key.encode("utf-8")
|
| 117 |
+
|
| 118 |
+
if os.environ.get("BU_AUTH_DISABLED", "") == "1":
|
| 119 |
+
# Dev mode: never block the loop, but never sign with a hardcoded
|
| 120 |
+
# default either β derive from the auth secret when one exists.
|
| 121 |
+
auth_secret = os.environ.get("BU_AUTH_SECRET", "").strip()
|
| 122 |
+
if auth_secret:
|
| 123 |
+
return hashlib.sha256(_DERIVE_TAG + auth_secret.encode("utf-8")).digest()
|
| 124 |
+
if _EPHEMERAL_KEY is None:
|
| 125 |
+
_EPHEMERAL_KEY = secrets.token_bytes(32)
|
| 126 |
+
if not _WARNED:
|
| 127 |
+
_WARNED = True
|
| 128 |
+
print(
|
| 129 |
+
"WARNING: LICENSE_SIGNING_KEY unset (BU_AUTH_DISABLED=1 dev "
|
| 130 |
+
"mode) β signing license keys with a process-EPHEMERAL key. "
|
| 131 |
+
"Issued keys will NOT verify after a restart. Set "
|
| 132 |
+
"LICENSE_SIGNING_KEY (see .env.example).",
|
| 133 |
+
file=sys.stderr,
|
| 134 |
+
)
|
| 135 |
+
return _EPHEMERAL_KEY
|
| 136 |
+
|
| 137 |
+
raise LicenseSigningKeyError(
|
| 138 |
+
"LICENSE_SIGNING_KEY is not set. License-key signing is mandatory "
|
| 139 |
+
"when auth is enabled (docs/HARDENING.md Phase 4) β generate one with "
|
| 140 |
+
"python3 -c \"import secrets; print(secrets.token_hex(32))\" and "
|
| 141 |
+
"export it, or export BU_AUTH_DISABLED=1 for local dev only."
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _key_sig(key_id: str) -> str:
|
| 146 |
+
"""Secret half of a license key: truncated hex HMAC-SHA256 over key_id."""
|
| 147 |
+
return hmac.new(
|
| 148 |
+
_signing_key(), key_id.encode("utf-8"), hashlib.sha256
|
| 149 |
+
).hexdigest()[:_SIG_LEN]
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _parse_key(license_key) -> tuple[str, str] | None:
|
| 153 |
+
"""Split 'atpk_<key_id>.<sig>' β (key_id, sig); None when malformed."""
|
| 154 |
+
if not isinstance(license_key, str) or not license_key.startswith(KEY_PREFIX):
|
| 155 |
+
return None
|
| 156 |
+
key_id, dot, sig = license_key[len(KEY_PREFIX):].partition(".")
|
| 157 |
+
if not dot or not key_id or not sig:
|
| 158 |
+
return None
|
| 159 |
+
return key_id, sig
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ββ Row plumbing βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 163 |
+
|
| 164 |
+
# camelCase API key β snake_case column; declaration order = SELECT order.
|
| 165 |
+
_LICENSE_COLS = {
|
| 166 |
+
"id": "id",
|
| 167 |
+
"agentId": "agent_id",
|
| 168 |
+
"kind": "kind",
|
| 169 |
+
"status": "status",
|
| 170 |
+
"keyId": "key_id", # public identifier half only β never secret
|
| 171 |
+
"createdAt": "created_at",
|
| 172 |
+
"expiresAt": "expires_at",
|
| 173 |
+
"revokedAt": "revoked_at",
|
| 174 |
+
"revokedReason": "revoked_reason",
|
| 175 |
+
"stripeCustomer": "stripe_customer",
|
| 176 |
+
"stripeSubscription": "stripe_subscription",
|
| 177 |
+
"seats": "seats",
|
| 178 |
+
}
|
| 179 |
+
_LICENSE_SELECT = ", ".join(_LICENSE_COLS.values())
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _to_camel(row: dict) -> dict:
|
| 183 |
+
"""Map a licenses row to the public camelCase shape (org_id dropped β
|
| 184 |
+
same convention as atp/store.py: returned dicts don't grow an org key)."""
|
| 185 |
+
return {camel: row.get(col) for camel, col in _LICENSE_COLS.items()}
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _now_iso() -> str:
|
| 189 |
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _parse_ts(value) -> datetime | None:
|
| 193 |
+
"""ISO-8601 β aware UTC datetime; None when unparseable."""
|
| 194 |
+
if not value or not isinstance(value, str):
|
| 195 |
+
return None
|
| 196 |
+
try:
|
| 197 |
+
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(
|
| 198 |
+
tzinfo=timezone.utc)
|
| 199 |
+
except ValueError:
|
| 200 |
+
pass
|
| 201 |
+
try:
|
| 202 |
+
dt = datetime.fromisoformat(value)
|
| 203 |
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
| 204 |
+
except ValueError:
|
| 205 |
+
return None
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _ensure_migrations() -> None:
|
| 209 |
+
"""Lazily apply pending migrations once per process (idempotent β the
|
| 210 |
+
startup run stays authoritative; this covers direct library use/tests)."""
|
| 211 |
+
global _MIGRATIONS_DONE
|
| 212 |
+
if not _MIGRATIONS_DONE:
|
| 213 |
+
db.run_migrations()
|
| 214 |
+
_MIGRATIONS_DONE = True
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _load_by_key_id(key_id: str) -> dict | None:
|
| 218 |
+
"""Load one licenses row by key_id β the key-based lookup path.
|
| 219 |
+
|
| 220 |
+
check_license() authenticates by license KEY, not by org (the caller of
|
| 221 |
+
the gated proxy may be a machine holding only the key); the org comes
|
| 222 |
+
FROM the row. On Postgres the `key_lookup` SELECT policy (migration 006)
|
| 223 |
+
makes exactly this one row visible after pinning the verified key_id:
|
| 224 |
+
SELECT set_config('app.license_key_id', :kid, true) -- == SET LOCAL
|
| 225 |
+
in the same transaction. Writes still require the org GUC, so everything
|
| 226 |
+
that follows (expiry flip, metering) goes back through atp/tenant_db.py
|
| 227 |
+
scoped to the row's org.
|
| 228 |
+
"""
|
| 229 |
+
engine = db.get_engine()
|
| 230 |
+
with engine.begin() as conn:
|
| 231 |
+
if engine.dialect.name == "postgresql":
|
| 232 |
+
conn.execute(
|
| 233 |
+
text("SELECT set_config('app.license_key_id', :kid, true)"),
|
| 234 |
+
{"kid": key_id},
|
| 235 |
+
)
|
| 236 |
+
row = conn.execute(
|
| 237 |
+
text(f"SELECT org_id, {_LICENSE_SELECT} FROM licenses "
|
| 238 |
+
"WHERE key_id = :kid"),
|
| 239 |
+
{"kid": key_id},
|
| 240 |
+
).mappings().first()
|
| 241 |
+
return dict(row) if row else None
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
# ββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 245 |
+
|
| 246 |
+
def issue_license(org_id: str, agent_id: str, kind: str,
|
| 247 |
+
expires_at: str | None = None,
|
| 248 |
+
stripe_customer: str | None = None,
|
| 249 |
+
stripe_subscription: str | None = None) -> dict:
|
| 250 |
+
"""Issue a license for a marketplace agent to `org_id`.
|
| 251 |
+
|
| 252 |
+
Returns {"license": <camelCase dict>, "licenseKey": "atpk_..."} β the
|
| 253 |
+
licenseKey is shown exactly ONCE here; only its key_id half is stored.
|
| 254 |
+
Manual (invoice-billed) licenses simply pass no stripe_* values.
|
| 255 |
+
|
| 256 |
+
Raises UnknownAgentError / AgentNotLicensableError when the agent is not
|
| 257 |
+
in the seed or its seed licensing says available=false; ValueError on a
|
| 258 |
+
bad kind or unparseable expires_at.
|
| 259 |
+
"""
|
| 260 |
+
agent = next(
|
| 261 |
+
(a for a in store.get_data()["AGENTS"] if a.get("id") == agent_id),
|
| 262 |
+
None)
|
| 263 |
+
if agent is None:
|
| 264 |
+
raise UnknownAgentError(f"unknown agent {agent_id!r}")
|
| 265 |
+
if not (agent.get("licensing") or {}).get("available"):
|
| 266 |
+
raise AgentNotLicensableError(
|
| 267 |
+
f"agent {agent_id!r} is not available for licensing")
|
| 268 |
+
if kind not in VALID_KINDS:
|
| 269 |
+
raise ValueError(
|
| 270 |
+
f"kind must be one of {sorted(VALID_KINDS)}, got {kind!r}")
|
| 271 |
+
if expires_at is not None and _parse_ts(expires_at) is None:
|
| 272 |
+
raise ValueError(
|
| 273 |
+
f"expires_at must be ISO-8601 UTC ('%Y-%m-%dT%H:%M:%SZ'), "
|
| 274 |
+
f"got {expires_at!r}")
|
| 275 |
+
|
| 276 |
+
_ensure_migrations()
|
| 277 |
+
key_id = secrets.token_hex(12) # 96 bits; UNIQUE constraint is backstop
|
| 278 |
+
license_key = f"{KEY_PREFIX}{key_id}.{_key_sig(key_id)}"
|
| 279 |
+
row = {
|
| 280 |
+
"id": f"lic-{uuid.uuid4().hex[:12]}",
|
| 281 |
+
"agent_id": agent_id,
|
| 282 |
+
"kind": kind,
|
| 283 |
+
"status": "active",
|
| 284 |
+
"key_id": key_id,
|
| 285 |
+
"created_at": _now_iso(),
|
| 286 |
+
"expires_at": expires_at,
|
| 287 |
+
"revoked_at": None,
|
| 288 |
+
"revoked_reason": None,
|
| 289 |
+
"stripe_customer": stripe_customer,
|
| 290 |
+
"stripe_subscription": stripe_subscription,
|
| 291 |
+
"seats": 1,
|
| 292 |
+
}
|
| 293 |
+
tenant_db.scoped_execute(
|
| 294 |
+
org_id,
|
| 295 |
+
"INSERT INTO licenses "
|
| 296 |
+
" (id, org_id, agent_id, kind, status, key_id, created_at, "
|
| 297 |
+
" expires_at, revoked_at, revoked_reason, stripe_customer, "
|
| 298 |
+
" stripe_subscription, seats) "
|
| 299 |
+
"VALUES (:id, :org, :agent_id, :kind, :status, :key_id, :created_at, "
|
| 300 |
+
" :expires_at, :revoked_at, :revoked_reason, :stripe_customer, "
|
| 301 |
+
" :stripe_subscription, :seats)",
|
| 302 |
+
{"org": org_id, **row},
|
| 303 |
+
)
|
| 304 |
+
return {"license": _to_camel(row), "licenseKey": license_key}
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def check_license(license_key) -> dict:
|
| 308 |
+
"""Verify a presented license key. -> {ok, license?, reason?}
|
| 309 |
+
|
| 310 |
+
Order of enforcement:
|
| 311 |
+
1. parse β malformed keys fail without touching the signing key or DB;
|
| 312 |
+
2. constant-time HMAC verify (hmac.compare_digest) β forged keys fail
|
| 313 |
+
without a DB hit;
|
| 314 |
+
3. DB state β the row must exist, have status 'active', and not be past
|
| 315 |
+
expires_at. Revoked/expired rows fail REGARDLESS of a valid
|
| 316 |
+
signature: revocation is DB state, the key alone never suffices.
|
| 317 |
+
Rows expired by date but still marked 'active' are flipped to 'expired'
|
| 318 |
+
here (scoped to the row's own org).
|
| 319 |
+
|
| 320 |
+
reason β {'malformed', 'invalid-signature', 'unknown', 'revoked',
|
| 321 |
+
'expired'}; on success the license dict is returned (no key material).
|
| 322 |
+
"""
|
| 323 |
+
parsed = _parse_key(license_key)
|
| 324 |
+
if parsed is None:
|
| 325 |
+
return {"ok": False, "reason": "malformed"}
|
| 326 |
+
key_id, sig = parsed
|
| 327 |
+
expected = _key_sig(key_id)
|
| 328 |
+
if not hmac.compare_digest(expected.encode("utf-8"), sig.encode("utf-8")):
|
| 329 |
+
return {"ok": False, "reason": "invalid-signature"}
|
| 330 |
+
|
| 331 |
+
_ensure_migrations()
|
| 332 |
+
row = _load_by_key_id(key_id)
|
| 333 |
+
if row is None:
|
| 334 |
+
return {"ok": False, "reason": "unknown"}
|
| 335 |
+
if row["status"] == "revoked":
|
| 336 |
+
return {"ok": False, "reason": "revoked"}
|
| 337 |
+
|
| 338 |
+
expires = _parse_ts(row.get("expires_at")) if row.get("expires_at") else None
|
| 339 |
+
past_expiry = row.get("expires_at") is not None and (
|
| 340 |
+
expires is None # unparseable date in DB β fail safe (deny)
|
| 341 |
+
or expires <= datetime.now(timezone.utc))
|
| 342 |
+
if row["status"] == "expired" or past_expiry:
|
| 343 |
+
if row["status"] == "active":
|
| 344 |
+
# Auto-transition: expiry-by-date becomes durable DB state.
|
| 345 |
+
# `licenses` is mutable by design (only usage/evidence are
|
| 346 |
+
# append-only); guard on status for concurrent checks.
|
| 347 |
+
tenant_db.scoped_execute(
|
| 348 |
+
row["org_id"],
|
| 349 |
+
"UPDATE licenses SET status = 'expired' "
|
| 350 |
+
"WHERE org_id = :org AND id = :id AND status = 'active'",
|
| 351 |
+
{"org": row["org_id"], "id": row["id"]},
|
| 352 |
+
)
|
| 353 |
+
return {"ok": False, "reason": "expired"}
|
| 354 |
+
|
| 355 |
+
return {"ok": True, "license": _to_camel(row)}
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def revoke_license(org_id: str, license_id: str, reason: str) -> dict:
|
| 359 |
+
"""Revoke a license (idempotent). Returns the camelCase license dict.
|
| 360 |
+
|
| 361 |
+
Org-scoped: a license belonging to another org is simply not visible, so
|
| 362 |
+
cross-org revocation raises LicenseNotFoundError. Takes effect on the
|
| 363 |
+
very next check_license() call β the gate re-reads DB state every time.
|
| 364 |
+
"""
|
| 365 |
+
_ensure_migrations()
|
| 366 |
+
rows = tenant_db.scoped_query(
|
| 367 |
+
org_id,
|
| 368 |
+
f"SELECT {_LICENSE_SELECT} FROM licenses "
|
| 369 |
+
"WHERE org_id = :org AND id = :id",
|
| 370 |
+
{"org": org_id, "id": license_id},
|
| 371 |
+
)
|
| 372 |
+
if not rows:
|
| 373 |
+
raise LicenseNotFoundError(
|
| 374 |
+
f"no license {license_id!r} in org {org_id!r}")
|
| 375 |
+
row = rows[0]
|
| 376 |
+
if row["status"] == "revoked":
|
| 377 |
+
return _to_camel(row) # already revoked β idempotent no-op
|
| 378 |
+
row["status"] = "revoked"
|
| 379 |
+
row["revoked_at"] = _now_iso()
|
| 380 |
+
row["revoked_reason"] = str(reason or "")
|
| 381 |
+
tenant_db.scoped_execute(
|
| 382 |
+
org_id,
|
| 383 |
+
"UPDATE licenses SET status = 'revoked', revoked_at = :ts, "
|
| 384 |
+
"revoked_reason = :reason WHERE org_id = :org AND id = :id",
|
| 385 |
+
{"org": org_id, "id": license_id,
|
| 386 |
+
"ts": row["revoked_at"], "reason": row["revoked_reason"]},
|
| 387 |
+
)
|
| 388 |
+
return _to_camel(row)
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def reissue_key(org_id: str, license_id: str) -> dict:
|
| 392 |
+
"""Mint a NEW key for an existing ACTIVE license (old key stops working
|
| 393 |
+
immediately β key_id is replaced, and check_license resolves by key_id).
|
| 394 |
+
|
| 395 |
+
The in-product delivery path for Stripe self-serve licenses, whose
|
| 396 |
+
webhook-issued plaintext key is discarded by design: an org admin
|
| 397 |
+
re-issues and hands the fresh key to the customer. Returns
|
| 398 |
+
{"license": ..., "licenseKey": ...}; the key appears exactly ONCE here.
|
| 399 |
+
"""
|
| 400 |
+
_ensure_migrations()
|
| 401 |
+
rows = tenant_db.scoped_query(
|
| 402 |
+
org_id,
|
| 403 |
+
f"SELECT {_LICENSE_SELECT} FROM licenses "
|
| 404 |
+
"WHERE org_id = :org AND id = :id",
|
| 405 |
+
{"org": org_id, "id": license_id},
|
| 406 |
+
)
|
| 407 |
+
if not rows:
|
| 408 |
+
raise LicenseNotFoundError(
|
| 409 |
+
f"no license {license_id!r} in org {org_id!r}")
|
| 410 |
+
if rows[0]["status"] != "active":
|
| 411 |
+
raise ValueError(
|
| 412 |
+
f"only active licenses can be re-keyed "
|
| 413 |
+
f"(status is {rows[0]['status']!r})")
|
| 414 |
+
key_id = secrets.token_hex(12)
|
| 415 |
+
license_key = f"{KEY_PREFIX}{key_id}.{_key_sig(key_id)}"
|
| 416 |
+
tenant_db.scoped_execute(
|
| 417 |
+
org_id,
|
| 418 |
+
"UPDATE licenses SET key_id = :key_id "
|
| 419 |
+
"WHERE org_id = :org AND id = :id",
|
| 420 |
+
{"org": org_id, "id": license_id, "key_id": key_id},
|
| 421 |
+
)
|
| 422 |
+
rows[0]["key_id"] = key_id
|
| 423 |
+
return {"license": _to_camel(rows[0]), "licenseKey": license_key}
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def record_usage(org_id: str, license_id: str | None, agent_id: str,
|
| 427 |
+
endpoint: str, tokens_in: int, tokens_out: int,
|
| 428 |
+
status: str) -> dict:
|
| 429 |
+
"""Append one metering row to usage_events (append-only billing evidence).
|
| 430 |
+
|
| 431 |
+
Returns the camelCase event including its row id.
|
| 432 |
+
"""
|
| 433 |
+
_ensure_migrations()
|
| 434 |
+
event = {
|
| 435 |
+
"ts": _now_iso(),
|
| 436 |
+
"license_id": license_id,
|
| 437 |
+
"agent_id": agent_id,
|
| 438 |
+
"endpoint": endpoint,
|
| 439 |
+
"tokens_in": int(tokens_in or 0),
|
| 440 |
+
"tokens_out": int(tokens_out or 0),
|
| 441 |
+
"status": str(status),
|
| 442 |
+
}
|
| 443 |
+
result = tenant_db.scoped_execute(
|
| 444 |
+
org_id,
|
| 445 |
+
"INSERT INTO usage_events "
|
| 446 |
+
" (ts, org_id, license_id, agent_id, endpoint, tokens_in, "
|
| 447 |
+
" tokens_out, status) "
|
| 448 |
+
"VALUES (:ts, :org, :license_id, :agent_id, :endpoint, :tokens_in, "
|
| 449 |
+
" :tokens_out, :status) "
|
| 450 |
+
"RETURNING id",
|
| 451 |
+
{"org": org_id, **event},
|
| 452 |
+
)
|
| 453 |
+
return {
|
| 454 |
+
"id": result.lastrowid,
|
| 455 |
+
"ts": event["ts"],
|
| 456 |
+
"licenseId": license_id,
|
| 457 |
+
"agentId": agent_id,
|
| 458 |
+
"endpoint": endpoint,
|
| 459 |
+
"tokensIn": event["tokens_in"],
|
| 460 |
+
"tokensOut": event["tokens_out"],
|
| 461 |
+
"status": event["status"],
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
def usage_summary(org_id: str, license_id: str | None = None) -> dict:
|
| 466 |
+
"""Aggregate an org's metering rows (optionally for one license).
|
| 467 |
+
|
| 468 |
+
-> {calls, tokensIn, tokensOut, byAgent: {agentId: {calls, tokensIn,
|
| 469 |
+
tokensOut}}}. Values are plain ints on both dialects.
|
| 470 |
+
"""
|
| 471 |
+
_ensure_migrations()
|
| 472 |
+
sql = (
|
| 473 |
+
"SELECT agent_id, COUNT(*) AS calls, "
|
| 474 |
+
" COALESCE(SUM(tokens_in), 0) AS tokens_in, "
|
| 475 |
+
" COALESCE(SUM(tokens_out), 0) AS tokens_out "
|
| 476 |
+
"FROM usage_events WHERE org_id = :org"
|
| 477 |
+
)
|
| 478 |
+
params: dict = {"org": org_id}
|
| 479 |
+
if license_id is not None:
|
| 480 |
+
sql += " AND license_id = :license_id"
|
| 481 |
+
params["license_id"] = license_id
|
| 482 |
+
sql += " GROUP BY agent_id"
|
| 483 |
+
by_agent: dict[str, dict] = {}
|
| 484 |
+
totals = {"calls": 0, "tokensIn": 0, "tokensOut": 0}
|
| 485 |
+
for r in tenant_db.scoped_query(org_id, sql, params):
|
| 486 |
+
entry = {
|
| 487 |
+
"calls": int(r["calls"]),
|
| 488 |
+
"tokensIn": int(r["tokens_in"]), # PG SUM β Decimal
|
| 489 |
+
"tokensOut": int(r["tokens_out"]),
|
| 490 |
+
}
|
| 491 |
+
by_agent[r["agent_id"] or ""] = entry
|
| 492 |
+
totals["calls"] += entry["calls"]
|
| 493 |
+
totals["tokensIn"] += entry["tokensIn"]
|
| 494 |
+
totals["tokensOut"] += entry["tokensOut"]
|
| 495 |
+
return {**totals, "byAgent": by_agent}
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
def list_licenses(org_id: str) -> list[dict]:
|
| 499 |
+
"""All of `org_id`'s licenses, latest-first, camelCase β never any key
|
| 500 |
+
material (only keyId, the public identifier half)."""
|
| 501 |
+
_ensure_migrations()
|
| 502 |
+
rows = tenant_db.scoped_query(
|
| 503 |
+
org_id,
|
| 504 |
+
f"SELECT {_LICENSE_SELECT} FROM licenses WHERE org_id = :org "
|
| 505 |
+
"ORDER BY created_at DESC, id DESC",
|
| 506 |
+
{"org": org_id},
|
| 507 |
+
)
|
| 508 |
+
return [_to_camel(r) for r in rows]
|
atp/reportcard.py
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
atp/reportcard.py β Phase 6 (docs/HARDENING.md): official report-card PDF.
|
| 3 |
+
|
| 4 |
+
build_report_card_pdf(agent_id, org_id='org-demo') -> bytes
|
| 5 |
+
|
| 6 |
+
Server-side PDF export of one agent's report card, written for a
|
| 7 |
+
NON-TECHNICAL stakeholder: who the agent is, what it is certified for,
|
| 8 |
+
where it is strong, and β deliberately IN the document, per the ATP
|
| 9 |
+
'failure transparency' principle β where it will break.
|
| 10 |
+
|
| 11 |
+
Data sources:
|
| 12 |
+
* Seed catalog (atp/store.get_data()) β T0 public: agent profile,
|
| 13 |
+
reportCard prose, skills, failure modes, cert metadata + seed awards.
|
| 14 |
+
* Live cert awards for the agent from atp_cert_awards, org-scoped through
|
| 15 |
+
atp/tenant_db.py ONLY (docs/TENANCY.md layer 2; org_id comes from the
|
| 16 |
+
verified request state at the route). Live awards whose evidence was
|
| 17 |
+
produced by the deterministic dry-run backend are marked DRY-RUN so a
|
| 18 |
+
stub exam can never read like a real certification.
|
| 19 |
+
|
| 20 |
+
The PDF mirrors the Agent-view aesthetic (design/styles.css "university +
|
| 21 |
+
standards-body tone"): navy header band, gold rules, mono labels, layer
|
| 22 |
+
palette. reportlab only β built-in Helvetica/Courier faces, no external
|
| 23 |
+
assets. Rendering is pure (bytes out, no files written); tenant files are
|
| 24 |
+
NEVER involved and nothing is served from /videos or /media (TENANCY.md
|
| 25 |
+
leak surface #4).
|
| 26 |
+
|
| 27 |
+
Raises UnknownAgentError for an agent id that is not in the seed roster β
|
| 28 |
+
the API route maps it to 404.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import io
|
| 34 |
+
import json
|
| 35 |
+
import threading
|
| 36 |
+
import time
|
| 37 |
+
from xml.sax.saxutils import escape as _xml_escape
|
| 38 |
+
|
| 39 |
+
from reportlab.lib import colors
|
| 40 |
+
from reportlab.lib.pagesizes import A4
|
| 41 |
+
from reportlab.lib.styles import ParagraphStyle
|
| 42 |
+
from reportlab.lib.units import mm # noqa: F401 (kept: handy for tweaks)
|
| 43 |
+
from reportlab.platypus import (
|
| 44 |
+
Flowable,
|
| 45 |
+
HRFlowable,
|
| 46 |
+
Paragraph,
|
| 47 |
+
SimpleDocTemplate,
|
| 48 |
+
Spacer,
|
| 49 |
+
Table,
|
| 50 |
+
TableStyle,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
from atp import store, tenant_db
|
| 54 |
+
|
| 55 |
+
# ββ Palette β mirrors design/styles.css (ATP / Agent view tokens) βββββββββββ
|
| 56 |
+
|
| 57 |
+
NAVY = colors.HexColor("#0a0e22") # --c-bg (header band)
|
| 58 |
+
NAVY_ELEV = colors.HexColor("#0f1733") # --c-bg-elev
|
| 59 |
+
GOLD = colors.HexColor("#e8c268") # --c-cert (on navy)
|
| 60 |
+
GOLD_PAPER = colors.HexColor("#a3801f") # --c-cert, paper palette (rules)
|
| 61 |
+
INK = colors.HexColor("#1c2237")
|
| 62 |
+
INK_MUTED = colors.HexColor("#5d6688")
|
| 63 |
+
LINE = colors.HexColor("#c9cede")
|
| 64 |
+
PANEL = colors.HexColor("#f4f5f9")
|
| 65 |
+
CORAL = colors.HexColor("#c2403f") # revoked / weak (paper-legible)
|
| 66 |
+
AMBER = colors.HexColor("#b45309") # developing
|
| 67 |
+
GREEN = colors.HexColor("#2f7d4a") # strong
|
| 68 |
+
GREY = colors.HexColor("#9ca3af") # untested
|
| 69 |
+
|
| 70 |
+
#: layer palette (--c-atp-l1..l7), darkened where needed for white paper.
|
| 71 |
+
LAYER_COLORS = {
|
| 72 |
+
1: colors.HexColor("#2f7fae"), 2: colors.HexColor("#1f8a63"),
|
| 73 |
+
3: colors.HexColor("#b45309"), 4: colors.HexColor("#c2410c"),
|
| 74 |
+
5: colors.HexColor("#c2403f"), 6: colors.HexColor("#7c5cbf"),
|
| 75 |
+
7: GOLD_PAPER,
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
_STATUS_COLORS = {
|
| 79 |
+
"strong": GREEN, "developing": AMBER, "weak": CORAL,
|
| 80 |
+
"untested": GREY, "active": GREEN, "training": AMBER, "revoked": CORAL,
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
PAGE_W, PAGE_H = A4
|
| 84 |
+
MARGIN = 48
|
| 85 |
+
BAND_H = 92 # first-page navy header band
|
| 86 |
+
BAND_H_LATER = 24 # thin band on later pages
|
| 87 |
+
FOOTER_H = 78
|
| 88 |
+
|
| 89 |
+
_MONO, _MONO_B = "Courier", "Courier-Bold"
|
| 90 |
+
_SANS, _SANS_B, _SANS_I = "Helvetica", "Helvetica-Bold", "Helvetica-Oblique"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class UnknownAgentError(ValueError):
|
| 94 |
+
"""agent_id is not in the seed roster (atp/seed.py AGENTS)."""
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ββ DB readiness (same lazy idiom as api/atp.py) ββββββββββββββββββββββββββββ
|
| 98 |
+
|
| 99 |
+
_DB_READY = False
|
| 100 |
+
_DB_LOCK = threading.Lock()
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _ensure_db() -> None:
|
| 104 |
+
global _DB_READY
|
| 105 |
+
if _DB_READY:
|
| 106 |
+
return
|
| 107 |
+
with _DB_LOCK:
|
| 108 |
+
if not _DB_READY:
|
| 109 |
+
from atp import db
|
| 110 |
+
db.run_migrations()
|
| 111 |
+
_DB_READY = True
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ββ Data assembly βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
+
|
| 116 |
+
def _find_agent(data: dict, agent_id: str) -> dict:
|
| 117 |
+
for a in data.get("AGENTS", []):
|
| 118 |
+
if a.get("id") == agent_id:
|
| 119 |
+
return a
|
| 120 |
+
raise UnknownAgentError(
|
| 121 |
+
f"agent {agent_id!r} not found in the seed roster (atp/seed.py)")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _seed_cert_rows(data: dict, agent_id: str) -> list[dict]:
|
| 125 |
+
"""Seed awards for the agent: one row per cert grant, oldest first."""
|
| 126 |
+
rows = []
|
| 127 |
+
for cert in data.get("CERTS", []):
|
| 128 |
+
for award in cert.get("awards", []):
|
| 129 |
+
if award.get("agentId") != agent_id:
|
| 130 |
+
continue
|
| 131 |
+
rows.append({
|
| 132 |
+
"label": cert["label"],
|
| 133 |
+
"domain": cert["domain"],
|
| 134 |
+
"layer": cert["layer"],
|
| 135 |
+
"score": award.get("score"),
|
| 136 |
+
"ts": award.get("ts", ""),
|
| 137 |
+
"revoked": award.get("status") == "revoked",
|
| 138 |
+
"revokedTs": award.get("revokedTs"),
|
| 139 |
+
"live": False,
|
| 140 |
+
"dryRun": False,
|
| 141 |
+
})
|
| 142 |
+
rows.sort(key=lambda r: r["ts"])
|
| 143 |
+
return rows
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _loads_maybe(v):
|
| 147 |
+
if not isinstance(v, str):
|
| 148 |
+
return v
|
| 149 |
+
try:
|
| 150 |
+
return json.loads(v)
|
| 151 |
+
except (json.JSONDecodeError, ValueError):
|
| 152 |
+
return v
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _live_cert_rows(data: dict, agent_id: str, org_id: str) -> list[dict]:
|
| 156 |
+
"""Live atp_cert_awards rows for the agent (org-scoped), oldest first.
|
| 157 |
+
|
| 158 |
+
DRY-RUN detection: an award's evidence rows (written in the same exam
|
| 159 |
+
run by atp/exams.py) record the candidate spec in their payload; if any
|
| 160 |
+
resolves to the deterministic dry-run backend ('dryrun:*'), the award is
|
| 161 |
+
marked so the table can never present a stub exam as a real one.
|
| 162 |
+
"""
|
| 163 |
+
_ensure_db()
|
| 164 |
+
awards = tenant_db.scoped_query(
|
| 165 |
+
org_id,
|
| 166 |
+
"SELECT id, ts, cert_id, score, evidence_ids FROM atp_cert_awards"
|
| 167 |
+
" WHERE org_id = :org AND agent_id = :aid ORDER BY id ASC",
|
| 168 |
+
{"org": org_id, "aid": agent_id})
|
| 169 |
+
if not awards:
|
| 170 |
+
return []
|
| 171 |
+
|
| 172 |
+
ev_rows = tenant_db.scoped_query(
|
| 173 |
+
org_id,
|
| 174 |
+
"SELECT id, payload FROM atp_evidence"
|
| 175 |
+
" WHERE org_id = :org AND agent_id = :aid",
|
| 176 |
+
{"org": org_id, "aid": agent_id})
|
| 177 |
+
candidate_by_id: dict[str, str] = {}
|
| 178 |
+
for row in ev_rows:
|
| 179 |
+
payload = _loads_maybe(row.get("payload"))
|
| 180 |
+
if isinstance(payload, dict):
|
| 181 |
+
candidate_by_id[str(row["id"])] = str(payload.get("candidate", ""))
|
| 182 |
+
|
| 183 |
+
certs_by_id = {c["id"]: c for c in data.get("CERTS", [])}
|
| 184 |
+
out = []
|
| 185 |
+
for aw in awards:
|
| 186 |
+
cert = certs_by_id.get(aw.get("cert_id"), {})
|
| 187 |
+
evidence_ids = _loads_maybe(aw.get("evidence_ids")) or []
|
| 188 |
+
dry = any(
|
| 189 |
+
candidate_by_id.get(str(eid), "").startswith("dryrun")
|
| 190 |
+
for eid in evidence_ids)
|
| 191 |
+
out.append({
|
| 192 |
+
"label": cert.get("label", aw.get("cert_id", "?")),
|
| 193 |
+
"domain": cert.get("domain", "β"),
|
| 194 |
+
"layer": cert.get("layer", "β"),
|
| 195 |
+
"score": aw.get("score"),
|
| 196 |
+
# Live award ts is a full ISO stamp; the table shows the date
|
| 197 |
+
# (seed award dates are already date-only).
|
| 198 |
+
"ts": str(aw.get("ts") or "")[:10],
|
| 199 |
+
"revoked": False,
|
| 200 |
+
"revokedTs": None,
|
| 201 |
+
"live": True,
|
| 202 |
+
"dryRun": dry,
|
| 203 |
+
})
|
| 204 |
+
return out
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _live_evidence_count(agent_id: str, org_id: str) -> int:
|
| 208 |
+
_ensure_db()
|
| 209 |
+
rows = tenant_db.scoped_query(
|
| 210 |
+
org_id,
|
| 211 |
+
"SELECT COUNT(*) AS n FROM atp_evidence"
|
| 212 |
+
" WHERE org_id = :org AND agent_id = :aid",
|
| 213 |
+
{"org": org_id, "aid": agent_id})
|
| 214 |
+
return int(rows[0]["n"]) if rows else 0
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _citation(data: dict, agent: dict) -> str:
|
| 218 |
+
"""The citable sentence (STANDARD.sentence grammar); honest on revoke."""
|
| 219 |
+
domain = (agent.get("domains") or ["β"])[0]
|
| 220 |
+
if agent.get("status") == "revoked":
|
| 221 |
+
return (f"{agent['name']} is NOT currently ATP-certified in {domain}: "
|
| 222 |
+
f"its L{agent['level']} certification was revoked.")
|
| 223 |
+
template = data.get("STANDARD", {}).get(
|
| 224 |
+
"sentence", "{agent} is an ATP L{n}-certified agent in {domain}.")
|
| 225 |
+
return (template.replace("{agent}", agent["name"])
|
| 226 |
+
.replace("{n}", str(agent["level"]))
|
| 227 |
+
.replace("{domain}", domain))
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# ββ Small flowables βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 231 |
+
|
| 232 |
+
class _MasteryBar(Flowable):
|
| 233 |
+
"""Simple filled-rect mastery bar (per the UI's mastery meters)."""
|
| 234 |
+
|
| 235 |
+
def __init__(self, fraction: float, status: str,
|
| 236 |
+
width: float = 108, height: float = 7):
|
| 237 |
+
super().__init__()
|
| 238 |
+
self.fraction = max(0.0, min(1.0, float(fraction or 0.0)))
|
| 239 |
+
self.fill = _STATUS_COLORS.get(status, GREY)
|
| 240 |
+
self.width, self.height = width, height
|
| 241 |
+
|
| 242 |
+
def wrap(self, availWidth, availHeight): # noqa: N803 (reportlab API)
|
| 243 |
+
return self.width, self.height
|
| 244 |
+
|
| 245 |
+
def draw(self):
|
| 246 |
+
c = self.canv
|
| 247 |
+
c.setFillColor(PANEL)
|
| 248 |
+
c.rect(0, 0, self.width, self.height, stroke=0, fill=1)
|
| 249 |
+
if self.fraction > 0:
|
| 250 |
+
c.setFillColor(self.fill)
|
| 251 |
+
c.rect(0, 0, self.width * self.fraction, self.height,
|
| 252 |
+
stroke=0, fill=1)
|
| 253 |
+
c.setStrokeColor(LINE)
|
| 254 |
+
c.setLineWidth(0.5)
|
| 255 |
+
c.rect(0, 0, self.width, self.height, stroke=1, fill=0)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
# ββ Paragraph styles ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 259 |
+
|
| 260 |
+
def _styles() -> dict[str, ParagraphStyle]:
|
| 261 |
+
return {
|
| 262 |
+
"h1": ParagraphStyle("h1", fontName=_SANS_B, fontSize=17,
|
| 263 |
+
leading=21, textColor=INK),
|
| 264 |
+
"section": ParagraphStyle("section", fontName=_MONO_B, fontSize=9.5,
|
| 265 |
+
leading=12, textColor=NAVY_ELEV,
|
| 266 |
+
spaceBefore=6),
|
| 267 |
+
"body": ParagraphStyle("body", fontName=_SANS, fontSize=9.5,
|
| 268 |
+
leading=13.5, textColor=INK),
|
| 269 |
+
"bullet": ParagraphStyle("bullet", fontName=_SANS, fontSize=9.5,
|
| 270 |
+
leading=13.5, textColor=INK,
|
| 271 |
+
leftIndent=10, bulletIndent=2),
|
| 272 |
+
"cell": ParagraphStyle("cell", fontName=_SANS, fontSize=8.5,
|
| 273 |
+
leading=11, textColor=INK),
|
| 274 |
+
"cellMono": ParagraphStyle("cellMono", fontName=_MONO, fontSize=8,
|
| 275 |
+
leading=10.5, textColor=INK),
|
| 276 |
+
"labelMono": ParagraphStyle("labelMono", fontName=_MONO, fontSize=7,
|
| 277 |
+
leading=9, textColor=INK_MUTED),
|
| 278 |
+
"note": ParagraphStyle("note", fontName=_SANS_I, fontSize=8.5,
|
| 279 |
+
leading=11.5, textColor=INK_MUTED),
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _esc(s) -> str:
|
| 284 |
+
return _xml_escape(str(s if s is not None else "β"))
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _rule(color=GOLD_PAPER, thickness=1.1):
|
| 288 |
+
return HRFlowable(width="100%", thickness=thickness, color=color,
|
| 289 |
+
spaceBefore=1, spaceAfter=6)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _section(title: str, story: list, st: dict) -> None:
|
| 293 |
+
story.append(Spacer(1, 10))
|
| 294 |
+
story.append(Paragraph(_esc(title.upper()), st["section"]))
|
| 295 |
+
story.append(_rule())
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# ββ Page decoration (header band + footer, drawn on the canvas) ββββββββββββ
|
| 299 |
+
|
| 300 |
+
def _make_page_decorators(agent: dict, citation: str, n_evidence: int,
|
| 301 |
+
generated_at: str):
|
| 302 |
+
agent_name = agent.get("name", agent["id"])
|
| 303 |
+
deep_link = f"#/agent/{agent['id']}"
|
| 304 |
+
|
| 305 |
+
def _footer(canv, doc):
|
| 306 |
+
canv.saveState()
|
| 307 |
+
y = FOOTER_H - 20
|
| 308 |
+
canv.setStrokeColor(GOLD_PAPER)
|
| 309 |
+
canv.setLineWidth(1)
|
| 310 |
+
canv.line(MARGIN, y, PAGE_W - MARGIN, y)
|
| 311 |
+
canv.setFont(_SANS_I, 8)
|
| 312 |
+
canv.setFillColor(INK)
|
| 313 |
+
canv.drawString(MARGIN, y - 11, f"β{citation}β")
|
| 314 |
+
canv.setFont(_MONO, 6.8)
|
| 315 |
+
canv.setFillColor(INK_MUTED)
|
| 316 |
+
canv.drawString(
|
| 317 |
+
MARGIN, y - 22,
|
| 318 |
+
f"{deep_link} Β· generated {generated_at} Β· "
|
| 319 |
+
f"evidence: {n_evidence} signed records β "
|
| 320 |
+
f"verify at /atp/chain/verify")
|
| 321 |
+
canv.setFont(_MONO, 7)
|
| 322 |
+
canv.drawRightString(PAGE_W - MARGIN, y - 11,
|
| 323 |
+
f"PAGE {canv.getPageNumber()}")
|
| 324 |
+
canv.restoreState()
|
| 325 |
+
|
| 326 |
+
def first_page(canv, doc):
|
| 327 |
+
canv.saveState()
|
| 328 |
+
canv.setFillColor(NAVY)
|
| 329 |
+
canv.rect(0, PAGE_H - BAND_H, PAGE_W, BAND_H, stroke=0, fill=1)
|
| 330 |
+
canv.setFillColor(GOLD)
|
| 331 |
+
canv.setFont(_MONO_B, 8.5)
|
| 332 |
+
canv.drawString(MARGIN, PAGE_H - 30,
|
| 333 |
+
"ATP β AGENTIC TRAINING PLATFORM")
|
| 334 |
+
canv.setFont(_MONO, 7)
|
| 335 |
+
canv.drawRightString(PAGE_W - MARGIN, PAGE_H - 30,
|
| 336 |
+
"STANDARD ATP@2026.1")
|
| 337 |
+
canv.setFillColor(colors.white)
|
| 338 |
+
canv.setFont(_SANS_B, 19)
|
| 339 |
+
canv.drawString(MARGIN, PAGE_H - 56, "Official Report Card")
|
| 340 |
+
canv.setFillColor(colors.HexColor("#9aa3c0"))
|
| 341 |
+
canv.setFont(_MONO, 7.5)
|
| 342 |
+
canv.drawString(MARGIN, PAGE_H - 72,
|
| 343 |
+
"EVIDENCE-LINKED Β· REPRODUCIBLE Β· "
|
| 344 |
+
"FAILURE-TRANSPARENT Β· REVOCABLE")
|
| 345 |
+
canv.setStrokeColor(GOLD)
|
| 346 |
+
canv.setLineWidth(2)
|
| 347 |
+
canv.line(0, PAGE_H - BAND_H, PAGE_W, PAGE_H - BAND_H)
|
| 348 |
+
canv.restoreState()
|
| 349 |
+
_footer(canv, doc)
|
| 350 |
+
|
| 351 |
+
def later_pages(canv, doc):
|
| 352 |
+
canv.saveState()
|
| 353 |
+
canv.setFillColor(NAVY)
|
| 354 |
+
canv.rect(0, PAGE_H - BAND_H_LATER, PAGE_W, BAND_H_LATER,
|
| 355 |
+
stroke=0, fill=1)
|
| 356 |
+
canv.setFillColor(GOLD)
|
| 357 |
+
canv.setFont(_MONO_B, 7.5)
|
| 358 |
+
canv.drawString(MARGIN, PAGE_H - 16,
|
| 359 |
+
f"ATP β OFFICIAL REPORT CARD Β· "
|
| 360 |
+
f"{agent_name.upper()}")
|
| 361 |
+
canv.setStrokeColor(GOLD)
|
| 362 |
+
canv.setLineWidth(1.2)
|
| 363 |
+
canv.line(0, PAGE_H - BAND_H_LATER, PAGE_W, PAGE_H - BAND_H_LATER)
|
| 364 |
+
canv.restoreState()
|
| 365 |
+
_footer(canv, doc)
|
| 366 |
+
|
| 367 |
+
return first_page, later_pages
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
# ββ Story sections ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 371 |
+
|
| 372 |
+
def _identity_block(agent: dict, story: list, st: dict) -> None:
|
| 373 |
+
status = agent.get("status", "β")
|
| 374 |
+
status_color = _STATUS_COLORS.get(status, INK)
|
| 375 |
+
story.append(Paragraph(_esc(agent.get("name", agent["id"])), st["h1"]))
|
| 376 |
+
story.append(Spacer(1, 4))
|
| 377 |
+
|
| 378 |
+
labels = ["AGENT ID", "BASE MODEL", "LEVEL", "CAREER STAGE", "TIER",
|
| 379 |
+
"STATUS"]
|
| 380 |
+
values = [
|
| 381 |
+
Paragraph(_esc(agent["id"]), st["cellMono"]),
|
| 382 |
+
Paragraph(_esc(agent.get("baseModel")), st["cell"]),
|
| 383 |
+
Paragraph(
|
| 384 |
+
f'<font name="{_MONO_B}" color="{LAYER_COLORS.get(agent.get("level"), INK).hexval()}">'
|
| 385 |
+
f'L{_esc(agent.get("level"))}</font>', st["cell"]),
|
| 386 |
+
Paragraph(_esc(agent.get("careerStage")), st["cell"]),
|
| 387 |
+
Paragraph(_esc(agent.get("tier")), st["cell"]),
|
| 388 |
+
Paragraph(
|
| 389 |
+
f'<font name="{_SANS_B}" color="{status_color.hexval()}">'
|
| 390 |
+
f'{_esc(status.upper())}</font>', st["cell"]),
|
| 391 |
+
]
|
| 392 |
+
# AGENT ID gets the widest column so long slugs (gemma-physics-ocw)
|
| 393 |
+
# never wrap mid-word in the mono face.
|
| 394 |
+
w = PAGE_W - 2 * MARGIN
|
| 395 |
+
col_w = [w * f for f in (0.21, 0.17, 0.10, 0.16, 0.15, 0.21)]
|
| 396 |
+
table = Table(
|
| 397 |
+
[[Paragraph(lbl, st["labelMono"]) for lbl in labels], values],
|
| 398 |
+
colWidths=col_w)
|
| 399 |
+
table.setStyle(TableStyle([
|
| 400 |
+
("TOPPADDING", (0, 0), (-1, -1), 1),
|
| 401 |
+
("BOTTOMPADDING", (0, 0), (-1, 0), 0),
|
| 402 |
+
("BOTTOMPADDING", (0, 1), (-1, 1), 4),
|
| 403 |
+
("LEFTPADDING", (0, 0), (-1, -1), 0),
|
| 404 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 4),
|
| 405 |
+
("LINEBELOW", (0, 1), (-1, 1), 0.5, LINE),
|
| 406 |
+
]))
|
| 407 |
+
story.append(table)
|
| 408 |
+
|
| 409 |
+
prov = agent.get("provenance") or {}
|
| 410 |
+
if prov.get("origin") == "commissioned":
|
| 411 |
+
corpora = ", ".join(prov.get("corpora") or []) or "β"
|
| 412 |
+
req = prov.get("requestId") or "β"
|
| 413 |
+
story.append(Spacer(1, 5))
|
| 414 |
+
story.append(Paragraph(
|
| 415 |
+
f"PROVENANCE: COMMISSIONED (TRAIN-TO-ORDER) Β· "
|
| 416 |
+
f"CORPORA: {_esc(corpora)} Β· REQUEST: {_esc(req)}",
|
| 417 |
+
ParagraphStyle("prov", parent=st["cellMono"], fontSize=7.5,
|
| 418 |
+
textColor=GOLD_PAPER)))
|
| 419 |
+
story.append(Paragraph(
|
| 420 |
+
"This agent was trained to order for a specific corpus and "
|
| 421 |
+
"certified through the identical exam blueprint as every "
|
| 422 |
+
"pre-existing agent β there is no separate trust tier for "
|
| 423 |
+
"commissioned experts.", st["note"]))
|
| 424 |
+
|
| 425 |
+
if agent.get("status") == "revoked":
|
| 426 |
+
story.append(Spacer(1, 7))
|
| 427 |
+
warn = Table([[Paragraph(
|
| 428 |
+
f'<font name="{_SANS_B}" color="{CORAL.hexval()}">'
|
| 429 |
+
f'CERTIFICATION REVOKED.</font> '
|
| 430 |
+
f'The ATP board has revoked this agentβs badge; the record '
|
| 431 |
+
f'below stays public on purpose (the βrevocableβ '
|
| 432 |
+
f'principle). See the certifications table and the failure '
|
| 433 |
+
f'transparency section before relying on this agent for '
|
| 434 |
+
f'anything.', st["cell"])]],
|
| 435 |
+
colWidths=[PAGE_W - 2 * MARGIN])
|
| 436 |
+
warn.setStyle(TableStyle([
|
| 437 |
+
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#faeceb")),
|
| 438 |
+
("BOX", (0, 0), (-1, -1), 0.8, CORAL),
|
| 439 |
+
("TOPPADDING", (0, 0), (-1, -1), 6),
|
| 440 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
| 441 |
+
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
| 442 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
| 443 |
+
]))
|
| 444 |
+
story.append(warn)
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def _report_card_block(agent: dict, story: list, st: dict) -> None:
|
| 448 |
+
rc = agent.get("reportCard") or {}
|
| 449 |
+
_section("Report card β in plain language", story, st)
|
| 450 |
+
if rc.get("summary"):
|
| 451 |
+
story.append(Paragraph(_esc(rc["summary"]), st["body"]))
|
| 452 |
+
story.append(Spacer(1, 6))
|
| 453 |
+
if rc.get("strengths"):
|
| 454 |
+
story.append(Paragraph("<b>Strengths</b>", st["body"]))
|
| 455 |
+
for s in rc["strengths"]:
|
| 456 |
+
story.append(Paragraph(_esc(s), st["bullet"],
|
| 457 |
+
bulletText="β"))
|
| 458 |
+
story.append(Spacer(1, 5))
|
| 459 |
+
if rc.get("weaknesses"):
|
| 460 |
+
story.append(Paragraph("<b>Weaknesses</b>", st["body"]))
|
| 461 |
+
for w in rc["weaknesses"]:
|
| 462 |
+
story.append(Paragraph(_esc(w), st["bullet"],
|
| 463 |
+
bulletText="β"))
|
| 464 |
+
story.append(Spacer(1, 5))
|
| 465 |
+
if rc.get("recommendation"):
|
| 466 |
+
rec = Table([[Paragraph(
|
| 467 |
+
f"<b>Recommendation</b> β {_esc(rc['recommendation'])}",
|
| 468 |
+
st["cell"])]], colWidths=[PAGE_W - 2 * MARGIN])
|
| 469 |
+
rec.setStyle(TableStyle([
|
| 470 |
+
("BACKGROUND", (0, 0), (-1, -1), PANEL),
|
| 471 |
+
("BOX", (0, 0), (-1, -1), 0.6, GOLD_PAPER),
|
| 472 |
+
("TOPPADDING", (0, 0), (-1, -1), 6),
|
| 473 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
| 474 |
+
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
| 475 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
| 476 |
+
]))
|
| 477 |
+
story.append(rec)
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def _cert_table(rows: list[dict], story: list, st: dict) -> None:
|
| 481 |
+
_section("Certifications", story, st)
|
| 482 |
+
if not rows:
|
| 483 |
+
story.append(Paragraph(
|
| 484 |
+
"No certifications on record. This agent has not yet passed an "
|
| 485 |
+
"ATP exam.", st["note"]))
|
| 486 |
+
return
|
| 487 |
+
|
| 488 |
+
header = ["CERTIFICATION", "DOMAIN", "LAYER", "SCORE", "DATE", "STATUS"]
|
| 489 |
+
data = [[Paragraph(h, st["labelMono"]) for h in header]]
|
| 490 |
+
styles = TableStyle([
|
| 491 |
+
("BACKGROUND", (0, 0), (-1, 0), NAVY),
|
| 492 |
+
("LINEBELOW", (0, 0), (-1, -1), 0.4, LINE),
|
| 493 |
+
("TOPPADDING", (0, 0), (-1, -1), 4),
|
| 494 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
| 495 |
+
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
| 496 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
| 497 |
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 498 |
+
])
|
| 499 |
+
header_lbl = ParagraphStyle("hdr", parent=st["labelMono"],
|
| 500 |
+
textColor=GOLD)
|
| 501 |
+
data[0] = [Paragraph(h, header_lbl) for h in header]
|
| 502 |
+
|
| 503 |
+
for i, r in enumerate(rows, start=1):
|
| 504 |
+
layer_color = LAYER_COLORS.get(r["layer"], INK)
|
| 505 |
+
if r["revoked"]:
|
| 506 |
+
status_txt = (f'<font name="{_MONO_B}" color="{CORAL.hexval()}">'
|
| 507 |
+
f'REVOKED {_esc(r.get("revokedTs") or "")}</font>')
|
| 508 |
+
elif r["live"] and r["dryRun"]:
|
| 509 |
+
status_txt = (f'<font name="{_MONO_B}" '
|
| 510 |
+
f'color="{AMBER.hexval()}">LIVE Β· DRY-RUN'
|
| 511 |
+
f'</font>')
|
| 512 |
+
elif r["live"]:
|
| 513 |
+
status_txt = (f'<font name="{_MONO_B}" '
|
| 514 |
+
f'color="{GREEN.hexval()}">LIVE EXAM</font>')
|
| 515 |
+
else:
|
| 516 |
+
status_txt = (f'<font name="{_MONO}" '
|
| 517 |
+
f'color="{GREEN.hexval()}">granted</font>')
|
| 518 |
+
score = r.get("score")
|
| 519 |
+
data.append([
|
| 520 |
+
Paragraph(_esc(r["label"]), st["cellMono"]),
|
| 521 |
+
Paragraph(_esc(r["domain"]), st["cell"]),
|
| 522 |
+
Paragraph(
|
| 523 |
+
f'<font name="{_MONO_B}" color="{layer_color.hexval()}">'
|
| 524 |
+
f'L{_esc(r["layer"])}</font>', st["cell"]),
|
| 525 |
+
Paragraph("β" if score is None else f"{float(score):.2f}",
|
| 526 |
+
st["cellMono"]),
|
| 527 |
+
Paragraph(_esc(r["ts"]), st["cellMono"]),
|
| 528 |
+
Paragraph(status_txt, st["cellMono"]),
|
| 529 |
+
])
|
| 530 |
+
if r["revoked"]:
|
| 531 |
+
styles.add("BACKGROUND", (0, i), (-1, i),
|
| 532 |
+
colors.HexColor("#faeceb"))
|
| 533 |
+
|
| 534 |
+
w = PAGE_W - 2 * MARGIN
|
| 535 |
+
table = Table(data, colWidths=[w * 0.30, w * 0.17, w * 0.08, w * 0.10,
|
| 536 |
+
w * 0.15, w * 0.20], repeatRows=1)
|
| 537 |
+
table.setStyle(styles)
|
| 538 |
+
story.append(table)
|
| 539 |
+
if any(r["live"] and r["dryRun"] for r in rows):
|
| 540 |
+
story.append(Spacer(1, 3))
|
| 541 |
+
story.append(Paragraph(
|
| 542 |
+
"DRY-RUN entries were produced by the deterministic CI stub "
|
| 543 |
+
"backend, not a live model β they demonstrate the exam "
|
| 544 |
+
"pipeline and are not real certifications.", st["note"]))
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
def _skills_table(agent: dict, story: list, st: dict) -> None:
|
| 548 |
+
_section("Skills β measured mastery", story, st)
|
| 549 |
+
skills = agent.get("skills") or []
|
| 550 |
+
if not skills:
|
| 551 |
+
story.append(Paragraph("No skills on record.", st["note"]))
|
| 552 |
+
return
|
| 553 |
+
header_lbl = ParagraphStyle("hdr2", parent=st["labelMono"],
|
| 554 |
+
textColor=GOLD)
|
| 555 |
+
data = [[Paragraph(h, header_lbl)
|
| 556 |
+
for h in ("SKILL", "LAYER", "MASTERY", "SCORE", "STATUS")]]
|
| 557 |
+
for s in skills:
|
| 558 |
+
status = s.get("status", "untested")
|
| 559 |
+
color = _STATUS_COLORS.get(status, GREY)
|
| 560 |
+
mastery = float(s.get("mastery") or 0.0)
|
| 561 |
+
data.append([
|
| 562 |
+
Paragraph(_esc(s.get("name")), st["cell"]),
|
| 563 |
+
Paragraph(
|
| 564 |
+
f'<font name="{_MONO}" '
|
| 565 |
+
f'color="{LAYER_COLORS.get(s.get("layer"), INK).hexval()}">'
|
| 566 |
+
f'L{_esc(s.get("layer"))}</font>', st["cell"]),
|
| 567 |
+
_MasteryBar(mastery, status),
|
| 568 |
+
Paragraph(f"{mastery:.2f}", st["cellMono"]),
|
| 569 |
+
Paragraph(
|
| 570 |
+
f'<font name="{_MONO}" color="{color.hexval()}">'
|
| 571 |
+
f'{_esc(status)}</font>', st["cellMono"]),
|
| 572 |
+
])
|
| 573 |
+
w = PAGE_W - 2 * MARGIN
|
| 574 |
+
table = Table(data, colWidths=[w * 0.36, w * 0.09, w * 0.27, w * 0.10,
|
| 575 |
+
w * 0.18], repeatRows=1)
|
| 576 |
+
table.setStyle(TableStyle([
|
| 577 |
+
("BACKGROUND", (0, 0), (-1, 0), NAVY),
|
| 578 |
+
("LINEBELOW", (0, 0), (-1, -1), 0.4, LINE),
|
| 579 |
+
("TOPPADDING", (0, 0), (-1, -1), 3.5),
|
| 580 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 3.5),
|
| 581 |
+
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
| 582 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
| 583 |
+
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
| 584 |
+
]))
|
| 585 |
+
story.append(table)
|
| 586 |
+
story.append(Spacer(1, 3))
|
| 587 |
+
story.append(Paragraph(
|
| 588 |
+
"βuntestedβ means exactly that: the skill has not been "
|
| 589 |
+
"measured yet. ATP shows uncertainty rather than hiding it.",
|
| 590 |
+
st["note"]))
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
def _failure_table(agent: dict, story: list, st: dict) -> None:
|
| 594 |
+
_section("Failure transparency β where this agent breaks", story, st)
|
| 595 |
+
story.append(Paragraph(
|
| 596 |
+
"The ATP standard publishes failure modes next to the passes. This "
|
| 597 |
+
"section is part of the official record β a badge tells you not "
|
| 598 |
+
"only what the agent can do, but concretely where it will fail.",
|
| 599 |
+
st["note"]))
|
| 600 |
+
story.append(Spacer(1, 4))
|
| 601 |
+
failures = agent.get("failures") or []
|
| 602 |
+
if not failures:
|
| 603 |
+
story.append(Paragraph(
|
| 604 |
+
"No failure modes recorded yet β usually a sign the agent "
|
| 605 |
+
"is too new to have been stress-tested, not that it is perfect.",
|
| 606 |
+
st["note"]))
|
| 607 |
+
return
|
| 608 |
+
header_lbl = ParagraphStyle("hdr3", parent=st["labelMono"],
|
| 609 |
+
textColor=GOLD)
|
| 610 |
+
data = [[Paragraph(h, header_lbl)
|
| 611 |
+
for h in ("FAILURE MODE", "RATE", "BOUNDARY β WHEN NOT TO "
|
| 612 |
+
"TRUST IT")]]
|
| 613 |
+
for f in failures:
|
| 614 |
+
rate = float(f.get("rate") or 0.0)
|
| 615 |
+
data.append([
|
| 616 |
+
Paragraph(f"<b>{_esc(f.get('mode'))}</b>", st["cell"]),
|
| 617 |
+
Paragraph(
|
| 618 |
+
f'<font name="{_MONO_B}" color="{CORAL.hexval()}">'
|
| 619 |
+
f'{rate:.0%}</font>', st["cellMono"]),
|
| 620 |
+
Paragraph(_esc(f.get("boundary")), st["cell"]),
|
| 621 |
+
])
|
| 622 |
+
w = PAGE_W - 2 * MARGIN
|
| 623 |
+
table = Table(data, colWidths=[w * 0.27, w * 0.09, w * 0.64],
|
| 624 |
+
repeatRows=1)
|
| 625 |
+
table.setStyle(TableStyle([
|
| 626 |
+
("BACKGROUND", (0, 0), (-1, 0), NAVY),
|
| 627 |
+
("LINEBELOW", (0, 0), (-1, -1), 0.4, LINE),
|
| 628 |
+
("TOPPADDING", (0, 0), (-1, -1), 4),
|
| 629 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
| 630 |
+
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
| 631 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
| 632 |
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 633 |
+
]))
|
| 634 |
+
story.append(table)
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
# ββ Public builder ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 638 |
+
|
| 639 |
+
def build_report_card_pdf(agent_id: str,
|
| 640 |
+
org_id: str = tenant_db.DEFAULT_ORG) -> bytes:
|
| 641 |
+
"""Render the official report-card PDF for `agent_id` β PDF bytes.
|
| 642 |
+
|
| 643 |
+
Seed profile + prose from atp/store.get_data(); live awards/evidence for
|
| 644 |
+
the caller's org via atp/tenant_db.py. Raises UnknownAgentError when the
|
| 645 |
+
agent is not in the seed roster.
|
| 646 |
+
"""
|
| 647 |
+
data = store.get_data()
|
| 648 |
+
agent = _find_agent(data, agent_id)
|
| 649 |
+
|
| 650 |
+
cert_rows = _seed_cert_rows(data, agent_id)
|
| 651 |
+
cert_rows += _live_cert_rows(data, agent_id, org_id)
|
| 652 |
+
|
| 653 |
+
seed_evidence = sum(
|
| 654 |
+
1 for e in data.get("EVIDENCE", []) if e.get("agentId") == agent_id)
|
| 655 |
+
n_evidence = seed_evidence + _live_evidence_count(agent_id, org_id)
|
| 656 |
+
|
| 657 |
+
generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
| 658 |
+
citation = _citation(data, agent)
|
| 659 |
+
st = _styles()
|
| 660 |
+
|
| 661 |
+
buf = io.BytesIO()
|
| 662 |
+
doc = SimpleDocTemplate(
|
| 663 |
+
buf, pagesize=A4,
|
| 664 |
+
leftMargin=MARGIN, rightMargin=MARGIN,
|
| 665 |
+
topMargin=BAND_H + 22, bottomMargin=FOOTER_H,
|
| 666 |
+
title=f"ATP Official Report Card β {agent.get('name', agent_id)}",
|
| 667 |
+
author="ATP β Agentic Training Platform")
|
| 668 |
+
|
| 669 |
+
story: list = []
|
| 670 |
+
_identity_block(agent, story, st)
|
| 671 |
+
_report_card_block(agent, story, st)
|
| 672 |
+
_cert_table(cert_rows, story, st)
|
| 673 |
+
_skills_table(agent, story, st)
|
| 674 |
+
_failure_table(agent, story, st)
|
| 675 |
+
|
| 676 |
+
first_page, later_pages = _make_page_decorators(
|
| 677 |
+
agent, citation, n_evidence, generated_at)
|
| 678 |
+
doc.build(story, onFirstPage=first_page, onLaterPages=later_pages)
|
| 679 |
+
return buf.getvalue()
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
# ββ CLI (dev convenience) βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 683 |
+
|
| 684 |
+
if __name__ == "__main__":
|
| 685 |
+
import argparse
|
| 686 |
+
|
| 687 |
+
parser = argparse.ArgumentParser(
|
| 688 |
+
description="Render an agent's official report-card PDF (Phase 6).")
|
| 689 |
+
parser.add_argument("agent_id", help="e.g. atlas-ops")
|
| 690 |
+
parser.add_argument("out", help="output .pdf path")
|
| 691 |
+
parser.add_argument("--org", default=tenant_db.DEFAULT_ORG)
|
| 692 |
+
args = parser.parse_args()
|
| 693 |
+
|
| 694 |
+
pdf = build_report_card_pdf(args.agent_id, org_id=args.org)
|
| 695 |
+
with open(args.out, "wb") as fh:
|
| 696 |
+
fh.write(pdf)
|
| 697 |
+
print(f"wrote {len(pdf)} bytes to {args.out}")
|
atp/seed.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
atp/signing.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evidence signing β HMAC-SHA256 over canonical payloads, per-org hash chain.
|
| 3 |
+
|
| 4 |
+
Phase 3 (docs/HARDENING.md): every evidence row written by atp/exams.py is
|
| 5 |
+
signed so the append-only tables (migration 002 triggers) become
|
| 6 |
+
tamper-EVIDENT, not just append-only:
|
| 7 |
+
|
| 8 |
+
sig = HMAC-SHA256(key, prev_hash || canonical(payload))
|
| 9 |
+
prev_hash = sig of the previous atp_evidence row for the SAME org
|
| 10 |
+
('genesis' for an org's first row)
|
| 11 |
+
|
| 12 |
+
Editing any historical payload breaks that row's sig; deleting or reordering
|
| 13 |
+
rows breaks the prev_hash linkage β verify_chain() catches both.
|
| 14 |
+
|
| 15 |
+
Key resolution (fail-loud, mirrors api/auth.py's posture):
|
| 16 |
+
|
| 17 |
+
EVIDENCE_SIGNING_KEY required whenever signing/verification happens
|
| 18 |
+
with auth enabled β RuntimeError if unset.
|
| 19 |
+
BU_AUTH_DISABLED=1 (dev) fallback: derive a stable key from
|
| 20 |
+
BU_AUTH_SECRET when set, else a process-
|
| 21 |
+
ephemeral random key with a loud warning
|
| 22 |
+
(sigs then don't verify across restarts).
|
| 23 |
+
|
| 24 |
+
Public:
|
| 25 |
+
canonical(payload) -> bytes stable JSON bytes (sorted keys,
|
| 26 |
+
no whitespace drift)
|
| 27 |
+
sign(payload, prev_hash) -> str hex HMAC-SHA256
|
| 28 |
+
verify_row(row) -> bool recompute + constant-time compare
|
| 29 |
+
last_sig(org_id) -> str chain head for an org ('genesis'
|
| 30 |
+
when the org has no evidence yet)
|
| 31 |
+
verify_chain(org_id) -> dict {ok, length, first_bad} walking
|
| 32 |
+
atp_evidence in id order (org-scoped
|
| 33 |
+
via atp/tenant_db.py)
|
| 34 |
+
GENESIS the chain-root sentinel string
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
from __future__ import annotations
|
| 38 |
+
|
| 39 |
+
import hashlib
|
| 40 |
+
import hmac
|
| 41 |
+
import json
|
| 42 |
+
import os
|
| 43 |
+
import secrets
|
| 44 |
+
import sys
|
| 45 |
+
import threading
|
| 46 |
+
|
| 47 |
+
from sqlalchemy import text
|
| 48 |
+
|
| 49 |
+
from atp import db, tenant_db
|
| 50 |
+
|
| 51 |
+
#: prev_hash sentinel for the first evidence row of an org's chain.
|
| 52 |
+
GENESIS = "genesis"
|
| 53 |
+
|
| 54 |
+
#: Domain-separation tag for the BU_AUTH_SECRET-derived dev key.
|
| 55 |
+
_DERIVE_TAG = b"bu-evidence-signing-v1:"
|
| 56 |
+
|
| 57 |
+
_EPHEMERAL_KEY: bytes | None = None
|
| 58 |
+
_WARNED = False
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class SigningKeyError(RuntimeError):
|
| 62 |
+
"""Signing was requested but no usable key is configured."""
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _signing_key() -> bytes:
|
| 66 |
+
"""Resolve the HMAC key (see module docstring for the precedence)."""
|
| 67 |
+
global _EPHEMERAL_KEY, _WARNED
|
| 68 |
+
key = os.environ.get("EVIDENCE_SIGNING_KEY", "").strip()
|
| 69 |
+
if key:
|
| 70 |
+
return key.encode("utf-8")
|
| 71 |
+
|
| 72 |
+
if os.environ.get("BU_AUTH_DISABLED", "") == "1":
|
| 73 |
+
# Dev mode: never block the loop, but never sign with a hardcoded
|
| 74 |
+
# default either β derive from the auth secret when one exists.
|
| 75 |
+
auth_secret = os.environ.get("BU_AUTH_SECRET", "").strip()
|
| 76 |
+
if auth_secret:
|
| 77 |
+
return hashlib.sha256(_DERIVE_TAG + auth_secret.encode("utf-8")).digest()
|
| 78 |
+
if _EPHEMERAL_KEY is None:
|
| 79 |
+
_EPHEMERAL_KEY = secrets.token_bytes(32)
|
| 80 |
+
if not _WARNED:
|
| 81 |
+
_WARNED = True
|
| 82 |
+
print(
|
| 83 |
+
"WARNING: EVIDENCE_SIGNING_KEY unset (BU_AUTH_DISABLED=1 dev "
|
| 84 |
+
"mode) β signing evidence with a process-EPHEMERAL key. "
|
| 85 |
+
"Signatures will NOT verify after a restart. Set "
|
| 86 |
+
"EVIDENCE_SIGNING_KEY (see .env.example).",
|
| 87 |
+
file=sys.stderr,
|
| 88 |
+
)
|
| 89 |
+
return _EPHEMERAL_KEY
|
| 90 |
+
|
| 91 |
+
raise SigningKeyError(
|
| 92 |
+
"EVIDENCE_SIGNING_KEY is not set. Evidence signing is mandatory when "
|
| 93 |
+
"auth is enabled (docs/HARDENING.md Phase 3) β generate one with "
|
| 94 |
+
"python3 -c \"import secrets; print(secrets.token_hex(32))\" and "
|
| 95 |
+
"export it, or export BU_AUTH_DISABLED=1 for local dev only."
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ββ Canonicalisation + signing βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 100 |
+
|
| 101 |
+
def canonical(payload: dict) -> bytes:
|
| 102 |
+
"""Stable byte serialisation of a payload dict.
|
| 103 |
+
|
| 104 |
+
json.dumps with sorted keys, compact separators, and ensure_ascii=True:
|
| 105 |
+
key order, whitespace, and non-ASCII escaping can never drift between the
|
| 106 |
+
signer and a later verifier (including across Python versions).
|
| 107 |
+
"""
|
| 108 |
+
return json.dumps(
|
| 109 |
+
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
| 110 |
+
).encode("utf-8")
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def sign(payload: dict, prev_hash: str) -> str:
|
| 114 |
+
"""Hex HMAC-SHA256 over prev_hash || canonical(payload)."""
|
| 115 |
+
msg = (prev_hash or GENESIS).encode("utf-8") + canonical(payload)
|
| 116 |
+
return hmac.new(_signing_key(), msg, hashlib.sha256).hexdigest()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def verify_row(row: dict) -> bool:
|
| 120 |
+
"""Recompute a row's sig from its stored payload + prev_hash.
|
| 121 |
+
|
| 122 |
+
`row` needs keys payload (JSON text or dict), sig, prev_hash β i.e. an
|
| 123 |
+
atp_evidence row as returned by the DAL. Constant-time compare.
|
| 124 |
+
"""
|
| 125 |
+
payload = row.get("payload")
|
| 126 |
+
if isinstance(payload, (str, bytes)):
|
| 127 |
+
try:
|
| 128 |
+
payload = json.loads(payload)
|
| 129 |
+
except (ValueError, TypeError):
|
| 130 |
+
return False
|
| 131 |
+
if not isinstance(payload, dict):
|
| 132 |
+
return False
|
| 133 |
+
expected = sign(payload, row.get("prev_hash") or GENESIS)
|
| 134 |
+
return hmac.compare_digest(expected, str(row.get("sig") or ""))
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ββ Chain access (org-scoped, via the tenant DAL only) ββββββββββββββββββββββ
|
| 138 |
+
|
| 139 |
+
def last_sig(org_id: str) -> str:
|
| 140 |
+
"""Current chain head for an org: sig of its newest evidence row,
|
| 141 |
+
or GENESIS when the org has no evidence yet."""
|
| 142 |
+
rows = tenant_db.scoped_query(
|
| 143 |
+
org_id,
|
| 144 |
+
"SELECT sig FROM atp_evidence WHERE org_id = :org "
|
| 145 |
+
"ORDER BY id DESC LIMIT 1",
|
| 146 |
+
{"org": org_id},
|
| 147 |
+
)
|
| 148 |
+
return rows[0]["sig"] if rows and rows[0].get("sig") else GENESIS
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ββ Serialized chain append (the ONLY sanctioned way to write evidence) βββββ
|
| 152 |
+
|
| 153 |
+
_CHAIN_LOCKS: dict[str, threading.Lock] = {}
|
| 154 |
+
_CHAIN_LOCKS_GUARD = threading.Lock()
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _chain_lock(org_id: str) -> threading.Lock:
|
| 158 |
+
with _CHAIN_LOCKS_GUARD:
|
| 159 |
+
return _CHAIN_LOCKS.setdefault(org_id, threading.Lock())
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def append_chain(org_id: str, records: list[dict]) -> list[tuple[int, str]]:
|
| 163 |
+
"""Append `records` to an org's evidence chain atomically, in order.
|
| 164 |
+
|
| 165 |
+
records: [{ts, agent_id, cert_id, kind, payload(dict)}, ...]
|
| 166 |
+
returns: [(row_id, sig), ...] in the same order.
|
| 167 |
+
|
| 168 |
+
Why this exists: the chain head (prev_hash) must be read and written
|
| 169 |
+
under mutual exclusion or two concurrent exam runs interleave and
|
| 170 |
+
permanently fork the ledger. Serialization is layered:
|
| 171 |
+
- per-org threading.Lock β serializes writers within one process
|
| 172 |
+
(the jobs pool runs multiple worker threads);
|
| 173 |
+
- Postgres: pg_advisory_xact_lock(hashtext(org)) inside the same
|
| 174 |
+
transaction β serializes across processes AND instances;
|
| 175 |
+
- SQLite: single-file DB, and multi-process SQLite is already
|
| 176 |
+
unsupported (Dockerfile: WORKERS>1 requires Postgres).
|
| 177 |
+
The whole batch is one transaction, so an exam's evidence lands
|
| 178 |
+
all-or-nothing β no partially signed runs on crash.
|
| 179 |
+
"""
|
| 180 |
+
out: list[tuple[int, str]] = []
|
| 181 |
+
with _chain_lock(org_id):
|
| 182 |
+
with tenant_db.scoped_txn(org_id) as conn:
|
| 183 |
+
if db.is_postgres():
|
| 184 |
+
conn.execute(
|
| 185 |
+
text("SELECT pg_advisory_xact_lock(hashtext(:org))"),
|
| 186 |
+
{"org": org_id},
|
| 187 |
+
)
|
| 188 |
+
row = conn.execute(
|
| 189 |
+
text("SELECT sig FROM atp_evidence WHERE org_id = :org "
|
| 190 |
+
"ORDER BY id DESC LIMIT 1"),
|
| 191 |
+
{"org": org_id},
|
| 192 |
+
).first()
|
| 193 |
+
prev = row[0] if row and row[0] else GENESIS
|
| 194 |
+
for rec in records:
|
| 195 |
+
sig = sign(rec["payload"], prev)
|
| 196 |
+
r = conn.execute(
|
| 197 |
+
text("INSERT INTO atp_evidence "
|
| 198 |
+
" (ts, agent_id, cert_id, kind, payload, sig, "
|
| 199 |
+
" prev_hash, org_id) "
|
| 200 |
+
"VALUES (:ts, :agent_id, :cert_id, :kind, :payload, "
|
| 201 |
+
" :sig, :prev_hash, :org) "
|
| 202 |
+
"RETURNING id"),
|
| 203 |
+
{
|
| 204 |
+
"ts": rec["ts"],
|
| 205 |
+
"agent_id": rec["agent_id"],
|
| 206 |
+
"cert_id": rec["cert_id"],
|
| 207 |
+
"kind": rec["kind"],
|
| 208 |
+
"payload": canonical(rec["payload"]).decode("utf-8"),
|
| 209 |
+
"sig": sig,
|
| 210 |
+
"prev_hash": prev,
|
| 211 |
+
"org": org_id,
|
| 212 |
+
},
|
| 213 |
+
).first()
|
| 214 |
+
out.append((r[0], sig))
|
| 215 |
+
prev = sig
|
| 216 |
+
return out
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def verify_chain(org_id: str) -> dict:
|
| 220 |
+
"""Walk an org's whole evidence chain in id order.
|
| 221 |
+
|
| 222 |
+
Returns {ok: bool, length: int, first_bad: int | None} where first_bad is
|
| 223 |
+
the id of the first row whose sig doesn't verify OR whose prev_hash
|
| 224 |
+
doesn't equal the previous row's sig (GENESIS for the first row).
|
| 225 |
+
"""
|
| 226 |
+
rows = tenant_db.scoped_query(
|
| 227 |
+
org_id,
|
| 228 |
+
"SELECT id, payload, sig, prev_hash FROM atp_evidence "
|
| 229 |
+
"WHERE org_id = :org ORDER BY id ASC",
|
| 230 |
+
{"org": org_id},
|
| 231 |
+
)
|
| 232 |
+
prev = GENESIS
|
| 233 |
+
for row in rows:
|
| 234 |
+
if (row.get("prev_hash") or GENESIS) != prev or not verify_row(row):
|
| 235 |
+
return {"ok": False, "length": len(rows), "first_bad": row["id"]}
|
| 236 |
+
prev = row["sig"]
|
| 237 |
+
return {"ok": True, "length": len(rows), "first_bad": None}
|
atp/store.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ATP store β seed access plus the two append-only logs.
|
| 3 |
+
|
| 4 |
+
get_data() β the full ATP_DATA dict (seed, cached)
|
| 5 |
+
append_hitl(event) -> event β enrich with weightedDelta + ts + auditId,
|
| 6 |
+
insert into the atp_hitl table
|
| 7 |
+
read_hitl(limit=100) β HITL log, latest first
|
| 8 |
+
append_request(payload) -> req β compute status via the compose matching
|
| 9 |
+
rule, build pipeline stages, insert into
|
| 10 |
+
the atp_requests table
|
| 11 |
+
read_requests(limit=100) β request log, latest first
|
| 12 |
+
|
| 13 |
+
Phase 1 (docs/HARDENING.md): both logs live in DAL tables (atp_hitl /
|
| 14 |
+
atp_requests β migration 002; append-only enforced by DB triggers) instead of
|
| 15 |
+
JSONL on ephemeral disk. Public shapes are unchanged: the same camelCase dict
|
| 16 |
+
keys (snake_case columns mapped back, JSON-text columns decoded) and read_*
|
| 17 |
+
still return latest-first lists. Legacy data/sessions/atp_hitl.jsonl /
|
| 18 |
+
atp_requests.jsonl are imported once into their empty table on first access,
|
| 19 |
+
then renamed to *.jsonl.imported (idempotent; the rename doubles as the
|
| 20 |
+
cross-process claim so rows land exactly once).
|
| 21 |
+
|
| 22 |
+
Phase 2 (docs/TENANCY.md): the four log functions take an OPTIONAL
|
| 23 |
+
`org_id` keyword (default `'org-demo'`, the bootstrap org every pre-tenancy
|
| 24 |
+
row belongs to β migration 005's column default). All table access is routed
|
| 25 |
+
through atp/tenant_db.py: writes stamp org_id, reads add `WHERE org_id =
|
| 26 |
+
:org`, and on Postgres every statement runs under `SET LOCAL app.org_id`
|
| 27 |
+
(RLS). Existing callers that don't pass org_id keep the exact pre-tenancy
|
| 28 |
+
behaviour against the demo org β signatures and returned shapes unchanged
|
| 29 |
+
(returned dicts do NOT grow an org key). Legacy JSONL imports land in
|
| 30 |
+
'org-demo'.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import json
|
| 36 |
+
import threading
|
| 37 |
+
import time
|
| 38 |
+
import uuid
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
|
| 41 |
+
from sqlalchemy import inspect as sa_inspect
|
| 42 |
+
|
| 43 |
+
from . import tenant_db
|
| 44 |
+
from .seed import build_seed
|
| 45 |
+
from .tenant_db import DEFAULT_ORG
|
| 46 |
+
|
| 47 |
+
PROJECT_ROOT = Path(__file__).parent.parent
|
| 48 |
+
SESSIONS_DIR = PROJECT_ROOT / "data" / "sessions"
|
| 49 |
+
LEGACY_HITL_LOG = SESSIONS_DIR / "atp_hitl.jsonl" # pre-Phase-1 log
|
| 50 |
+
LEGACY_REQUESTS_LOG = SESSIONS_DIR / "atp_requests.jsonl" # pre-Phase-1 log
|
| 51 |
+
|
| 52 |
+
_DATA: dict | None = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ββ Seed access βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
+
|
| 57 |
+
def get_data() -> dict:
|
| 58 |
+
"""Return the full ATP_DATA payload. Cached β seed is pure data."""
|
| 59 |
+
global _DATA
|
| 60 |
+
if _DATA is None:
|
| 61 |
+
_DATA = build_seed()
|
| 62 |
+
return _DATA
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ββ HITL reward log (Β§1 formula) ββββββββββββββββββββββββββββββββββββββββββββ
|
| 66 |
+
|
| 67 |
+
def append_hitl(event: dict, org_id: str = DEFAULT_ORG) -> dict:
|
| 68 |
+
"""Enrich a HITL reward signal and append it to the audit table.
|
| 69 |
+
|
| 70 |
+
Input: {agentId, layer, signal (+1|-1), reason, rater}
|
| 71 |
+
Adds: ts, weightedDelta, auditId
|
| 72 |
+
Formula (Β§1): weightedDelta = signal Γ 0.2 Γ (weight[layer] / 0.26)
|
| 73 |
+
The row is stamped with `org_id` (docs/TENANCY.md); the returned dict
|
| 74 |
+
keeps its pre-tenancy shape.
|
| 75 |
+
"""
|
| 76 |
+
data = get_data()
|
| 77 |
+
weights = data["RL"]["rewardWeights"]
|
| 78 |
+
|
| 79 |
+
layer = int(event.get("layer", 1))
|
| 80 |
+
signal = int(event.get("signal", 0))
|
| 81 |
+
weight = float(weights.get(f"L{layer}", 0.0))
|
| 82 |
+
weighted_delta = round(signal * 0.2 * (weight / 0.26), 5)
|
| 83 |
+
|
| 84 |
+
enriched = {
|
| 85 |
+
"ts": event.get("ts") or _now(),
|
| 86 |
+
"rater": event.get("rater", "anonymous"),
|
| 87 |
+
"agentId": event.get("agentId"),
|
| 88 |
+
"layer": layer,
|
| 89 |
+
"signal": signal,
|
| 90 |
+
"weightedDelta": weighted_delta,
|
| 91 |
+
"reason": event.get("reason", ""),
|
| 92 |
+
"auditId": f"audit-{uuid.uuid4().hex[:10]}",
|
| 93 |
+
}
|
| 94 |
+
_ensure_ready("atp_hitl", LEGACY_HITL_LOG, _HITL_COLS)
|
| 95 |
+
tenant_db.insert_scoped("atp_hitl", _to_row(enriched, _HITL_COLS), org_id)
|
| 96 |
+
return enriched
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def read_hitl(limit: int = 100, org_id: str = DEFAULT_ORG) -> list[dict]:
|
| 100 |
+
"""Return `org_id`'s HITL log, latest first (capped at `limit`)."""
|
| 101 |
+
_ensure_ready("atp_hitl", LEGACY_HITL_LOG, _HITL_COLS)
|
| 102 |
+
return _read_latest("atp_hitl", _HITL_COLS, limit, org_id)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ββ Compose / train-to-order requests (Β§7.2) ββββββββββββββββββββββββββββββββ
|
| 106 |
+
|
| 107 |
+
def append_request(payload: dict, org_id: str = DEFAULT_ORG) -> dict:
|
| 108 |
+
"""Create an expert-composition request from a wizard submission.
|
| 109 |
+
|
| 110 |
+
Input: {major, specialty, badgeIds, packId}
|
| 111 |
+
Computes status against the seed agents with the same matching rule as
|
| 112 |
+
the client-side `matchExperts`, builds the pipeline stages, appends to
|
| 113 |
+
the request table (stamped with `org_id`), and returns the request.
|
| 114 |
+
"""
|
| 115 |
+
data = get_data()
|
| 116 |
+
badge_ids = list(payload.get("badgeIds") or [])
|
| 117 |
+
pack_id = payload.get("packId")
|
| 118 |
+
major = str(payload.get("major") or "")
|
| 119 |
+
|
| 120 |
+
status, matched_agent_id, gap = _match(
|
| 121 |
+
data, major, badge_ids, pack_id,
|
| 122 |
+
# Wizard sends the concentration id as `specialty` (Β§7.2 payload);
|
| 123 |
+
# accept an explicit concentrationId too for parity with matchExperts.
|
| 124 |
+
payload.get("concentrationId") or payload.get("specialty") or None)
|
| 125 |
+
pipeline = _pipeline_for(status)
|
| 126 |
+
|
| 127 |
+
req = {
|
| 128 |
+
"id": f"req-{uuid.uuid4().hex[:8]}",
|
| 129 |
+
"ts": _now(),
|
| 130 |
+
"major": major,
|
| 131 |
+
"specialty": payload.get("specialty", ""),
|
| 132 |
+
"badgeIds": badge_ids,
|
| 133 |
+
"packId": pack_id,
|
| 134 |
+
"layers": list(payload.get("layers") or []),
|
| 135 |
+
"corpora": list(payload.get("corpora") or []),
|
| 136 |
+
"status": status,
|
| 137 |
+
"matchedAgentId": matched_agent_id,
|
| 138 |
+
"gap": gap,
|
| 139 |
+
"pipeline": pipeline,
|
| 140 |
+
"resultAgentId": None,
|
| 141 |
+
}
|
| 142 |
+
_ensure_ready("atp_requests", LEGACY_REQUESTS_LOG, _REQUEST_COLS)
|
| 143 |
+
tenant_db.insert_scoped("atp_requests", _to_row(req, _REQUEST_COLS), org_id)
|
| 144 |
+
return req
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def read_requests(limit: int = 100, org_id: str = DEFAULT_ORG) -> list[dict]:
|
| 148 |
+
"""Return `org_id`'s request log, latest first (capped at `limit`)."""
|
| 149 |
+
_ensure_ready("atp_requests", LEGACY_REQUESTS_LOG, _REQUEST_COLS)
|
| 150 |
+
return _read_latest("atp_requests", _REQUEST_COLS, limit, org_id)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ββ Matching rule (mirrors atp_data.js matchExperts) ββββββββββββββββββββββββ
|
| 154 |
+
|
| 155 |
+
def _match(data: dict, major: str, badge_ids: list[str], pack_id, concentration_id=None):
|
| 156 |
+
"""Return (status, matchedAgentId, gap) for a composition request.
|
| 157 |
+
|
| 158 |
+
Mirrors the client-side `matchExperts`: competencies come from the named
|
| 159 |
+
concentration if `concentration_id` is given, otherwise the union of every
|
| 160 |
+
concentration in the major.
|
| 161 |
+
|
| 162 |
+
exact = agent whose skills cover all of those competencies AND holds every
|
| 163 |
+
requested badge AND (packId ? bound to pack policies : true)
|
| 164 |
+
partial β gap analysis; else training (commission).
|
| 165 |
+
"""
|
| 166 |
+
# Resolve the concentration tolerantly: by id ('18-C') or display name
|
| 167 |
+
# ('Applied Mathematics', case-insensitive prefix). Unresolvable text
|
| 168 |
+
# (free-form specialty) falls back to the whole-major union rather than
|
| 169 |
+
# an empty competency set.
|
| 170 |
+
want = str(concentration_id).strip().lower() if concentration_id else ""
|
| 171 |
+
resolved = None
|
| 172 |
+
majors = [m for m in data["COMPOSE"]["majors"] if str(m["course"]) == str(major)]
|
| 173 |
+
if want:
|
| 174 |
+
for m in majors:
|
| 175 |
+
for cc in m["concentrations"]:
|
| 176 |
+
nm = cc.get("name", "").lower()
|
| 177 |
+
if cc["id"].lower() == want or nm == want or (nm and want.startswith(nm)):
|
| 178 |
+
resolved = cc["id"]
|
| 179 |
+
break
|
| 180 |
+
if resolved:
|
| 181 |
+
break
|
| 182 |
+
|
| 183 |
+
comps: list[str] = []
|
| 184 |
+
for m in majors:
|
| 185 |
+
for cc in m["concentrations"]:
|
| 186 |
+
if resolved and cc["id"] != resolved:
|
| 187 |
+
continue
|
| 188 |
+
comps.extend(cc.get("competencies", []))
|
| 189 |
+
comps = list(dict.fromkeys(comps)) # de-dup, keep order
|
| 190 |
+
|
| 191 |
+
pack_policies: list[str] = []
|
| 192 |
+
if pack_id:
|
| 193 |
+
pk = next((p for p in data["COMPOSE"]["packs"] if p["id"] == pack_id), None)
|
| 194 |
+
if pk:
|
| 195 |
+
pack_policies = list(pk.get("policyIds", []))
|
| 196 |
+
|
| 197 |
+
exact: list[str] = []
|
| 198 |
+
partials: list[dict] = []
|
| 199 |
+
for a in data["AGENTS"]:
|
| 200 |
+
if a.get("status") == "revoked":
|
| 201 |
+
continue
|
| 202 |
+
skill_names = {s["name"] for s in a.get("skills", [])}
|
| 203 |
+
cert_ids = set(a.get("certIds", []))
|
| 204 |
+
policy_ids = set(a.get("policyIds", []))
|
| 205 |
+
|
| 206 |
+
covered = [c for c in comps if c in skill_names]
|
| 207 |
+
missing = [c for c in comps if c not in skill_names]
|
| 208 |
+
holds_badges = all(b in cert_ids for b in badge_ids)
|
| 209 |
+
bound_pack = (not pack_id) or all(p in policy_ids for p in pack_policies)
|
| 210 |
+
|
| 211 |
+
if comps and not missing and holds_badges and bound_pack:
|
| 212 |
+
exact.append(a["id"])
|
| 213 |
+
elif covered or any(b in cert_ids for b in badge_ids):
|
| 214 |
+
partials.append({"agentId": a["id"], "covered": covered, "missing": missing})
|
| 215 |
+
|
| 216 |
+
if exact:
|
| 217 |
+
return "matched", exact[0], None
|
| 218 |
+
|
| 219 |
+
if partials:
|
| 220 |
+
partials.sort(key=lambda p: len(p["covered"]), reverse=True)
|
| 221 |
+
covered = sorted({c for p in partials for c in p["covered"]})
|
| 222 |
+
missing = [c for c in comps if c not in covered]
|
| 223 |
+
gap = {"covered": covered, "missing": missing,
|
| 224 |
+
"coveringAgentIds": [p["agentId"] for p in partials[:3]]}
|
| 225 |
+
return "gap", None, gap
|
| 226 |
+
|
| 227 |
+
return "training", None, None
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _pipeline_for(status: str) -> list[dict]:
|
| 231 |
+
stages = ["requested", "corpus-mapped", "fine-tuning", "certifying", "listed"]
|
| 232 |
+
if status == "matched":
|
| 233 |
+
done = {s: True for s in stages} # existing asset already covers it
|
| 234 |
+
elif status == "gap":
|
| 235 |
+
done = {"requested": True}
|
| 236 |
+
else: # training / commission
|
| 237 |
+
done = {"requested": True, "corpus-mapped": True}
|
| 238 |
+
now = _now()
|
| 239 |
+
return [{"stage": s, "ts": now if done.get(s) else None, "done": bool(done.get(s))}
|
| 240 |
+
for s in stages]
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# ββ DAL access (atp/tenant_db.py β migrations 002 + 005) ββββββββββββββββββββ
|
| 244 |
+
|
| 245 |
+
# dict key β table column; declaration order = INSERT/SELECT column order.
|
| 246 |
+
_HITL_COLS = {
|
| 247 |
+
"ts": "ts",
|
| 248 |
+
"rater": "rater",
|
| 249 |
+
"agentId": "agent_id",
|
| 250 |
+
"layer": "layer",
|
| 251 |
+
"signal": "signal",
|
| 252 |
+
"weightedDelta": "weighted_delta",
|
| 253 |
+
"reason": "reason",
|
| 254 |
+
"auditId": "audit_id",
|
| 255 |
+
}
|
| 256 |
+
_REQUEST_COLS = {
|
| 257 |
+
"id": "id",
|
| 258 |
+
"ts": "ts",
|
| 259 |
+
"major": "major",
|
| 260 |
+
"specialty": "specialty",
|
| 261 |
+
"badgeIds": "badge_ids",
|
| 262 |
+
"packId": "pack_id",
|
| 263 |
+
"layers": "layers",
|
| 264 |
+
"corpora": "corpora",
|
| 265 |
+
"status": "status",
|
| 266 |
+
"matchedAgentId": "matched_agent_id",
|
| 267 |
+
"gap": "gap",
|
| 268 |
+
"pipeline": "pipeline",
|
| 269 |
+
"resultAgentId": "result_agent_id",
|
| 270 |
+
}
|
| 271 |
+
_JSON_COLS = {"badge_ids", "layers", "corpora", "gap", "pipeline"} # JSON text
|
| 272 |
+
|
| 273 |
+
_DB_LOCK = threading.Lock()
|
| 274 |
+
_DB_READY = False
|
| 275 |
+
_LEGACY_DONE: set[str] = set()
|
| 276 |
+
_ORDER: dict[str, str] = {}
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _engine():
|
| 280 |
+
"""Engine from the Phase-1 DAL. Imported lazily so this module (and
|
| 281 |
+
`import api.server`) stays importable before atp/db.py lands; first log
|
| 282 |
+
access also ensures migrations ran β the server runs them at startup too,
|
| 283 |
+
and the runner is idempotent, so a second call is a no-op."""
|
| 284 |
+
global _DB_READY
|
| 285 |
+
from atp import db # deferred: see docstring
|
| 286 |
+
|
| 287 |
+
if not _DB_READY:
|
| 288 |
+
with _DB_LOCK:
|
| 289 |
+
if not _DB_READY:
|
| 290 |
+
fn = (getattr(db, "ensure_migrations", None)
|
| 291 |
+
or getattr(db, "run_migrations", None)
|
| 292 |
+
or getattr(db, "migrate", None))
|
| 293 |
+
if callable(fn):
|
| 294 |
+
try:
|
| 295 |
+
fn()
|
| 296 |
+
except TypeError: # runner variant that takes the engine
|
| 297 |
+
fn(db.get_engine())
|
| 298 |
+
_DB_READY = True
|
| 299 |
+
return db.get_engine()
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _ensure_ready(table: str, legacy: Path, cols: dict) -> None:
|
| 303 |
+
"""Engine + migrations up, then the one-time legacy JSONL import."""
|
| 304 |
+
_engine()
|
| 305 |
+
_import_legacy_once(table, legacy, cols)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def _read_latest(table: str, cols: dict, limit: int, org_id: str) -> list[dict]:
|
| 309 |
+
sel = ", ".join(cols.values())
|
| 310 |
+
rows = tenant_db.scoped_query(
|
| 311 |
+
org_id,
|
| 312 |
+
f"SELECT {sel} FROM {table} WHERE org_id = :org"
|
| 313 |
+
f" ORDER BY {_order_by(table)} LIMIT :n",
|
| 314 |
+
{"org": org_id, "n": int(limit)})
|
| 315 |
+
return [_to_dict(r, cols) for r in rows]
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _order_by(table: str) -> str:
|
| 319 |
+
"""Newest-first ordering. Prefer the monotonic surrogate key from
|
| 320 |
+
migration 002 (`seq`), tolerate an integer `id` pk, then fall back to
|
| 321 |
+
sqlite's implicit rowid / the ts column so reads survive DDL drift."""
|
| 322 |
+
cached = _ORDER.get(table)
|
| 323 |
+
if cached:
|
| 324 |
+
return cached
|
| 325 |
+
engine = _engine()
|
| 326 |
+
info = {c["name"]: str(c["type"]).upper() for c in sa_inspect(engine).get_columns(table)}
|
| 327 |
+
if "seq" in info:
|
| 328 |
+
order = "seq DESC"
|
| 329 |
+
elif "INT" in info.get("id", ""):
|
| 330 |
+
order = "id DESC"
|
| 331 |
+
elif engine.dialect.name == "sqlite":
|
| 332 |
+
order = "rowid DESC"
|
| 333 |
+
else:
|
| 334 |
+
order = "ts DESC"
|
| 335 |
+
_ORDER[table] = order
|
| 336 |
+
return order
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def _to_row(rec: dict, cols: dict) -> dict:
|
| 340 |
+
"""camelCase log dict β snake_case column dict (JSON cols dumped)."""
|
| 341 |
+
row = {}
|
| 342 |
+
for key, col in cols.items():
|
| 343 |
+
v = rec.get(key)
|
| 344 |
+
if col in _JSON_COLS and v is not None:
|
| 345 |
+
v = json.dumps(v, default=str)
|
| 346 |
+
row[col] = v
|
| 347 |
+
return row
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def _to_dict(row: dict, cols: dict) -> dict:
|
| 351 |
+
"""DB row (scoped_query dict) β the exact dict shape the JSONL logs used
|
| 352 |
+
(camelCase keys β org_id is intentionally NOT part of the shape)."""
|
| 353 |
+
rec = {}
|
| 354 |
+
for key, col in cols.items():
|
| 355 |
+
v = row[col]
|
| 356 |
+
if col in _JSON_COLS and v is not None:
|
| 357 |
+
v = json.loads(v)
|
| 358 |
+
rec[key] = v
|
| 359 |
+
return rec
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def _import_legacy_once(table: str, legacy: Path, cols: dict) -> None:
|
| 363 |
+
"""One-time lazy migration of the pre-Phase-1 JSONL log.
|
| 364 |
+
|
| 365 |
+
If the legacy file exists and the table holds no 'org-demo' rows, rename
|
| 366 |
+
the file to *.jsonl.imported (the atomic rename is the cross-process
|
| 367 |
+
claim β a racing worker gets FileNotFoundError and skips) and import its
|
| 368 |
+
rows oldest-first so surrogate-key order matches insertion order.
|
| 369 |
+
Pre-tenancy rows belong to the bootstrap org, so imports are stamped
|
| 370 |
+
'org-demo' (docs/TENANCY.md leak surface #1). Idempotent: reruns see
|
| 371 |
+
either no file or existing rows and do nothing.
|
| 372 |
+
"""
|
| 373 |
+
if table in _LEGACY_DONE:
|
| 374 |
+
return
|
| 375 |
+
with _DB_LOCK:
|
| 376 |
+
if table in _LEGACY_DONE:
|
| 377 |
+
return
|
| 378 |
+
if legacy.exists():
|
| 379 |
+
count = tenant_db.scoped_query(
|
| 380 |
+
DEFAULT_ORG,
|
| 381 |
+
f"SELECT COUNT(*) AS n FROM {table} WHERE org_id = :org",
|
| 382 |
+
{"org": DEFAULT_ORG})[0]["n"]
|
| 383 |
+
if not count:
|
| 384 |
+
claimed = legacy.with_name(legacy.name + ".imported")
|
| 385 |
+
try:
|
| 386 |
+
legacy.rename(claimed)
|
| 387 |
+
except FileNotFoundError:
|
| 388 |
+
claimed = None # another worker claimed the import
|
| 389 |
+
if claimed:
|
| 390 |
+
for line in claimed.read_text().splitlines():
|
| 391 |
+
line = line.strip()
|
| 392 |
+
if not line:
|
| 393 |
+
continue
|
| 394 |
+
try:
|
| 395 |
+
rec = json.loads(line)
|
| 396 |
+
except json.JSONDecodeError:
|
| 397 |
+
continue # same tolerance the JSONL reader had
|
| 398 |
+
tenant_db.insert_scoped(
|
| 399 |
+
table, _to_row(rec, cols), DEFAULT_ORG)
|
| 400 |
+
_LEGACY_DONE.add(table)
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 404 |
+
|
| 405 |
+
def _now() -> str:
|
| 406 |
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
atp/tenant_db.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Org-scoped DAL β enforcement layer 2 of docs/TENANCY.md (Phase 2).
|
| 3 |
+
|
| 4 |
+
Tenant tables (atp_hitl, atp_requests, atp_evidence, atp_cert_awards, jobs,
|
| 5 |
+
access_log) must be read/written EXCLUSIVELY through the helpers here β never
|
| 6 |
+
through raw atp.db query()/execute() from routes (convention + CI grep, per
|
| 7 |
+
TENANCY.md layer 2).
|
| 8 |
+
|
| 9 |
+
What the helpers enforce:
|
| 10 |
+
|
| 11 |
+
* Scope validation (both dialects): any statement that mentions a tenant
|
| 12 |
+
table must (a) bind an `:org` parameter in its SQL and (b) pass
|
| 13 |
+
params['org'] equal to the org_id argument β otherwise TenantScopeError.
|
| 14 |
+
The check is a deliberately SIMPLE parse (word-boundary table-name match);
|
| 15 |
+
false positives (a tenant table named in a string literal) just demand an
|
| 16 |
+
`:org` bind, never weaker scoping.
|
| 17 |
+
* Postgres (layer 3): every statement runs inside a transaction that first
|
| 18 |
+
executes `SELECT set_config('app.org_id', :org, true)` β the exact
|
| 19 |
+
equivalent of `SET LOCAL app.org_id` but bind-parameter safe. Migration
|
| 20 |
+
005 enables FORCE ROW LEVEL SECURITY with policy
|
| 21 |
+
`USING (org_id = current_setting('app.org_id', true))`, so a transaction
|
| 22 |
+
that skips this wrapper sees ZERO tenant rows (deny by default β there is
|
| 23 |
+
intentionally no permissive fallback when the setting is absent).
|
| 24 |
+
* SQLite (dev/demo): no RLS β the `WHERE org_id = :org` the validation
|
| 25 |
+
forces into every statement IS the isolation (layers 1-2 only,
|
| 26 |
+
documented gap).
|
| 27 |
+
|
| 28 |
+
The org_id passed in must come from the verified JWT (route layer) β never
|
| 29 |
+
from a request body or query string.
|
| 30 |
+
|
| 31 |
+
Public:
|
| 32 |
+
scoped_query(org_id, sql, params) -> list[dict] (SELECTs)
|
| 33 |
+
scoped_execute(org_id, sql, params) -> db.ExecResult (writes)
|
| 34 |
+
insert_scoped(table, row, org_id) -> db.ExecResult (row['org_id'] forced)
|
| 35 |
+
log_access(org_id, user, role, method, path, table, row_count, status)
|
| 36 |
+
TENANT_TABLES, DEFAULT_ORG, TenantScopeError
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
from __future__ import annotations
|
| 40 |
+
|
| 41 |
+
import re
|
| 42 |
+
import time
|
| 43 |
+
from contextlib import contextmanager
|
| 44 |
+
|
| 45 |
+
from sqlalchemy import text
|
| 46 |
+
|
| 47 |
+
from atp import db
|
| 48 |
+
|
| 49 |
+
#: Bootstrap org every pre-tenancy row belongs to (migration 005 default).
|
| 50 |
+
DEFAULT_ORG = "org-demo"
|
| 51 |
+
|
| 52 |
+
#: Tables that carry org_id and may only be touched through this module.
|
| 53 |
+
TENANT_TABLES = frozenset({
|
| 54 |
+
"atp_hitl",
|
| 55 |
+
"atp_requests",
|
| 56 |
+
"atp_evidence",
|
| 57 |
+
"atp_cert_awards",
|
| 58 |
+
"jobs",
|
| 59 |
+
"access_log",
|
| 60 |
+
"licenses", # Phase 4 (migration 006) β atp/licensing.py; the
|
| 61 |
+
"usage_events", # key_lookup read path is documented in _load_by_key_id
|
| 62 |
+
"vault_docs", # Phase 6 (migration 007) β atp/knowledge.py (T2:
|
| 63 |
+
"l5_drafts", # encrypted content; RLS on PG per 005 pattern)
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
| 67 |
+
# ':org' bind β \b so ':org_id' / ':organization' don't satisfy the check.
|
| 68 |
+
_ORG_BIND_RE = re.compile(r":org\b")
|
| 69 |
+
_TABLE_RES = {t: re.compile(rf"\b{t}\b", re.IGNORECASE) for t in TENANT_TABLES}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class TenantScopeError(RuntimeError):
|
| 73 |
+
"""A statement touched a tenant table without correct org scoping."""
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ββ Validation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 77 |
+
|
| 78 |
+
def _require_org(org_id: str) -> str:
|
| 79 |
+
if not org_id or not isinstance(org_id, str):
|
| 80 |
+
raise TenantScopeError("org_id must be a non-empty string")
|
| 81 |
+
return org_id
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _tables_in(sql: str) -> set[str]:
|
| 85 |
+
return {t for t, rx in _TABLE_RES.items() if rx.search(sql)}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _validate(org_id: str, sql: str, params: dict) -> None:
|
| 89 |
+
"""Simple-parse scope check: tenant table named β ':org' bind required
|
| 90 |
+
and params['org'] must equal the org the caller authenticated as."""
|
| 91 |
+
_require_org(org_id)
|
| 92 |
+
touched = _tables_in(sql)
|
| 93 |
+
if not touched:
|
| 94 |
+
return
|
| 95 |
+
if not _ORG_BIND_RE.search(sql):
|
| 96 |
+
raise TenantScopeError(
|
| 97 |
+
f"statement touches tenant table(s) {sorted(touched)} without an "
|
| 98 |
+
f"':org' bind β every tenant-table statement must scope by org "
|
| 99 |
+
f"(docs/TENANCY.md layer 2): {sql!r}"
|
| 100 |
+
)
|
| 101 |
+
if params.get("org") != org_id:
|
| 102 |
+
raise TenantScopeError(
|
| 103 |
+
f"params['org'] ({params.get('org')!r}) does not match the "
|
| 104 |
+
f"authenticated org_id ({org_id!r})"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ββ Scoped connection (PG: SET LOCAL app.org_id) ββββββββββββββββββββββββββββ
|
| 109 |
+
|
| 110 |
+
@contextmanager
|
| 111 |
+
def _scoped_conn(org_id: str):
|
| 112 |
+
"""One transaction with the org GUC pinned (Postgres) β commit on exit.
|
| 113 |
+
|
| 114 |
+
set_config(..., is_local=true) == SET LOCAL: the setting dies with the
|
| 115 |
+
transaction, so pooled connections can never leak one org's scope into
|
| 116 |
+
the next request's transaction.
|
| 117 |
+
"""
|
| 118 |
+
engine = db.get_engine()
|
| 119 |
+
with engine.begin() as conn:
|
| 120 |
+
if engine.dialect.name == "postgresql":
|
| 121 |
+
conn.execute(
|
| 122 |
+
text("SELECT set_config('app.org_id', :org, true)"),
|
| 123 |
+
{"org": org_id},
|
| 124 |
+
)
|
| 125 |
+
yield conn
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ββ Public helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 129 |
+
|
| 130 |
+
@contextmanager
|
| 131 |
+
def scoped_txn(org_id: str):
|
| 132 |
+
"""Public multi-statement transaction with the org GUC pinned.
|
| 133 |
+
|
| 134 |
+
For callers that must make several reads/writes ATOMICALLY (e.g. the
|
| 135 |
+
evidence-chain append in atp/signing.py, which reads the chain head and
|
| 136 |
+
inserts under it). Yields the SQLAlchemy connection; commit on exit,
|
| 137 |
+
rollback on exception. Statement-level :org validation is the caller's
|
| 138 |
+
responsibility inside the block β Postgres RLS remains the backstop.
|
| 139 |
+
"""
|
| 140 |
+
_require_org(org_id)
|
| 141 |
+
with _scoped_conn(org_id) as conn:
|
| 142 |
+
yield conn
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def scoped_query(org_id: str, sql: str, params: dict | None = None) -> list[dict]:
|
| 146 |
+
"""Org-scoped SELECT. Named params (:name); returns rows as list[dict]."""
|
| 147 |
+
params = dict(params or {})
|
| 148 |
+
_validate(org_id, sql, params)
|
| 149 |
+
with _scoped_conn(org_id) as conn:
|
| 150 |
+
rows = conn.execute(text(sql), params).mappings().all()
|
| 151 |
+
return [dict(r) for r in rows]
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def scoped_execute(org_id: str, sql: str, params: dict | None = None) -> db.ExecResult:
|
| 155 |
+
"""Org-scoped write. Same ExecResult contract as atp.db.execute()."""
|
| 156 |
+
params = dict(params or {})
|
| 157 |
+
_validate(org_id, sql, params)
|
| 158 |
+
with _scoped_conn(org_id) as conn:
|
| 159 |
+
result = conn.execute(text(sql), params)
|
| 160 |
+
lastrowid: int | None = None
|
| 161 |
+
if result.returns_rows: # INSERT ... RETURNING <id> (both dialects)
|
| 162 |
+
row = result.first()
|
| 163 |
+
if row is not None:
|
| 164 |
+
lastrowid = row[0]
|
| 165 |
+
else:
|
| 166 |
+
try:
|
| 167 |
+
lastrowid = result.lastrowid # meaningful on SQLite
|
| 168 |
+
except Exception: # noqa: BLE001 β drivers without lastrowid
|
| 169 |
+
lastrowid = None
|
| 170 |
+
return db.ExecResult(rowcount=result.rowcount, lastrowid=lastrowid)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def insert_scoped(table: str, row: dict, org_id: str) -> db.ExecResult:
|
| 174 |
+
"""INSERT `row` into tenant `table` with org_id forced to the caller's
|
| 175 |
+
org (any org_id already present in `row` is overwritten β the verified
|
| 176 |
+
org always wins over payload-supplied values)."""
|
| 177 |
+
_require_org(org_id)
|
| 178 |
+
if table not in TENANT_TABLES:
|
| 179 |
+
raise TenantScopeError(
|
| 180 |
+
f"insert_scoped only handles tenant tables {sorted(TENANT_TABLES)}, "
|
| 181 |
+
f"got {table!r} β use atp.db.execute() for non-tenant tables"
|
| 182 |
+
)
|
| 183 |
+
row = dict(row)
|
| 184 |
+
row["org_id"] = org_id
|
| 185 |
+
for col in row:
|
| 186 |
+
if not _IDENT_RE.match(col):
|
| 187 |
+
raise TenantScopeError(f"invalid column name {col!r}")
|
| 188 |
+
cols = ", ".join(row)
|
| 189 |
+
marks = ", ".join(f":{c}" for c in row)
|
| 190 |
+
with _scoped_conn(org_id) as conn:
|
| 191 |
+
result = conn.execute(
|
| 192 |
+
text(f"INSERT INTO {table} ({cols}) VALUES ({marks})"), row)
|
| 193 |
+
try:
|
| 194 |
+
lastrowid = result.lastrowid
|
| 195 |
+
except Exception: # noqa: BLE001
|
| 196 |
+
lastrowid = None
|
| 197 |
+
return db.ExecResult(rowcount=result.rowcount, lastrowid=lastrowid)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def log_access(org_id: str, user: str, role: str, method: str, path: str,
|
| 201 |
+
table: str | None = None, row_count: int = 0,
|
| 202 |
+
status: int = 200) -> None:
|
| 203 |
+
"""Append one audit row to access_log (TENANCY.md layer 4).
|
| 204 |
+
|
| 205 |
+
access_log is append-only (triggers, migration 004) and itself org-scoped
|
| 206 |
+
under RLS (migration 005), so the insert goes through the scoped path.
|
| 207 |
+
Column names follow 004's schema and avoid reserved words: the `user`
|
| 208 |
+
argument β column `user_id`, `table` β column `target_table`.
|
| 209 |
+
"""
|
| 210 |
+
insert_scoped("access_log", {
|
| 211 |
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 212 |
+
"user_id": user,
|
| 213 |
+
"role": role,
|
| 214 |
+
"method": method,
|
| 215 |
+
"path": path,
|
| 216 |
+
"target_table": table,
|
| 217 |
+
"row_count": int(row_count),
|
| 218 |
+
"status": int(status),
|
| 219 |
+
}, org_id)
|