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

255 lines
9.7 KiB
Python

"""Source name/label/tags storage + the D2 source-name matching rule (phase 191, task 01).
``TODO.md`` L4: "Sources need a name, a label, tag(s) so you can focus
the model when prompting if you have a project name or a standard name
you reference things by." This module is the whole non-HTTP surface of
that feature: the upsert/get/delete helpers over the ``source_labels``
table (migration 0030) and the pure matching rule the retrieval boost
(task 04) consumes.
**The alias contract (D1, owner-confirmed):** a label row is an ALIAS
beside the derived source name — ``Document.source``, the ``source/path``
document identity, the read/grep/``show_text`` locators, the citations
and the KB tree are ALL untouched by it. Renaming a source never
invalidates a saved chat, a shared page or a verbatim locator. Every
function here keys on the DERIVED name (``Document.source``); the
display name is data, never identity.
**The matching rule (D2):** a source is a SOURCE-NAME HIT when at least
one :func:`app.rag.retriever.name_hit_tokens` candidate (the phase-119
class-agnostic token list, reused UNCHANGED) equals — in the
:func:`app.rag.retriever._normalize_name` form, so case-insensitive and
accent-folded (phase 162) — a token of the source's ``display_name``,
``label`` or any ``tags`` entry. The phase-119 digit-class rule does
NOT apply to user-given names (the owner's names are not paths —
"gitea" must hit even though it is digitless; the digit rule exists to
keep prose tokens from matching PATHS). The one filter is
:data:`app.rag.retriever.NAME_TOKEN_MIN_LEN` (4) applied to the SOURCE
side too — a 2-char tag never matches.
Session convention (the phase-53 ``sources_meta`` style): the write
helpers only *flush* — the **caller** commits.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Final
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import SourceLabel
from app.rag.retriever import NAME_TOKEN_MIN_LEN, _normalize_name, name_hit_tokens
#: Boundary limits (phase 191 D1/D5) — the API (task 02) turns the
#: ``normalize_tags`` raises into the framework's standard 422; the
#: display-name / label caps are enforced there against these constants.
DISPLAY_NAME_MAX_CHARS: Final = 120
LABEL_MAX_CHARS: Final = 200
TAG_MAX_CHARS: Final = 32
TAGS_MAX_COUNT: Final = 10
class _Unset:
"""Marker type for "field omitted" in :func:`set_label`.
The partial-update shape (phase-168 D5): an OMITTED field leaves the
stored value untouched, an explicit ``None`` CLEARS it — the two are
indistinguishable with plain ``None`` defaults, so the sentinel owns
the difference (the API passes only the fields the request carried).
"""
__slots__ = ()
_UNSET: Final = _Unset()
@dataclass(frozen=True)
class SourceLabelView:
"""Immutable read-side snapshot of one label row (D1).
``tags`` is a ``tuple`` so the view stays hashable/mutable-proof
after the ORM row leaves the session; ``display_name`` / ``label``
are ``None`` when unset (never the empty string — the API trims
empty submissions to NULL).
"""
source: str
display_name: str | None
label: str | None
tags: tuple[str, ...]
def _view(row: SourceLabel) -> SourceLabelView:
return SourceLabelView(
source=row.source,
display_name=row.display_name,
label=row.label,
tags=tuple(row.tags or ()),
)
def get_label(db: Session, source: str) -> SourceLabel | None:
"""The ORM row for *source* (one PK read); ``None`` when absent."""
return db.get(SourceLabel, source)
def label_for(db: Session, source: str) -> SourceLabelView | None:
"""The frozen :class:`SourceLabelView` for *source* (one PK read);
``None`` when absent — the "no label row = pre-phase" contract."""
row = db.get(SourceLabel, source)
return None if row is None else _view(row)
def all_labels(db: Session) -> dict[str, SourceLabelView]:
"""Every label row as a frozen view, keyed by ``source``.
ONE query (the phase-119 one-query convention): the table carries a
handful of rows, so callers match in Python against it instead of
issuing a query per source. Ordered by ``source`` for determinism.
"""
rows = db.scalars(select(SourceLabel).order_by(SourceLabel.source)).all()
return {row.source: _view(row) for row in rows}
def set_label(
db: Session,
source: str,
*,
display_name: str | None | _Unset = _UNSET,
label: str | None | _Unset = _UNSET,
tags: Sequence[str] | None | _Unset = _UNSET,
) -> SourceLabel:
"""Upsert the label row for *source*; return the ORM row.
The partial-update shape (phase 191 D5, the phase-168 precedent):
a field left at the ``_UNSET`` sentinel keeps its stored value, an
explicitly passed value OVERWRITES it, an explicit ``None`` CLEARS
(``tags`` clears to ``[]`` — the column is NOT NULL). A missing row
is created with the passed values (omitted fields = their defaults:
``NULL`` / ``[]``). Values arrive ALREADY VALIDATED (the API layer
trims/enforces the caps via :func:`normalize_tags`); this helper
stores what it is given.
Flush-only (the phase-53 session convention): the **caller** commits.
"""
row = db.get(SourceLabel, source)
if row is None:
row = SourceLabel(
source=source,
display_name=None if isinstance(display_name, _Unset) else display_name,
label=None if isinstance(label, _Unset) else label,
tags=[] if isinstance(tags, _Unset) or tags is None else list(tags),
)
db.add(row)
else:
if not isinstance(display_name, _Unset):
row.display_name = display_name
if not isinstance(label, _Unset):
row.label = label
if not isinstance(tags, _Unset):
row.tags = [] if tags is None else list(tags)
db.flush()
return row
def delete_label(db: Session, source: str) -> bool:
"""Remove the label row for *source*; ``False`` when absent (the
API's 404 shape), ``True`` when a row was deleted. Flush-only."""
row = db.get(SourceLabel, source)
if row is None:
return False
db.delete(row)
db.flush()
return True
def normalize_tags(raw: Sequence[str]) -> list[str]:
"""Normalize one tag submission (pure, the API's 422 source).
* trim every entry, drop the ones that are empty afterwards
(``[]`` is legal — it clears the tags);
* raise :class:`ValueError` above :data:`TAGS_MAX_COUNT` entries
(counted on the trimmed non-empty list, BEFORE the case-insensitive
de-dupe: 11 typed tags are 11 rejected tags);
* raise :class:`ValueError` when any entry exceeds
:data:`TAG_MAX_CHARS` afterwards;
* de-duplicate case-insensitively, preserving the FIRST case seen
(``Gitea`` then ``gitea`` keeps ``Gitea``), order otherwise kept.
"""
cleaned = [tag.strip() for tag in raw]
cleaned = [tag for tag in cleaned if tag]
if len(cleaned) > TAGS_MAX_COUNT:
raise ValueError(f"at most {TAGS_MAX_COUNT} tags are allowed")
for tag in cleaned:
if len(tag) > TAG_MAX_CHARS:
raise ValueError(f"each tag must be at most {TAG_MAX_CHARS} characters")
out: list[str] = []
seen: set[str] = set()
for tag in cleaned:
key = tag.casefold()
if key in seen:
continue
seen.add(key)
out.append(tag)
return out
def _source_side_tokens(view: SourceLabelView) -> set[str]:
"""The normalized token set of one label row (the D2 source side).
Every whitespace token of ``display_name`` / ``label`` / every
``tags`` entry, folded through :func:`_normalize_name` (the single
normalization BOTH sides of name matching pass through, phase 162)
and kept at :data:`NAME_TOKEN_MIN_LEN` chars minimum — the 4-char
floor applies to the SOURCE side too (a 2-char tag never matches).
"""
tokens: set[str] = set()
fields: list[str] = [view.display_name or "", view.label or "", *view.tags]
for field in fields:
for raw in field.split():
norm = _normalize_name(raw)
if len(norm) >= NAME_TOKEN_MIN_LEN:
tokens.add(norm)
return tokens
def question_name_hits(view: SourceLabelView | None, question: str) -> bool:
"""The D2 rule (pure): does *question* name the source behind *view*?
``None`` (no label row) → ``False`` — the pre-phase contract. Any
:func:`name_hit_tokens` candidate EQUAL (in the normalized form) to
a source-side token is a hit: no digit-class rule, no prefix match
(user-given names have no prose-precision problem — the phase-119
digit rule guards PATHS, not names).
"""
if view is None:
return False
candidates = name_hit_tokens(question)
if not candidates:
return False
source_tokens = _source_side_tokens(view)
return any(candidate in source_tokens for candidate in candidates)
def question_name_hit_sources(db: Session, question: str) -> set[str]:
"""The derived source names the question names (D2) — the standalone
one-query entry point (no other per-turn work needed).
ONE :func:`all_labels` read + the pure rule per row (a handful of
rows, matched in Python — the phase-119 one-query convention). The
empty set is the common case: no label rows, or no name in the
question.
The chat turn does NOT call this: since task 05 it inlines the same
pair against its own single ``all_labels`` read — the read that also
feeds the prompt surfaces (D4) — so a turn issues ONE label query.
"""
return {
view.source
for view in all_labels(db).values()
if question_name_hits(view, question)
}