Files
ducoterra 4f20c256d7
Build and Push Containers / build-and-push-app (push) Successful in 7m24s
Build and Push Containers / build-and-push-db (push) Successful in 36s
phase: 191_source_name_labels_tags
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/`.
2026-10-19 06:13:32 -04:00

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())