phase: 191_source_name_labels_tags
Build and Push Containers / build-and-push-app (push) Successful in 7m24s
Build and Push Containers / build-and-push-db (push) Successful in 36s

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/`.
This commit is contained in:
2026-10-19 06:13:32 -04:00
parent c6ffa050a6
commit 4f20c256d7
70 changed files with 7378 additions and 50 deletions
+115
View File
@@ -2246,3 +2246,118 @@ class AdminSettingsCleared(BaseModel):
deleted."""
cleared: int
#: One focus tag (phase 191, D1/D5): the owner's short token, capped at
#: the ``app.rag.source_labels.TAG_MAX_CHARS`` 32 (the ``_Chip`` item-cap
#: precedent — an ``Annotated`` alias, so the JSON shape stays a plain
#: string and the cap rides on every list of tags, request AND response).
_Tag = Annotated[str, Field(min_length=1, max_length=32)]
class SourceLabelOut(BaseModel):
"""One source's name/label/tags (``GET /api/source-labels`` entry,
``PUT``/``DELETE`` response — phase 191, task 02).
``source`` is the DERIVED source name — the name ``Document.source``
carries (the clone dir / local dir / upload file stem): the row's
identity on the wire and the join key to the documents
(phase 191 D1). The display name is an ALIAS beside it: no locator,
citation or KB-tree node is renamed by a label row.
``display_name`` / ``label`` are ``null`` when the owner set nothing
(never the empty string — the request validator folds a
whitespace-only submission to ``None``), ``tags`` ``[]``. No row =
the same null/``[]`` shape: a source without a label row is
indistinguishable from one whose label was cleared, which is exactly
the pre-phase state (D1).
``extra="forbid"`` (the :class:`GitSourceOut` structural contract):
a key outside this shape cannot be built into a response — the
row's ``updated_at`` and anything else that leaks later raise at
construction instead of silently widening the wire shape.
"""
model_config = ConfigDict(extra="forbid")
#: The derived source name (``documents.source``, ``String(120)``).
source: str = Field(max_length=120)
display_name: str | None = Field(default=None, max_length=120)
label: str | None = Field(default=None, max_length=200)
#: At most 10 tags of at most 32 chars each (:data:`_Tag`).
tags: list[_Tag] = Field(default_factory=list, max_length=10)
class SourceLabelList(BaseModel):
"""``GET /api/source-labels`` response (phase 191, task 02): the
whole source catalogue — every ``git_sources`` row (git AND local,
or the ``BOR_GIT_SOURCES`` env names while the table is empty) plus
every indexed document source no row derives (the uploads), one
entry per name, de-duplicated and sorted by ``source`` (the stable
order the Sources page and the phase's E2E rely on). Each entry
carries its label row or the null/``[]`` defaults."""
labels: list[SourceLabelOut]
class SourceLabelUpdate(BaseModel):
"""``PUT /api/source-labels/{source}`` body (phase 191, task 02).
The partial-update shape (D5, the phase-168 precedent): an OMITTED
field leaves the stored value untouched, an explicit ``null`` CLEARS
it, a value SETS it — the two ``None`` legs are distinguishable
because the router reads ``model_fields_set``, not the value.
Every string is trimmed *before* the length constraints run (the
``GitSourceIn._trim_url`` precedent), so a value with surrounding
spaces stores clean and a whitespace-only ``display_name``/``label``
folds to ``None`` — the CLEAR leg, since storing an empty string
would make "no name" and "an empty name" two different things the
prompt builders (task 05) would have to distinguish.
``tags`` carries the RAW list the token input produced. Every entry
is trimmed by ``_trim_tags`` BEFORE the constraints run, so an entry
that is empty afterwards is the framework's standard 422 (an empty
focus token matches nothing — a silent drop would tell the owner
"saved" while their tag vanished); the count cap (10) and the entry
cap (32, :data:`_Tag`) are the same numbers
:func:`app.rag.source_labels.normalize_tags` guards, and the router
still runs the submission through it for the case-insensitive
de-dupe (first case wins) and maps its ``ValueError`` to a 422, so
the rule can never turn into a 500 if this shape is ever relaxed.
``[]`` is legal and clears.
``extra="forbid"`` rejects a key outside the shape (the phase-91
voice) — a client cannot half-write a field nobody declared.
"""
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, max_length=120)
label: str | None = Field(default=None, max_length=200)
tags: list[_Tag] | None = Field(default=None, max_length=10)
@field_validator("display_name", "label", mode="before")
@classmethod
def _trim_to_none(cls, v: object) -> object:
"""Trim, and fold an empty result to ``None`` (the CLEAR leg).
``mode='before'`` so the trim runs BEFORE ``max_length`` (the
``GitSourceIn._trim_url`` convention): 121 chars that trim to
120 are legal, 121 chars of text are not."""
if isinstance(v, str):
trimmed = v.strip()
return trimmed or None
return v
@field_validator("tags", mode="before")
@classmethod
def _trim_tags(cls, v: object) -> object:
"""Trim every entry BEFORE the item constraints run (the same
``GitSourceIn`` ordering as :meth:`_trim_to_none`): ``" gitea "``
passes as ``gitea``, ``" "`` becomes ``""`` and the item's
``min_length`` rejects it. Non-list / non-string input is passed
through untouched so pydantic reports it in its own voice."""
if isinstance(v, list):
return [tag.strip() if isinstance(tag, str) else tag for tag in v]
return v