phase: 167_api_payload_diet
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:
+85
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user