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/`.
371 lines
14 KiB
Python
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()
|