phase: 167_api_payload_diet
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 14s

All gates green. Final report:

**Verified (phase 167 final pass — all 6 tasks already in `complete/`)**
- Live re-measure of the headline criterion on the **1,000-doc live-replica corpus** (restored `data/bor_live_kb_replica.dump.sql` into a scratch DB `bor_audit`, alembic 0011→0027, dropped after; the shared dev DB was never touched): `GET /api/docs/tree` default depth=1 = **8,474 identity → 1,128 B with `Content-Encoding: gzip`** (< 40 KB bar; ~2 MB defect fixed), repeat load **304 / 0 bytes**; depth=8 265,147→23,515; node drill 20,844→1,065; `/api/docs` 246,366→45,104; `/api/documents/content` 892,124→60,776 (`LOOK`, accepted-with-why). Reproduces `AUDIT.md` to within 1 byte.
- Transport contracts proven live: `POST /api/chat` **no** `Content-Encoding` + frames arriving live + budget line `… enc=identity duration_ms=7380 stream=1`; HTML still `no-cache` with the `?v=` rewrite (and gzipped); A20 headers on 200 **and** 304; ETag identical across identity/gzip; `depth=0|9|abc`→422; anonymous tree/node→403; unknown folder/source→404 `unknown node`.

**Gates (all 100% green)**
- `uv run pytest` → **3980 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (`compression.py`, `http_cache.py`, `response_budget.py` 100%; `api/docs.py` 99%, one defensive dedupe line).
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Phase + regression E2E **in isolation** (`--junitxml`, exit codes): `test_kb_tree_lazy_load` 11, `test_kb_tree` 8, `test_kb_tree_nav` 4, `test_navbar_refresh` 7, `test_document_dates` 6, `test_git_source_dates` 4, `test_sync_summary_visibility` 3, `test_table_ux_polish` 6, `test_sources_midstream_bug` 6, `test_ls_tree_drilldown` 3, `test_smoke` 3 — **0 failures/errors** (the earlier `-q -q` run had simply suppressed pytest's summary line).

**Documentation** — README.md + AGENTS.md reviewed in full (1,436 / 80 lines):
- Fixed (pre-existing, phase-167-exposed): README *Caching & deploys* claimed "One Starlette middleware (`app/core/caching.py`) applies the rule" while the section now describes three transport layers → rewritten to name caching/compression/http_cache, the real outermost→innermost order, and why `bytes=` is uncompressed.
- AGENTS.md rule 5 (UI Structure Check) gained the phase-167 clause: a lazily-drilled level keeps the current level rendered, its in-flight `role="status"` / `role="alert"`+Retry row lives in the same bounded scroll panel, and stat cards read server `totals` (never a walk of a depth-limited tree).
- Cross-checked and consistent: every `BOR_*` in `.env.example` is in the README table, every `Settings` field is documented, all 15 README-cited API routes exist in the app, no stale "one fetch / drill client-side" claim remains anywhere.

**Notable decisions / deviations**
- `compression.py` adds a documented `_RebufferSmallResponses` seam beyond the D1 sketch (the caching layer's `BaseHTTPMiddleware` re-framing otherwise defeats Starlette's `minimum_size` exemption and inflates tiny responses) — integration-pinned, math still Starlette's.
- A node-route **404 = vanished folder → honest reset to top level** (per `00_phase.md` D4/D6), not task-06's "alert + Retry"; 500 shows alert+Retry with the level preserved — both pinned, nothing weakened.
- Retrieval battery not measurable (shared E2E DB holds no corpus), but `git diff --name-only -- app/rag` is empty, so retrieval is unchanged by construction. `AUDIT.md` left byte-identical (it lives under `todo/`, off-limits to this pass); no git index/history commands run.

**Next pending phase:** none — `167_api_payload_diet` is the only entry in `.agents/phases/todo/`; the harness moves it to `complete/` and commits.
This commit is contained in:
2026-10-08 21:23:49 -04:00
parent ab9391d019
commit 2ac37455f0
73 changed files with 7313 additions and 228 deletions
+85 -3
View File
@@ -522,6 +522,17 @@ class KbTreeFolder(BaseModel):
A 0-document folder cannot exist (a folder is a catalogue prefix
only), so every existing folder with no stored row is pending —
single-file folders included.
``truncated`` (phase 167, D2/A2): true iff the depth-limited build
STOPPED here — the node's own children were not sent, so the RAG
view drills via ``GET /api/docs/tree/node`` instead of reading
``children``. A truncated node still carries every field the level
row renders: the recursive ``documents`` count, the recursive
``updated_at`` (the phase-106 D9 max — kept correct without the
children), its ``summary``, and ``summary_pending``. Omitted from
the wire when ``False`` (the :func:`_drop_image_fields` omission
precedent) — an untruncated node's wire shape stays byte-identical
to pre-phase-167.
"""
kind: Literal["folder"] = "folder"
@@ -534,6 +545,13 @@ class KbTreeFolder(BaseModel):
summary: str | None = None
summary_pending: bool = False
children: list[KbTreeFolder | KbTreeFile] = Field(default_factory=list)
#: Phase 167 (task 02, D2/A2) — the children were not built at this
#: depth (see the class docstring); OMITTED when ``False``.
truncated: bool = False
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
return _drop_truncated_when_false(self, handler)
class KbTreeSource(BaseModel):
@@ -560,6 +578,14 @@ class KbTreeSource(BaseModel):
A registered 0-document source is never pending (there is nothing
to summarize); a single-document source IS pending while its root
row is absent.
``truncated`` (phase 167, D2/A2) exists on the shape for symmetry
with :class:`KbTreeFolder` (and the task-03 node route), but the
depth budget never marks a SOURCE node truncated at ``depth ≥ 1``:
the top level of the RAG view is source → level 1, so the source's
direct children are always built. Omitted from the wire when
``False`` — the source node's wire shape stays byte-identical to
pre-phase-167.
"""
name: str
@@ -571,14 +597,52 @@ class KbTreeSource(BaseModel):
summary: str | None = None
summary_pending: bool = False
children: list[KbTreeFolder | KbTreeFile] = Field(default_factory=list)
#: Phase 167 (task 02, D2/A2) — never true at ``depth ≥ 1`` (see the
#: class docstring); OMITTED from the wire when ``False``.
truncated: bool = False
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
return _drop_truncated_when_false(self, handler)
class KbTreeTotals(BaseModel):
"""Catalogue-wide totals the RAG view's three stat cards read
(phase 167, task 02, D2/A2).
A depth-limited tree CANNOT be summed client-side — the phase-97
``treeStats()`` walk only sees the sent levels, so a truncated
tree would under-count every card. The endpoint therefore computes
the totals from the FULL catalogue rows it already fetched (no
extra query) and ships them beside the tree.
"""
#: Every indexed document (the whole catalogue, not one level).
documents: int = Field(default=0, ge=0)
#: Sum of the per-document chunk counts (content + ``is_summary``
#: chunks — the same numbers ``GET /api/docs`` reports).
chunks: int = Field(default=0, ge=0)
#: The newest ``indexed_at`` stamp across the catalogue (ISO-8601),
#: ``None`` for an empty KB.
last_indexed: str | None = None
class KbTree(BaseModel):
"""Response of ``GET /api/docs/tree`` (phase 97, task 02).
The FULL recursive tree in ONE fetch — the RAG view (admin) drills
client-side, zero per-level fetches (the ``00_phase.md`` "The tree
endpoint" contract).
Since phase 167 (task 02, LOCKED A2/A3 — supersedes the phase-97
"full recursive tree in one fetch, drill client-side" contract) the
endpoint is DEPTH-LIMITED by default (``depth=1``: the sources plus
ONE level); a node whose children were not built carries
``truncated: true`` and the client drills level by level. An
explicit ``depth`` up to 8 still returns the whole tree, and the
pure builder's ``depth=None`` default keeps every pre-phase
response byte-identical.
``totals`` (phase 167, additive top-level key) is the catalogue's
true documents/chunks/last-indexed — the stat cards stay honest at
every depth because the server, not the (partial) tree walk,
computes them.
Every SOURCE and FOLDER node carries ``summary_pending`` (phase
98, D3): true iff its recursive document count ≥
@@ -592,6 +656,9 @@ class KbTree(BaseModel):
"""
sources: list[KbTreeSource]
#: Phase 167 (task 02): the server-computed catalogue totals beside
#: any depth of tree (see :class:`KbTreeTotals`).
totals: KbTreeTotals = Field(default_factory=KbTreeTotals)
class DocContent(BaseModel):
@@ -1560,6 +1627,21 @@ def _drop_image_fields(model: KbTreeFile, handler: SerializerFunctionWrapHandler
return data
def _drop_truncated_when_false(
model: KbTreeFolder | KbTreeSource, handler: SerializerFunctionWrapHandler
) -> Any:
"""The phase-167 (task 02, D2) truncation-omission rule for the
:class:`KbTreeFolder` / :class:`KbTreeSource` nodes: only a node whose
children were NOT built adds the ``truncated: true`` key — an
untruncated node's wire shape stays byte-identical to pre-phase-167
(every existing wire pin holds), the deliberate echo of the
:func:`_drop_image_fields` omission idiom."""
data = handler(model)
if not model.truncated:
data.pop("truncated", None)
return data
def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any:
"""The ``share_url`` omission rule (phase 51, task 02): ``None`` →
ABSENT from the JSON (not ``"share_url": null``) — an unshared chat