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/`.
This commit is contained in:
+115
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user