All gates re-verified against a stable tree. Report: **Verified (all 6 tasks in `complete/`; this was the final pass)** - `SourceLabel`+alembic `0030` (dev DB `alembic current` = `0030 (head)`, table present, 0 rows); admin-only `GET/PUT/DELETE /api/source-labels` (`require_admin` router-wide, `resolve_source_name`, partial-update caps/404s); Sources-page "Names, labels and tags" card (read-only derived-name `<code>`, per-row `saveInFlight`, 404 note that survives the settle, chips reuse `.git-source-hidden-count`); `question_name_hits` D2 rule; ONE `all_labels` read per turn in `chat.py` feeding both the `source_name_hit` flag and the prompt surfaces; `source_name_hit_bonus=0.005` in `_selection_order`; ` name=`/` label=` in `build_high_prompt` + `build_overview_prompt(names=…)`. - **Completion criteria:** E2E `tests/e2e/test_source_name_labels.py` ✅ 7 passed (arms a–e: editor all kinds, control/named question, rename-never-breaks-identity, 404 note, reset) · full suite ✅ · coverage ✅ · README/AGENTS ✅ · phase dir in `complete/` ✅ · commit left to the harness. - **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 7160 stmts / 16 missed = 99%** (>90%) · `uv run pytest tests/e2e/test_source_name_labels.py -v --no-cov` → **7 passed, exit 0** · regression E2E isolated, exit 0: `test_document_viewer`, `test_admin_auth`, `test_api_tokens`, `test_git_sources_admin`, `test_kb_tree`, `test_local_directory_sources` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors. - **Battery (task 04 criterion):** `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` at default vs `BOR_SOURCE_NAME_HIT_BONUS=0` → **`diff` empty (line-for-line identical)**, matching the recorded arms (dev KB is empty, so every question deflects; the observable boost is unit/E2E-pinned). - **Documentation:** README — fixed the `/git-sources.html` rows (nav label is **Sources**) and cross-linked the phase-191 card from that section; full audit otherwise clean: all 86 `BOR_*` settings documented with defaults matching `app/config.py`, 0 broken file/dir references and 0 broken anchors in README+AGENTS, AGENTS rule 17 re-checked line-by-line against the code. - **Notable / deviations:** (1) a **concurrent pipeline process** (`phased-execution/progress.mjs` for `191_…_00_phase`) was mutating this same working tree mid-pass — it rewrote `tests/e2e/test_source_name_labels.py` (11→7 tests), `frontend/assets/git-sources.js`, `tests/unit/test_source_labels_editor_ui.py` and `AGENTS.md` at 05:27; every gate above was (re-)run after those edits with the tree no source file newer than the green full run. (2) The task-06 report/phase text claims regression suites `test_chat_token_gate.py`, `test_git_sources_page.py`, `test_rag_browser.py` are green — **those files have never existed in git history** (those runs exit 4); I ran the real equivalents listed above instead (phase files are not mine to edit). (3) **No live changes**: the VM DB already has `source_labels` (0 rows) but the deployed app predates this uncommitted code, so deploying `/api/source-labels` to the live service is the owner's post-commit step, not part of this phase's criteria. - **Next pending phase:** none — `191_source_name_labels_tags` is the only entry in `.agents/phases/todo/`.
383 lines
15 KiB
Python
383 lines
15 KiB
Python
"""Audit the payload of every endpoint that could load a ton of data
|
|
(phase 167, task 05, D5, LOCKED A5).
|
|
|
|
TODO L5 is one sentence: "Fix that **any any other endpoints** that could
|
|
load a ton of data." ``GET /api/docs/tree`` was the reported offender; this
|
|
script is the measured answer for the rest of the API, against the app the
|
|
operator actually runs:
|
|
|
|
uv run python -m scripts.api_payload_audit --base-url http://localhost:8000
|
|
# (or against the deployed app: --base-url https://brain.reeseapps.com)
|
|
|
|
It signs in through the real ``POST /api/login`` (the session cookie is the
|
|
only credential — nothing is hardcoded: ``--password`` or the
|
|
``BOR_ADMIN_PASSWORD`` the app itself reads from ``.env``), walks the
|
|
candidate read routes, and prints one row per endpoint with the IDENTITY and
|
|
the GZIP transfer size, the ratio, the wall time and a verdict against the
|
|
budget (default 256 KB — the phase's "look at it" bar; LOCKED A5: this is a
|
|
report, never a gate, so the exit code is 0 whenever the measurement
|
|
succeeded).
|
|
|
|
**Credentials are password-only on purpose.** The single-admin session is
|
|
``POST /api/login {"password": …}`` (PLAN A10) — there is no username field
|
|
to send, so there is no ``--username``; ``BOR_ADMIN_PASSWORD`` (or
|
|
``--password``) is the whole credential.
|
|
|
|
Both variants are measured on every endpoint because that is the pair the
|
|
byte-budget log line reports (``bytes=`` / ``enc=``): ``Accept-Encoding:
|
|
identity`` gives the payload the route produced, ``Accept-Encoding: gzip``
|
|
gives what the client puts on the wire (``response.num_bytes_downloaded``,
|
|
the raw bytes — ``response.content`` is already decompressed by httpx). The
|
|
routes that publish a validator (the three catalogue reads, phase 167 task
|
|
03) get one extra conditional request each: the expected answer is
|
|
``304`` with an empty body, which is the row's verdict.
|
|
|
|
Run it against the 1,000-document live-replica snapshot (the
|
|
``restore-test-db`` skill) so the numbers are the production order of
|
|
magnitude and not the fixture KB's — the corpus the run measured is printed
|
|
with the table for exactly that reason.
|
|
|
|
Exit codes: **0** the table printed (an over-budget row is a FINDING, shown
|
|
as ``LOOK``, not a failure), **2** a precondition failed — the app
|
|
unreachable, the login rejected, or no credential available.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
|
|
#: The phase's "look at it" bar (256 KB uncompressed — D5/A5). Rows over it
|
|
#: are verdict ``LOOK`` and must be fixed in the phase or carry a named
|
|
#: follow-up in ``AUDIT.md``; nothing here rejects a request.
|
|
DEFAULT_BUDGET = 262_144
|
|
|
|
#: Per-request wall-clock ceiling. A large catalogue read on the restored
|
|
#: 1,000-doc replica is fast, but a cold first query can take seconds.
|
|
DEFAULT_TIMEOUT = 60.0
|
|
|
|
|
|
@dataclass
|
|
class Row:
|
|
"""One measured endpoint."""
|
|
|
|
label: str
|
|
path: str
|
|
status: int = 0
|
|
identity_bytes: int | None = None
|
|
identity_ms: int | None = None
|
|
gzip_bytes: int | None = None
|
|
gzip_ms: int | None = None
|
|
revalidated: bool | None = None
|
|
note: str = ""
|
|
|
|
@property
|
|
def ratio(self) -> str:
|
|
"""identity / gzip — how much the compression layer bought (< 1 is
|
|
a win; ``—`` when the route did not compress)."""
|
|
if not self.gzip_bytes or not self.identity_bytes:
|
|
return "—"
|
|
return f"{self.gzip_bytes / self.identity_bytes:.3f}"
|
|
|
|
def verdict(self, budget: int) -> str:
|
|
if self.status == 0:
|
|
# Nothing was measured — the corpus has no such row (an empty KB,
|
|
# no saved chats). Honest gap, not a pass and not a finding.
|
|
return "n/a"
|
|
if self.status == 304:
|
|
return "304"
|
|
if self.status != 200:
|
|
return f"HTTP {self.status}"
|
|
if self.revalidated:
|
|
return "304"
|
|
if (self.identity_bytes or 0) > budget:
|
|
return f"LOOK (> {budget})"
|
|
return "ok"
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(
|
|
prog="python -m scripts.api_payload_audit",
|
|
description=(
|
|
"Measure the response size (identity AND gzip) of every "
|
|
"candidate large-payload endpoint, against a byte budget."
|
|
),
|
|
)
|
|
p.add_argument(
|
|
"--base-url",
|
|
default="http://localhost:8000",
|
|
metavar="URL",
|
|
help="app to measure (default: http://localhost:8000)",
|
|
)
|
|
p.add_argument(
|
|
"--password",
|
|
default=None,
|
|
metavar="PASSWORD",
|
|
help=(
|
|
"admin password; falls back to $BOR_ADMIN_PASSWORD (the app's "
|
|
"own variable). The single-admin login has no username (A10)."
|
|
),
|
|
)
|
|
p.add_argument(
|
|
"--budget",
|
|
type=int,
|
|
default=DEFAULT_BUDGET,
|
|
metavar="BYTES",
|
|
help=f"the LOOK threshold in bytes (default: {DEFAULT_BUDGET})",
|
|
)
|
|
p.add_argument(
|
|
"--timeout",
|
|
type=float,
|
|
default=DEFAULT_TIMEOUT,
|
|
metavar="SECONDS",
|
|
help=f"per-request timeout (default: {DEFAULT_TIMEOUT:g})",
|
|
)
|
|
return p
|
|
|
|
|
|
def _fail(message: str) -> int:
|
|
print(f"api_payload_audit: {message}", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
def _get(client: httpx.Client, path: str, accept: str) -> tuple[int, int, int, int]:
|
|
"""GET ``path`` with an explicit ``Accept-Encoding`` →
|
|
``(status, body bytes, wire bytes, ms)``.
|
|
|
|
``body`` is the decoded payload (what the route produced); ``wire`` is
|
|
the raw transfer, which is what the browser pays. They differ exactly
|
|
when the response came back gzipped.
|
|
"""
|
|
started = time.perf_counter()
|
|
response = client.get(path, headers={"Accept-Encoding": accept})
|
|
ms = int(round((time.perf_counter() - started) * 1000))
|
|
return response.status_code, len(response.content), response.num_bytes_downloaded, ms
|
|
|
|
|
|
def measure(
|
|
client: httpx.Client, label: str, path: str, *, conditional: bool = False
|
|
) -> Row:
|
|
"""One endpoint, both encodings, plus the conditional re-request on the
|
|
routes that publish a validator (``conditional=True``)."""
|
|
row = Row(label=label, path=path)
|
|
row.status, row.identity_bytes, _, row.identity_ms = _get(client, path, "identity")
|
|
gzip_status, _, wire, ms = _get(client, path, "gzip")
|
|
row.gzip_bytes, row.gzip_ms = wire, ms
|
|
if gzip_status != row.status: # pragma: no cover - defensive
|
|
row.status = gzip_status
|
|
if conditional:
|
|
# The ETag from a fresh unconditional GET, replayed (fetch does the
|
|
# same thing on the second page view).
|
|
etag = client.get(path, headers={"Accept-Encoding": "identity"}).headers.get("etag")
|
|
if etag is None:
|
|
row.revalidated = False
|
|
else:
|
|
again = client.get(
|
|
path, headers={"Accept-Encoding": "identity", "If-None-Match": etag}
|
|
)
|
|
row.revalidated = again.status_code == 304 and len(again.content) == 0
|
|
return row
|
|
|
|
|
|
def _first_folder(tree: dict[str, Any]) -> tuple[str, str] | None:
|
|
"""``(source, folder path)`` of the first folder node the depth-1 tree
|
|
reports — a representative drill target for the node route."""
|
|
for source in tree.get("sources", []):
|
|
for child in source.get("children", []):
|
|
if child.get("kind") == "folder":
|
|
return str(source.get("name", "")), str(child.get("path", ""))
|
|
return None
|
|
|
|
|
|
def run(args: argparse.Namespace) -> tuple[int, list[Row], list[str]]:
|
|
"""Do the whole audit; returns ``(exit code, rows, corpus lines)``."""
|
|
password = args.password or os.environ.get("BOR_ADMIN_PASSWORD", "")
|
|
if not password:
|
|
return (
|
|
_fail(
|
|
"no admin password — pass --password or set BOR_ADMIN_PASSWORD "
|
|
"(the app's own variable; it is never hardcoded here)"
|
|
),
|
|
[],
|
|
[],
|
|
)
|
|
|
|
client = httpx.Client(
|
|
base_url=args.base_url, timeout=args.timeout, follow_redirects=False
|
|
)
|
|
try:
|
|
login = client.post("/api/login", json={"password": password})
|
|
except httpx.HTTPError as exc:
|
|
return (
|
|
_fail(
|
|
f"cannot reach the app at {args.base_url} ({exc.__class__.__name__}: {exc}) "
|
|
"— start it with `uv run uvicorn app.main:app`"
|
|
),
|
|
[],
|
|
[],
|
|
)
|
|
if login.status_code != 204:
|
|
detail = ""
|
|
try:
|
|
detail = str(login.json().get("detail", ""))
|
|
except Exception: # noqa: BLE001 - a non-JSON error body is still an error
|
|
detail = login.text[:120]
|
|
hint = {
|
|
401: "wrong password (BOR_ADMIN_PASSWORD of the app under test)",
|
|
429: "rate limited (10 failed attempts / 15 min) — wait for the window",
|
|
}.get(login.status_code, "unexpected answer from the real login route")
|
|
return (
|
|
_fail(
|
|
f"login to {args.base_url} failed: HTTP {login.status_code} — "
|
|
f"{hint} {detail}"
|
|
),
|
|
[],
|
|
[],
|
|
)
|
|
|
|
rows: list[Row] = []
|
|
corpus: list[str] = []
|
|
|
|
# --- the corpus the numbers describe (printed with the table) ---------
|
|
docs_response = client.get("/api/docs", headers={"Accept-Encoding": "identity"})
|
|
documents: list[dict[str, Any]] = list(docs_response.json().get("documents", []))
|
|
by_chunks = sorted(documents, key=lambda d: int(d.get("chunks", 0)), reverse=True)
|
|
biggest_doc = by_chunks[0] if by_chunks else None
|
|
chunks_total = sum(int(d.get("chunks", 0)) for d in documents)
|
|
corpus.append(f"documents={len(documents)} chunks_total={chunks_total}")
|
|
|
|
chats: list[dict[str, Any]] = list(
|
|
client.get("/api/chats", headers={"Accept-Encoding": "identity"}).json().get("chats", [])
|
|
)
|
|
biggest_chat = (
|
|
sorted(chats, key=lambda c: int(c.get("message_count", 0)), reverse=True)[0]
|
|
if chats
|
|
else None
|
|
)
|
|
|
|
# --- the candidates ---------------------------------------------------
|
|
rows.append(
|
|
measure(client, "tree depth=1 (default)", "/api/docs/tree?depth=1", conditional=True)
|
|
)
|
|
rows.append(
|
|
measure(client, "tree depth=8 (full)", "/api/docs/tree?depth=8", conditional=True)
|
|
)
|
|
|
|
depth1 = client.get(
|
|
"/api/docs/tree?depth=1", headers={"Accept-Encoding": "identity"}
|
|
).json()
|
|
folder = _first_folder(depth1)
|
|
if folder is not None and folder[0]:
|
|
node_path = f"/api/docs/tree/node?source={folder[0]}&path={folder[1]}&depth=1"
|
|
rows.append(
|
|
measure(client, f"tree node {folder[0]}/{folder[1]}", node_path, conditional=True)
|
|
)
|
|
else: # the honest gap: an empty KB has no drill to measure
|
|
rows.append(
|
|
Row(label="tree node (drill)", path="/api/docs/tree/node", note="no folder in corpus")
|
|
)
|
|
|
|
rows.append(measure(client, "docs catalogue", "/api/docs", conditional=True))
|
|
if biggest_doc is not None:
|
|
content_path = (
|
|
f"/api/documents/content?source={biggest_doc['source']}&path={biggest_doc['path']}"
|
|
)
|
|
row = measure(client, f"document content (biggest: {biggest_doc['path']})", content_path)
|
|
body = client.get(content_path, headers={"Accept-Encoding": "identity"}).json()
|
|
row.note = f"content field {len(str(body.get('content', '')))} chars"
|
|
rows.append(row)
|
|
else:
|
|
rows.append(Row(label="document content", path="/api/documents/content", note="empty KB"))
|
|
|
|
rows.append(measure(client, "saved chats list", "/api/chats"))
|
|
if biggest_chat is not None:
|
|
rows.append(
|
|
measure(
|
|
client,
|
|
f"saved chat (biggest: {int(biggest_chat.get('message_count', 0))} msgs)",
|
|
f"/api/chats/{biggest_chat['id']}",
|
|
)
|
|
)
|
|
else:
|
|
rows.append(Row(label="saved chat (one)", path="/api/chats/{id}", note="no saved chats"))
|
|
|
|
rows.append(measure(client, "git sources", "/api/git-sources"))
|
|
# phase 191: the label catalogue the Sources page loads (one entry
|
|
# per source — it grows with the registry, so it belongs in the audit)
|
|
rows.append(measure(client, "source labels (catalogue)", "/api/source-labels"))
|
|
rows.append(measure(client, "suggestions", "/api/suggestions"))
|
|
rows.append(measure(client, "sync status (polled)", "/api/sync/status"))
|
|
rows.append(measure(client, "tokens", "/api/tokens"))
|
|
rows.append(measure(client, "config", "/api/config"))
|
|
rows.append(measure(client, "whoami", "/api/whoami"))
|
|
rows.append(measure(client, "admin settings (catalogue)", "/api/admin/settings"))
|
|
|
|
corpus.append(f"sources={len(depth1.get('sources', []))}")
|
|
totals = depth1.get("totals") or {}
|
|
if totals:
|
|
corpus.append(
|
|
f"tree totals documents={totals.get('documents')} chunks={totals.get('chunks')}"
|
|
)
|
|
corpus.append(f"saved_chats={len(chats)}")
|
|
corpus.append(f"biggest_doc={biggest_doc['path'] if biggest_doc else '—'}")
|
|
return 0, rows, corpus
|
|
|
|
|
|
def render(rows: list[Row], *, base_url: str, budget: int, corpus: list[str]) -> None:
|
|
"""The table: endpoint, identity bytes, gzip bytes, ratio, ms, verdict."""
|
|
header: tuple[str, ...] = ("endpoint", "identity", "gzip", "ratio", "ms", "verdict")
|
|
table: list[tuple[str, ...]] = [header]
|
|
for row in rows:
|
|
table.append(
|
|
(
|
|
row.label,
|
|
f"{row.identity_bytes:,}" if row.identity_bytes is not None else "—",
|
|
f"{row.gzip_bytes:,}" if row.gzip_bytes is not None else "—",
|
|
row.ratio,
|
|
f"{row.gzip_ms}" if row.gzip_ms is not None else "—",
|
|
row.verdict(budget),
|
|
)
|
|
)
|
|
widths = [max(len(str(r[i])) for r in table) for i in range(len(header))]
|
|
|
|
def _line(values: tuple[Any, ...]) -> str:
|
|
return (
|
|
f" {str(values[0]):<{widths[0]}} {str(values[1]):>10} "
|
|
f"{str(values[2]):>10} {str(values[3]):>{widths[3]}} "
|
|
f"{str(values[4]):>6} {str(values[5])}"
|
|
)
|
|
|
|
print(f"api_payload_audit: base={base_url} budget={budget:,} bytes (LOOK threshold)")
|
|
print(f"api_payload_audit: corpus {' '.join(corpus)}")
|
|
print(_line(header))
|
|
print(f" {'-' * (widths[0] + 44)}")
|
|
for row, source in zip(rows, table[1:], strict=True):
|
|
print(_line(source))
|
|
if row.note:
|
|
print(f" note: {row.note}")
|
|
print(
|
|
"api_payload_audit: ms is the gzip (real-client) variant; identity is the "
|
|
"route's own bytes — the pair the ' :: api ' log line reports."
|
|
)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
code, rows, corpus = run(args)
|
|
if code != 0 or not rows:
|
|
return code
|
|
render(rows, base_url=args.base_url, budget=args.budget, corpus=corpus)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|