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/`.
255 lines
9.7 KiB
Python
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)
|
|
}
|