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

371 lines
14 KiB
Python

"""Unit: source name/label/tags helpers + the D2 matching rule (phase 191, task 01).
The pure half (``normalize_tags``, ``question_name_hits``) needs no DB;
the session helpers run against the local compose Postgres
(``podman compose up -d db``) — the house DB-test pattern of
``test_sources_meta.py``: the helpers are thin session wrappers whose
contract (upsert, partial-update shape, flush-not-commit, absent-row
``None``/``False``) only holds against a real database. Skips with clear
instructions when the stack is not up.
The D2 rule is the heart of the phase — every branch is pinned: the
name/label/tag hit classes, the case-insensitive ``_normalize_name``
form (the phase-162 accent fold included), the 4-char minimum on BOTH
sides, the NO digit-class rule ("gitea" hits digitless), ``None`` →
``False``, and the no-overlap baseline.
A fixture truncates ``source_labels`` before and after every test so
the suite leaves the dev DB exactly as migration 0030 left it (empty).
"""
from __future__ import annotations
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.rag.source_labels import (
TAGS_MAX_COUNT,
SourceLabelView,
all_labels,
delete_label,
get_label,
label_for,
normalize_tags,
question_name_hit_sources,
question_name_hits,
set_label,
)
@pytest.fixture(autouse=True)
def clean_source_labels(db: Session):
"""Empty ``source_labels`` before and after every test (the
migration-0030 state)."""
db.execute(text("DELETE FROM source_labels"))
db.commit()
yield
db.execute(text("DELETE FROM source_labels"))
db.commit()
# ---------------------------------------------------------------------------
# The model round-trip + the upsert / partial-update shape (D1)
# ---------------------------------------------------------------------------
def test_set_label_creates_row_with_stamp(db: Session) -> None:
"""All three fields set: the row is created verbatim and
``updated_at`` carries the server stamp (the house style)."""
row = set_label(
db,
"gitea-forge",
display_name="the git server",
label="self-hosted forge + Gitea",
tags=["gitea", "forge"],
)
db.commit()
assert row.source == "gitea-forge"
stored = get_label(db, "gitea-forge")
assert stored is not None
assert stored.display_name == "the git server"
assert stored.label == "self-hosted forge + Gitea"
assert stored.tags == ["gitea", "forge"] # stored exactly as normalized
assert stored.updated_at is not None, "updated_at must be server-stamped"
def test_set_label_defaults_omitted_fields(db: Session) -> None:
"""A brand-new row created with only ``display_name`` carries the
D1 defaults: ``label`` NULL and ``tags`` ``[]`` (NOT NULL column)."""
set_label(db, "uploads", display_name="the uploads")
db.commit()
stored = get_label(db, "uploads")
assert stored is not None
assert stored.display_name == "the uploads"
assert stored.label is None
assert stored.tags == []
def test_partial_update_omitted_field_untouched(db: Session) -> None:
"""The partial-update shape: an OMITTED field leaves the stored
value untouched (the ``_UNSET`` sentinel owns the difference)."""
set_label(
db, "homelab", display_name="the lab", label="first", tags=["homelab"]
)
db.commit()
set_label(db, "homelab", label="second")
db.commit()
stored = get_label(db, "homelab")
assert stored is not None
assert stored.label == "second", "the passed field overwrites"
assert stored.display_name == "the lab", "the omitted field is untouched"
assert stored.tags == ["homelab"], "the omitted tags are untouched"
def test_partial_update_explicit_none_clears(db: Session) -> None:
"""An explicit ``None`` CLEARS the field (the D5 shape); ``tags``
clears to ``[]`` — the column stays NOT NULL."""
set_label(
db, "homelab", display_name="the lab", label="first", tags=["homelab"]
)
db.commit()
set_label(db, "homelab", display_name=None, label=None, tags=None)
db.commit()
stored = get_label(db, "homelab")
assert stored is not None
assert stored.display_name is None
assert stored.label is None
assert stored.tags == []
def test_set_label_flushes_without_committing(db: Session) -> None:
"""Flush-only (the phase-53 session convention): a rolled-back
session rolls the upsert back with it."""
set_label(db, "homelab", display_name="the lab")
db.rollback()
other = SessionLocal()
try:
assert get_label(other, "homelab") is None, "the uncommitted row must roll back"
finally:
other.close()
def test_source_is_the_sole_pk(db: Session) -> None:
"""One label row per source: the raw duplicate insert raises."""
db.execute(text("INSERT INTO source_labels (source) VALUES ('dup')"))
db.commit()
with pytest.raises(IntegrityError):
db.execute(text("INSERT INTO source_labels (source) VALUES ('dup')"))
db.rollback()
def test_tags_server_default_is_empty_list(db: Session) -> None:
"""A row inserted without ``tags`` reads ``[]`` (the phase-89
``ignore_paths`` server-default shape — no NULL ever surfaces)."""
db.execute(text("INSERT INTO source_labels (source) VALUES ('nodefault')"))
db.commit()
stored = get_label(db, "nodefault")
assert stored is not None
assert stored.tags == []
# ---------------------------------------------------------------------------
# The read side: label_for / all_labels / delete_label
# ---------------------------------------------------------------------------
def test_label_for_returns_frozen_view(db: Session) -> None:
"""``label_for`` maps the row to the frozen view; ``tags`` arrives
as a tuple."""
set_label(db, "homelab", display_name="the lab", tags=["a", "b"])
db.commit()
view = label_for(db, "homelab")
assert view is not None
assert view == SourceLabelView(
source="homelab", display_name="the lab", label=None, tags=("a", "b")
)
assert isinstance(view.tags, tuple)
def test_label_for_absent_returns_none(db: Session) -> None:
"""No row → ``None`` — the "no label row = pre-phase" contract."""
assert label_for(db, "never-named") is None
def test_all_labels_keyed_by_source(db: Session) -> None:
"""One query, every row, keyed by ``source`` (the views the boost
and the API iterate)."""
set_label(db, "alpha", display_name="the alpha")
set_label(db, "beta", label="the beta row")
db.commit()
labels = all_labels(db)
assert set(labels) == {"alpha", "beta"}
assert labels["alpha"].display_name == "the alpha"
assert labels["beta"].label == "the beta row"
assert labels["alpha"].tags == ()
def test_all_labels_empty_table(db: Session) -> None:
"""The pre-phase state: an empty table reads as an empty mapping."""
assert all_labels(db) == {}
def test_delete_label_removes_row(db: Session) -> None:
"""The reset path: the row is gone, the helper reports ``True``."""
set_label(db, "homelab", display_name="the lab")
db.commit()
assert delete_label(db, "homelab") is True
db.commit()
assert get_label(db, "homelab") is None
def test_delete_label_absent_returns_false(db: Session) -> None:
"""An absent row is ``False`` — the API maps that to 404 (D5)."""
assert delete_label(db, "never-named") is False
def test_delete_label_flushes_without_committing(db: Session) -> None:
"""Flush-only both ways: a rolled-back delete keeps the row."""
set_label(db, "homelab", display_name="the lab")
db.commit()
assert delete_label(db, "homelab") is True
db.rollback()
other = SessionLocal()
try:
assert get_label(other, "homelab") is not None, "the rollback must restore"
finally:
other.close()
# ---------------------------------------------------------------------------
# normalize_tags (the API's 422 source, D5)
# ---------------------------------------------------------------------------
def test_normalize_tags_trims_and_drops_empties() -> None:
"""Entries are trimmed; entries that are empty afterwards dropped;
``[]`` is legal (it clears)."""
assert normalize_tags([" gitea ", "", " ", "forge"]) == ["gitea", "forge"]
assert normalize_tags([]) == []
assert normalize_tags(["", " "]) == []
def test_normalize_tags_dedupes_case_insensitively_first_case_wins() -> None:
"""Case-insensitive de-dupe preserving the FIRST case seen, order
otherwise kept."""
assert normalize_tags(["Gitea", "gitea", "GITEA", "Forge"]) == ["Gitea", "Forge"]
def test_normalize_tags_rejects_too_many_tags() -> None:
"""> 10 entries raise (counted on the trimmed non-empty list)."""
with pytest.raises(ValueError):
normalize_tags([f"tag{i}" for i in range(TAGS_MAX_COUNT + 1)])
# exactly 10 is legal
assert len(normalize_tags([f"tag{i}" for i in range(TAGS_MAX_COUNT)])) == 10
def test_normalize_tags_rejects_overlong_tag() -> None:
"""A > 32-char entry raises (the cap is checked after the trim)."""
with pytest.raises(ValueError):
normalize_tags(["gitea", "x" * 33])
assert normalize_tags(["x" * 32]) == ["x" * 32] # exactly 32 is legal
# ---------------------------------------------------------------------------
# question_name_hits — the D2 rule (every branch pinned)
# ---------------------------------------------------------------------------
_VIEW = SourceLabelView(
source="gitea-forge",
display_name="the git server",
label="self-hosted forge + Gitea",
tags=("gitea", "forgejo"),
)
def test_hit_on_display_name_token() -> None:
"""A question token equal to a display-name token hits — and the
phase-119 digit-class rule does NOT apply ("server" is digitless)."""
assert question_name_hits(_VIEW, "restart the SERVER properly") is True
def test_hit_on_label_token() -> None:
"""A token of the ``label`` field hits (``+Gitea`` normalizes to
``gitea``)."""
assert question_name_hits(_VIEW, "upgrade gitea today") is True
def test_hit_on_tag_entry() -> None:
"""A tag entry hits even when neither name nor label mentions it."""
view = SourceLabelView(
source="s", display_name=None, label=None, tags=("forgejo",)
)
assert question_name_hits(view, "mirror forgejo repos") is True
def test_hit_is_accent_folded_both_sides() -> None:
"""The phase-162 fold rides ``_normalize_name``: the accented source
name answers to the ASCII question token (and vice versa)."""
view = SourceLabelView(
source="besta", display_name="BESTÅ", label=None, tags=()
)
assert question_name_hits(view, "how tall is the besta shelf") is True
assert question_name_hits(view, "BESTÅ shelf height") is True
def test_digitless_name_hits_without_digit_rule() -> None:
"""The 2026-09-16 finding, name side: "gitea" must hit even though
it carries no digit (the path digit rule is NOT reused here)."""
view = SourceLabelView(
source="gitea-forge", display_name="Gitea", label=None, tags=()
)
assert question_name_hits(view, "How do I deploy gitea?") is True
def test_short_source_side_token_never_matches() -> None:
"""The 4-char minimum applies to the SOURCE side: a 3-char tag can
never match, even against an identical question token."""
view = SourceLabelView(
source="ops", display_name="gke", label=None, tags=("gke",)
)
assert question_name_hits(view, "set up gke today") is False
def test_short_question_token_never_matches() -> None:
"""The phase-119 ``NAME_TOKEN_MIN_LEN`` side: a 3-char question
token is no candidate against a matching source token."""
view = SourceLabelView(
source="ops", display_name="Gke Cluster", label=None, tags=()
)
assert question_name_hits(view, "gke deploy") is False
def test_none_view_never_hits() -> None:
"""No label row → ``False`` — the byte-identical pre-phase contract."""
assert question_name_hits(None, "restart the git server") is False
def test_question_without_candidates_never_hits() -> None:
"""A question with NO qualifying candidate (every token under the
4-char floor) short-circuits to ``False`` without touching the
source side."""
assert question_name_hits(_VIEW, "ai ox up") is False
def test_no_overlap_question_never_hits() -> None:
"""The baseline: a question sharing no normalized token with any
field is not a hit."""
assert question_name_hits(_VIEW, "bake a birthday cake") is False
def test_dotted_and_versioned_candidates_still_match() -> None:
"""The reused phase-119 candidate list: a dotted token (kept whole,
``llama.cpp`` → ``llamacpp``) matches the equal source token."""
view = SourceLabelView(
source="infer", display_name="llama.cpp", label=None, tags=()
)
assert question_name_hits(view, "tune llama.cpp flags") is True
# ---------------------------------------------------------------------------
# question_name_hit_sources — the per-turn entry point (one read)
# ---------------------------------------------------------------------------
def test_hit_sources_matches_per_row(db: Session) -> None:
"""The DB half: every labelled row is tested against the ONE
``all_labels`` read; only the named source comes back."""
set_label(db, "gitea-forge", display_name="the git server", tags=["gitea"])
set_label(db, "media", display_name="the media box", tags=["jellyfin"])
set_label(db, "plain") # a row with nothing set can never hit
db.commit()
assert question_name_hit_sources(db, "restart jellyfin tonight") == {"media"}
assert question_name_hit_sources(db, "upgrade gitea") == {"gitea-forge"}
assert question_name_hit_sources(db, "nothing relevant here") == set()
def test_hit_sources_empty_table(db: Session) -> None:
"""Pre-phase state: no rows → the empty set (the boost never fires)."""
assert question_name_hit_sources(db, "restart the git server") == set()