phase: 191_source_name_labels_tags
Build and Push Containers / build-and-push-app (push) Successful in 7m24s
Build and Push Containers / build-and-push-db (push) Successful in 36s

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:
2026-10-19 06:13:32 -04:00
parent c6ffa050a6
commit 4f20c256d7
70 changed files with 7378 additions and 50 deletions
+113
View File
@@ -13,6 +13,7 @@ import json
import uuid
from collections.abc import Iterator, Sequence
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
@@ -27,6 +28,7 @@ from app.rag.agent import AGENT_TOOLS, SHOW_TEXT_TOOL
from app.rag.llm import StreamPiece, ToolCallPiece
from app.rag.prompts import SUGGEST_INTRO, build_deflect_prompt
from app.rag.retriever import RetrievedChunk, weak_hit_titles
from app.rag.source_labels import SourceLabelView
from app.rag.suggestions import MAX_SUGGESTIONS, derive_suggestions
from tests.conftest import ADMIN_PASSWORD
@@ -1817,3 +1819,114 @@ def test_deflected_turn_resolves_nothing_and_logs_zero(
done = [f for f in frames if f.get("type") == "done"]
assert done and done[0]["deflected"] is True
assert "answer_text" not in done[0]
# ---------- phase 191 (D4, task 05): the turn's prompt learns the name ----------
def _label_view(
source: str = "Homelab",
display_name: str | None = None,
label: str | None = None,
) -> SourceLabelView:
return SourceLabelView(source=source, display_name=display_name, label=label, tags=())
def test_plan_turn_high_prompt_carries_the_source_name() -> None:
"""The HIGH branch hands the label views to ``build_high_prompt``: the
suggested block gains `` name="…"`` / `` label="…"`` right after
``source="…"`` — the model sees the name→docs mapping."""
doc = _doc("Gitea Upgrade", "GITEA_DOC_CONTENT", summary="Upgrading Gitea.")
names = {"Homelab": _label_view(display_name="the git server", label="self-hosted forge")}
plan = chat_api.plan_turn(
[_chunk(doc, 0.90, cosine=0.90)],
_settings(threshold=0.30),
names=names,
)
assert plan.deflected is False
assert 'source="Homelab" name="the git server" label="self-hosted forge" path=' in (
plan.system_prompt
)
assert "Upgrading Gitea." in plan.system_prompt # the body is untouched
def test_plan_turn_without_names_is_byte_identical() -> None:
"""THE kill pin on the call-through: ``names`` omitted, ``None`` and
``{}`` each build the exact pre-phase prompt (and every other TurnPlan
field is identical)."""
doc = _doc("Gitea Upgrade", "GITEA_DOC_CONTENT", summary="Upgrading Gitea.")
chunks = [_chunk(doc, 0.90, cosine=0.90)]
settings = _settings(threshold=0.30)
pre = chat_api.plan_turn(chunks, settings)
for names in (None, {}):
plan = chat_api.plan_turn(chunks, settings, names=names)
assert plan.system_prompt == pre.system_prompt
assert plan.deflected == pre.deflected
assert [d.id for d in plan.suggested_docs] == [d.id for d in pre.suggested_docs]
assert ' name="' not in pre.system_prompt
def test_plan_turn_low_prompt_ignores_names() -> None:
"""A deflected turn has no document blocks: the LOW prompt is
byte-identical whether or not the source is named (D4 touches the
suggested tier only)."""
doc = _doc("Mongolia Trivia", "MONGOLIA_CONTENT")
settings = _settings(threshold=0.62)
chunks = [_chunk(doc, 0.05, cosine=0.10, fts_hit=True)]
pre = chat_api.plan_turn(chunks, settings)
plan = chat_api.plan_turn(chunks, settings, names={"Homelab": _label_view("Homelab", "x")})
assert plan.deflected is True
assert plan.system_prompt == pre.system_prompt
# The pre-phase deflected build, unchanged, is what the LOW branch owes.
assert plan.system_prompt == build_deflect_prompt(
weak_hit_titles(chunks), notes=[], kb_overview=""
)
def test_chat_module_issues_exactly_one_label_read_per_turn() -> None:
"""The marker pin for the "one query per turn" contract: the endpoint
reads the label rows ONCE (``all_labels``) and derives both the D2 hit
set and the prompt map from that read — no second label query, and task
04's convenience wrapper (which would re-query) is gone from the
module."""
src = Path(chat_api.__file__).read_text(encoding="utf-8")
assert src.count("all_labels(") == 1
assert "question_name_hit_sources(" not in src
assert src.count("names=labels") == 1, "the single read is what the prompt receives"
assert "question_name_hits(" in src, "the pure D2 rule stays applied per view"
def test_endpoint_single_label_read_feeds_flags_and_prompt(
client: TestClient,
gate_env: tuple[_FakeSession, _CannedLLM],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The one-read contract on the wire (task 04 + task 05): the ONE
``all_labels`` result of a turn sets the D2 boost flags AND the
``<document>`` name/label attributes — one query, two surfaces."""
_session, llm = gate_env
doc = _doc("Gitea Upgrade", "GITEA_DOC_CONTENT", summary="Upgrading Gitea.")
chunk = _chunk(doc, 0.90, cosine=0.90)
assert chunk.source_name_hit is False # the pre-flag state
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([chunk]))
views = {
"Homelab": SourceLabelView(
source="Homelab",
display_name="the git server",
label="self-hosted forge",
tags=("gitea",),
)
}
reads: list[int] = []
def _counting_all_labels(_db: Any) -> dict[str, SourceLabelView]:
reads.append(1)
return views
monkeypatch.setattr(chat_api, "all_labels", _counting_all_labels)
frames = _ask(client, "how do I restart the git server?")
assert [f for f in frames if f.get("type") == "done"], "the turn completed"
assert len(reads) == 1, "ONE label read for the whole turn"
assert chunk.source_name_hit is True, "the same read sets the task-04 boost flag"
system = llm.seen[0][0]["content"]
assert 'source="Homelab" name="the git server" label="self-hosted forge"' in system
+33
View File
@@ -45,6 +45,9 @@ def test_defaults_match_locked_decisions(monkeypatch: pytest.MonkeyPatch) -> Non
# selection-time document score is ON by default (0.005 — the
# owner-tunable starting point; ``0`` is the kill switch).
assert s.name_hit_bonus == 0.005
# Phase 191, D3: the SECOND bounded selection bonus (the source-name
# hit) ships ON at the same phase-119 starting point.
assert s.source_name_hit_bonus == 0.005
# Owner instruction 2026-08-22: answers may run up to 32 768 tokens.
assert s.max_output_tokens == 32_768
# Phase 17: the model's thinking streams by default (kill-switch off).
@@ -553,6 +556,36 @@ def test_name_hit_bonus_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> Non
_settings()
def test_source_name_hit_bonus_default_and_env_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 191, D3 (LOCKED A3): the bounded SOURCE-name bonus (the
second, independent selection knob over the ``source_labels`` row)
is ON by default at the phase-119 starting point (0.005) and
env-tunable so the owner re-tunes live (the ``BOR_NAME_HIT_BONUS``
pattern). ``0`` is legal — the byte-identical kill switch."""
monkeypatch.delenv("BOR_SOURCE_NAME_HIT_BONUS", raising=False)
s = _settings()
assert s.source_name_hit_bonus == 0.005
monkeypatch.setenv("BOR_SOURCE_NAME_HIT_BONUS", "0")
assert _settings().source_name_hit_bonus == 0.0
monkeypatch.setenv("BOR_SOURCE_NAME_HIT_BONUS", "0.01")
assert _settings().source_name_hit_bonus == 0.01
def test_source_name_hit_bonus_rejects_negative(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``0`` is the byte-identical kill switch (the pre-phase-191
selection order) — a NEGATIVE bonus would demote source-named
documents (the exact opposite of D2/D3), so the validator fails
loudly at startup naming the field (the ``name_hit_bonus``
pattern, phase 191)."""
monkeypatch.setenv("BOR_SOURCE_NAME_HIT_BONUS", "-0.1")
with pytest.raises(ValidationError, match="source_name_hit_bonus"):
_settings()
def test_retrieval_ef_search_default_and_env_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
+7 -1
View File
@@ -373,7 +373,13 @@ def test_branch_field_css_rules_are_house_styled() -> None:
assert "var(--ink-soft)" in hint_rule and "font-size: 0.8rem" in hint_rule, (
"the .field-hint values (the token field's hint styling)"
)
tag = css.find(".git-source-hidden-count,\n.git-source-branch-count {")
# Phase 191 (task 03): the name/label/tags row's two tags ("named" and
# one tag chip) joined this SAME rule — the reuse that keeps the token
# input from shipping its own tag CSS.
tag = css.find(
".git-source-hidden-count,\n.git-source-branch-count,\n"
".source-label-state,\n.source-label-tag {"
)
assert tag != -1, "the row tag joins the 'hidden on' tag rule"
tag_rule = css[tag : css.find("}", tag)]
assert "background: var(--bg)" in tag_rule and "border" in tag_rule, (
+8 -2
View File
@@ -398,8 +398,14 @@ def test_hidden_checkbox_css_rules_exist_with_accent_color() -> None:
)
# Phase 135 (task 04): the "branch: <name>" state tag joined this
# rule — the "hidden on" tag keeps the same idiom (text + a
# distinct background, never color alone).
tag = _css_rule(css, ".git-source-hidden-count,\n.git-source-branch-count")
# distinct background, never color alone). Phase 191 (task 03): the
# name/label/tags row's "named" tag and its tag CHIPS joined the same
# rule (the token input reuses this tag, it ships none of its own).
tag = _css_rule(
css,
".git-source-hidden-count,\n.git-source-branch-count,\n"
".source-label-state,\n.source-label-tag",
)
assert "var(--ink)" in tag and "var(--bg)" in tag, (
"text + a distinct background (never color alone)"
)
+217
View File
@@ -10,13 +10,22 @@ pins the deterministic ``Sources: …`` inventory tail (LOCKED A2): the
format/order/counts, the 1-doc survival, the happy-path stored content,
the byte-identical zero-doc / ``LLMError`` paths, and the byte-identical
model input.
And the phase-191 display names (task 05, D4): the INPUT lines lead with
the owner's ``display_name`` when the source has a label row (the raw
source name is replaced in the ``lite`` model's input ONLY — the stored
row and its ``Sources: …`` tail keep the derived name), byte-identical
for every unnamed source, and fed by exactly ONE ``all_labels`` read per
run (the marker pins at the end).
"""
from __future__ import annotations
import asyncio
import inspect
import logging
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from sqlalchemy import select, text
@@ -24,6 +33,7 @@ from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.models import Document, KbOverview
from app.rag import overview as overview_module
from app.rag.llm import LLMError
from app.rag.overview import (
KB_OVERVIEW_MODE,
@@ -35,6 +45,8 @@ from app.rag.overview import (
regenerate_overview,
)
from app.rag.retriever import TRUNCATION_MARKER
from app.rag.source_labels import all_labels as real_all_labels
from app.rag.source_labels import set_label
REPLY = "- Homelab\n - Kubernetes (k3s)\n- Deployments\n - Borg backups"
@@ -650,3 +662,208 @@ def test_regenerate_llm_error_preserves_an_existing_inventory_tail(
assert row is not None and row.content == previous
finally:
_truncate_documents(db)
# ---------------------------------------------------------------------------
# Phase 191 (D4, task 05): the input lines carry the owner's display name
# ---------------------------------------------------------------------------
#: The owner's alias for the ``gitea-infra`` source (phase 191 D1 — an
#: alias, never the identity).
GIT_SERVER = "the git server"
@pytest.fixture()
def clean_source_labels(db: Session):
"""Empty ``source_labels`` before and after (migration-0030 state)."""
db.execute(text("DELETE FROM source_labels"))
db.commit()
yield
db.execute(text("DELETE FROM source_labels"))
db.commit()
# ---------- build_overview_prompt: the names map (pure) ----------
@pytest.mark.parametrize(
"names",
[None, {}, {"Other": "another name"}],
ids=["names=None", "names={}", "no-entry-for-the-source"],
)
def test_input_lines_without_a_names_entry_are_byte_identical_to_pre_phase(
names: dict[str, str] | None,
) -> None:
"""THE kill pin (phase 191 D4): no map, an empty map and a map that
misses the row's source each build the pre-phase input byte-for-byte —
the same cap, the same separator, the same order."""
rows = [
("Homelab", "k8s.md", "Kubernetes", "A cluster.\nSource: Homelab/k8s.md"),
("Homelab", "borg.md", "Borg", None),
]
pre = build_overview_prompt(rows, max_chars=10_000)
assert build_overview_prompt(rows, max_chars=10_000, names=names) == pre
def test_input_line_leads_with_the_display_name_when_named() -> None:
"""A named source: the display name REPLACES the raw source name in the
lite model's input — the path / title / summary halves untouched."""
rows = [("gitea-infra", "docs/install.md", "Install", "The install notes.")]
_system, user = build_overview_prompt(rows, names={"gitea-infra": GIT_SERVER})
assert user == f"{GIT_SERVER} — docs/install.md — Install — The install notes."
assert "gitea-infra" not in user
def test_input_line_without_an_entry_keeps_the_raw_source_name() -> None:
"""Mixed catalogue: only the named source's lines change — every other
line stays exactly as the pre-phase builder wrote it."""
rows = [
("gitea-infra", "a.md", "A", "Sum A."),
("Homelab", "b.md", "B", "Sum B."),
]
_system, user = build_overview_prompt(rows, names={"gitea-infra": GIT_SERVER})
assert user.splitlines() == [
f"{GIT_SERVER} — a.md — A — Sum A.",
"Homelab — b.md — B — Sum B.",
]
def test_named_summary_less_line_has_no_dangling_dash() -> None:
"""The pre-phase "no summary → no trailing field" rule survives the
name swap."""
_system, user = build_overview_prompt(
[("gitea-infra", "a.md", "A", None)], names={"gitea-infra": GIT_SERVER}
)
assert user == f"{GIT_SERVER} — a.md — A"
def test_named_entry_with_an_empty_display_name_falls_back_to_the_source() -> None:
"""Defensive: an empty name in the map is "no name" (the API trims
empty submissions to NULL, so this never reaches the wire — the input
line must never lead with a blank)."""
_system, user = build_overview_prompt(
[("gitea-infra", "a.md", "A", None)], names={"gitea-infra": ""}
)
assert user == "gitea-infra — a.md — A"
def test_names_take_part_in_the_input_cap() -> None:
"""The cap counts the whole line (the name included) — the truncation
contract is unchanged: cut at the cap, marker on its own line."""
rows = [("gitea-infra", "a.md", "A", None)]
_system, plain = build_overview_prompt(rows, max_chars=10)
_system, named = build_overview_prompt(rows, max_chars=10, names={"gitea-infra": GIT_SERVER})
assert named != plain
assert named.endswith(TRUNCATION_MARKER)
assert named.startswith(GIT_SERVER[:10])
# ---------- regenerate_overview: the ONE all_labels read (DB) ----------
def test_regenerate_model_input_carries_the_display_name(
db: Session, clean_overview, clean_source_labels
) -> None:
"""The wire half of D4: the single read feeds the input lines, and the
unnamed source's line is untouched."""
_truncate_documents(db)
set_label(db, "gitea-infra", display_name=GIT_SERVER)
db.commit()
try:
base = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
_add_doc(db, "gitea-infra", "a.md", "A", "Sum A.", created_at=base + timedelta(days=1))
_add_doc(db, "Homelab", "b.md", "B", "Sum B.", created_at=base)
llm = _FakeLLM()
assert asyncio.run(regenerate_overview(llm, db)) is True
user = llm.messages[1]["content"]
assert user.splitlines() == [
f"{GIT_SERVER} — a.md — A — Sum A.",
"Homelab — b.md — B — Sum B.",
]
finally:
_truncate_documents(db)
def test_regenerate_stored_content_keeps_the_derived_names(
db: Session, clean_overview, clean_source_labels
) -> None:
"""The ALIAS contract (D1) on the stored row: the deterministic
``Sources: …`` inventory tail names sources by their DERIVED name —
a rename never changes the identity the rest of the app keys on."""
_truncate_documents(db)
set_label(db, "gitea-infra", display_name=GIT_SERVER, label="self-hosted forge")
db.commit()
try:
_add_doc(db, "gitea-infra", "a.md", "A")
assert asyncio.run(regenerate_overview(_FakeLLM(), db)) is True
row = db.get(KbOverview, 1)
assert row is not None
assert row.content == REPLY + "\nSources: gitea-infra (1 doc)"
assert GIT_SERVER not in row.content
finally:
_truncate_documents(db)
def test_regenerate_without_label_rows_is_byte_identical_to_pre_phase(
db: Session, clean_overview, clean_source_labels
) -> None:
"""No label rows (the whole-KB kill pin): the model input and the
stored content are exactly the pre-phase bytes."""
_truncate_documents(db)
try:
_add_doc(db, "Homelab", "a.md", "A", "Sum A.")
llm = _FakeLLM()
assert asyncio.run(regenerate_overview(llm, db)) is True
assert llm.messages[1]["content"] == "Homelab — a.md — A — Sum A."
row = db.get(KbOverview, 1)
assert row is not None and row.content == REPLY + "\nSources: Homelab (1 doc)"
finally:
_truncate_documents(db)
def test_regenerate_reads_the_label_rows_once(
db: Session, clean_overview, clean_source_labels, monkeypatch: pytest.MonkeyPatch
) -> None:
"""ONE ``all_labels`` read per run (the task's single-query contract):
the regenerator calls it exactly once and derives the name map from
those views — no second query."""
_truncate_documents(db)
set_label(db, "gitea-infra", display_name=GIT_SERVER)
db.commit()
calls: list[int] = []
def _counting_all_labels(session: Session) -> dict:
calls.append(1)
return real_all_labels(session)
monkeypatch.setattr(
"app.rag.overview.all_labels",
_counting_all_labels,
)
try:
_add_doc(db, "gitea-infra", "a.md", "A")
_add_doc(db, "gitea-infra", "b.md", "B")
assert asyncio.run(regenerate_overview(_FakeLLM(), db)) is True
assert len(calls) == 1
finally:
_truncate_documents(db)
# ---------- the marker pins: one read, in the right function ----------
OVERVIEW_PY = Path(overview_module.__file__).resolve().read_text(encoding="utf-8")
def test_overview_module_issues_exactly_one_all_labels_read() -> None:
"""The marker pin: the ONE ``all_labels(` call in the module — the
overview builder never queries labels on its own."""
assert OVERVIEW_PY.count("all_labels(") == 1
def test_the_single_read_lives_in_regenerate_overview() -> None:
"""The read sits in the caller (which already holds the session), not
in the pure builder — so the builder stays a pure function of its
arguments and one read covers the whole run."""
src = inspect.getsource(regenerate_overview)
assert src.count("all_labels(") == 1
assert "all_labels(" not in inspect.getsource(build_overview_prompt)
+209
View File
@@ -39,6 +39,12 @@ phase-124 ``show_image`` clause through one mechanism, and every build
the flags do not select stays byte-identical (the constant's own sha pin
lives in :mod:`tests.unit.test_prompt_lock`).
And the phase-191 source names (task 05, D4): a ``<document>`` block can
carry the owner's label row as the ADDITIVE ``name`` / ``label``
attributes (order ``source`` → ``name?`` → ``label?`` → ``path`` →
``title`` → ``date``, XML-escaped values), with no map / no entry for the
source byte-identical to pre-phase (the kill pin).
And the phase-119 cite discipline (task 04, LOCKED A5): the intro's
final sentence ("cite the document(s) you used, by path", phase 118)
is REPLACED — the HIGH prompt carries the discipline sentence exactly
@@ -65,6 +71,7 @@ from app.rag.prompts import (
SHOW_TEXT_CLAUSE,
SUGGEST_INTRO,
TOOLS_SECTION,
_attr,
_base,
_document_body,
_extract_context_line,
@@ -74,6 +81,7 @@ from app.rag.prompts import (
build_steering_section,
)
from app.rag.retriever import TRUNCATION_MARKER
from app.rag.source_labels import SourceLabelView
#: A small multi-line outline standing in for the lite-generated one.
OVERVIEW = "- Homelab\n - Kubernetes (k3s)\n- Deployments\n - Borg backups"
@@ -1944,3 +1952,204 @@ def test_high_prompt_pdf_image_doc_block_exact_shape() -> None:
assert prompt.index(line) < prompt.index("A diagram of the cluster.")
assert block_start < prompt.index(line) < prompt.index("</document>")
assert "The second page of the guide" not in prompt
# ---------------------------------------------------------------------------
# Phase 191 (D4, task 05): the ``<document>`` block learns the owner's name
# ---------------------------------------------------------------------------
#: The owner's display name for the fixture source (phase 191 D1 — an
#: ALIAS beside the derived name, never a replacement for it).
GIT_SERVER = "the git server"
#: The owner's one-line description of the same source.
FORGE_LABEL = "self-hosted forge + Gitea"
def _label_view(
source: str = "Homelab",
display_name: str | None = None,
label: str | None = None,
tags: tuple[str, ...] = (),
) -> SourceLabelView:
"""One label-row view (task 01's frozen dataclass) for the pins."""
return SourceLabelView(
source=source,
display_name=display_name,
label=label,
tags=tags,
)
def _block_head(prompt: str) -> str:
"""The opening tag of the FIRST ``<document>`` block (attribute
order/escaping pins read it whole, so nothing is asserted loosely)."""
start = prompt.index("<document ")
return prompt[start : prompt.index(">", start) + 1]
@pytest.mark.parametrize(
"names",
[
None,
{},
{"Other": _label_view("Other", display_name=GIT_SERVER, label=FORGE_LABEL)},
],
ids=["names=None", "names={}", "no-entry-for-the-source"],
)
def test_high_prompt_without_a_label_entry_is_byte_identical_to_pre_phase(
names: dict[str, SourceLabelView] | None,
) -> None:
"""THE kill pin (phase 191 D4): no map, an empty map and a map with no
entry for the document's source each build a prompt byte-identical to
the pre-phase build — no ``name``, no ``label``, no stray space."""
doc = _doc(
"kubernetes.md",
"Talos Linux on three nodes.",
"Kubernetes Homelab Cluster",
summary="A Talos Linux cluster on three nodes.",
)
pre = build_high_prompt([doc])
assert build_high_prompt([doc], names=names) == pre
assert ' name="' not in pre and ' label="' not in pre
def test_no_label_block_keeps_the_exact_pre_phase_head() -> None:
"""The block head for a label-less source, byte for byte (the shape the
phase-106/118/125 pins rely on: ``source`` → ``path`` → ``title`` →
``date``, single-spaced)."""
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes Homelab Cluster", summary="S.")
assert _block_head(build_high_prompt([doc], names={})) == (
'<document source="Homelab" path="kubernetes.md" '
'title="Kubernetes Homelab Cluster" date="2024-06-15">'
)
def test_document_block_name_attribute_sits_immediately_after_source() -> None:
"""A ``display_name``-only row: exactly `` name="…"`` added, directly
after ``source="…"`` and before ``path`` (the attribute-order pin)."""
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes", summary="S.")
prompt = build_high_prompt([doc], names={"Homelab": _label_view(display_name=GIT_SERVER)})
assert _block_head(prompt) == (
'<document source="Homelab" name="the git server" path="kubernetes.md" '
'title="Kubernetes" date="2024-06-15">'
)
assert ' label="' not in prompt
def test_document_block_label_only_view_adds_exactly_the_label_attribute() -> None:
"""A ``label``-only row gains ONE attribute — an unset field adds
NOTHING (never an empty ``name=""``)."""
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes", summary="S.")
prompt = build_high_prompt([doc], names={"Homelab": _label_view(label=FORGE_LABEL)})
assert _block_head(prompt) == (
'<document source="Homelab" label="self-hosted forge + Gitea" '
'path="kubernetes.md" title="Kubernetes" date="2024-06-15">'
)
assert ' name="' not in prompt
def test_document_block_row_with_neither_field_is_byte_identical() -> None:
"""A row that exists but carries neither name nor label (the owner
saved only tags) leaves the block untouched — tags never render in the
block (they are a retrieval input, D2/D3)."""
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes", summary="S.")
pre = build_high_prompt([doc])
names = {"Homelab": _label_view(tags=("gitea", "forge"))}
built = build_high_prompt([doc], names=names)
assert built == pre
assert "gitea" not in built
def test_document_block_carries_both_attributes_in_the_locked_order() -> None:
"""Both fields set → both attributes, in the fixed order
``source`` → ``name`` → ``label`` → ``path`` → ``title`` → ``date``."""
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes", summary="S.")
names = {"Homelab": _label_view(display_name=GIT_SERVER, label=FORGE_LABEL)}
head = _block_head(build_high_prompt([doc], names=names))
order = ['source="', 'name="', 'label="', 'path="', 'title="', 'date="']
positions = [head.index(attr) for attr in order]
assert positions == sorted(positions)
assert head == (
'<document source="Homelab" name="the git server" '
'label="self-hosted forge + Gitea" path="kubernetes.md" '
'title="Kubernetes" date="2024-06-15">'
)
def test_document_block_name_and_label_are_xml_escaped() -> None:
"""The escaping pin (task 05 work item 1): a ``"`` in owner text renders
as ``&quot;`` so the attribute cannot break; ``&``/``<`` fold too."""
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes", summary="S.")
names = {
"Homelab": _label_view(
display_name='Reese "forge" & <co>',
label='say "hi"',
)
}
prompt = build_high_prompt([doc], names=names)
assert ' name="Reese &quot;forge&quot; &amp; &lt;co&gt;"' in prompt
assert ' label="say &quot;hi&quot;"' in prompt
# The unescaped form never reaches the wire: the attribute stays closed.
assert ' name="Reese "forge"' not in prompt
head = _block_head(prompt)
assert head.count('"') % 2 == 0, "an unescaped quote would break the attribute"
def test_attribute_escaping_helper_covers_the_xml_five() -> None:
"""``_attr`` is the ONE escaper for the new attribute values: the XML
specials go, the single quote stays (the delimiter is the double
quote, and ``Reese's`` must stay readable to the model)."""
assert _attr('a & b < c > d " e') == "a &amp; b &lt; c &gt; d &quot; e"
assert _attr("Reese's forge") == "Reese's forge"
def test_names_apply_per_document_source_and_never_touch_the_body() -> None:
"""Only the blocks whose source has an entry gain the attribute; the
summary body (and the other source's block) are untouched."""
named = _doc("a.md", "CONTENT_A", "Title A", summary="SUMMARY A.")
other = _doc("b.md", "CONTENT_B", "Title B", summary="SUMMARY B.")
other.source = "Other"
prompt = build_high_prompt(
[named, other],
names={"Homelab": _label_view(display_name=GIT_SERVER)},
)
assert 'source="Homelab" name="the git server" path="a.md"' in prompt
assert 'source="Other" path="b.md"' in prompt
assert prompt.count(GIT_SERVER) == 1, "the name rides the attribute, nothing else"
assert "SUMMARY A." in prompt and "SUMMARY B." in prompt
def test_names_on_an_empty_documents_build_change_nothing() -> None:
"""No blocks → nothing to annotate: the honest "(no documents
matched…)" build is byte-identical with a label row in hand."""
names = {"Homelab": _label_view(display_name=GIT_SERVER, label=FORGE_LABEL)}
assert build_high_prompt([], names=names) == build_high_prompt([])
def test_names_compose_with_folder_lines_and_display_clauses() -> None:
"""The additive attributes ride alongside the phase-119 folder lines and
the phase-124/182 display clauses — the section order and the intro are
untouched (the folder lines stay plain, no new markup)."""
doc = _doc("kubernetes.md", "CONTENT", "Kubernetes", summary="S.")
names = {"Homelab": _label_view(display_name=GIT_SERVER)}
prompt = build_high_prompt(
[doc],
folder_lines=["docs/gitea.md — Gitea install"],
show_image=True,
show_text=True,
names=names,
)
assert 'source="Homelab" name="the git server" path="kubernetes.md"' in prompt
assert prompt.index(SUGGEST_INTRO) < prompt.index("docs/gitea.md — Gitea install")
assert prompt.index("docs/gitea.md — Gitea install") < prompt.index("<document ")
assert prompt.endswith(TOOLS_SECTION[: -len("</tools>")]
+ SHOW_IMAGE_CLAUSE + "\n\n" + SHOW_TEXT_CLAUSE + "\n</tools>")
def test_deflect_prompt_has_no_name_or_label_surface() -> None:
"""D4 touches the suggested-tier block only: the deflected prompt is
byte-identical (it carries no document blocks to annotate)."""
prompt = build_deflect_prompt(["Kubernetes Homelab Cluster"])
assert ' name="' not in prompt and ' label="' not in prompt
assert GIT_SERVER not in prompt
+174 -9
View File
@@ -1260,9 +1260,12 @@ def _bonus_rc(
cosine: float,
position: int = 0,
name_hit: bool = False,
source_name_hit: bool = False,
) -> RetrievedChunk:
"""One fused-list candidate (name-hit rows follow the D1 lexical
convention: ``cosine=0.0``, ``fts_hit=True``)."""
convention: ``cosine=0.0``, ``fts_hit=True``). The
``source_name_hit`` flag (phase 191, D2) is set at the retrieval
boundary in chat.py — here directly, on the fused row."""
return RetrievedChunk(
chunk_id=uuid.uuid4(),
position=position,
@@ -1272,6 +1275,7 @@ def _bonus_rc(
cosine=cosine,
fts_hit=cosine == 0.0,
name_hit=name_hit,
source_name_hit=source_name_hit,
)
@@ -1479,24 +1483,185 @@ def test_weak_hit_titles_bonus_adjusted_order() -> None:
def test_bonus_lives_in_the_selection_layer_only() -> None:
"""LOCKED A3: the bonus never touches the chunk objects —
"""LOCKED A3 (mirrored by phase 191 for the SECOND bonus): the
bonuses never touch the chunk objects —
``score``/``cosine``/``fts_hit`` (the A8 gate's inputs —
``query_log.top_score`` is the best fused chunk score, the same
values) are unchanged after every selection walk, even with the
bonus lifting a document."""
values) and the ``name_hit``/``source_name_hit`` flags themselves
are unchanged after every selection walk, even with the bonuses
lifting a document."""
chunks = _bonus_chunks()
chunks[-1].source_name_hit = True
before = {
rc.chunk_id: (rc.score, rc.cosine, rc.fts_hit, rc.name_hit) for rc in chunks
rc.chunk_id: (
rc.score,
rc.cosine,
rc.fts_hit,
rc.name_hit,
rc.source_name_hit,
)
for rc in chunks
}
select_suggested(chunks, n=5, bonus=0.005)
select_related(chunks, set(), cap=5, bonus=0.005)
weak_hit_titles(chunks, bonus=0.005)
select_suggested(chunks, n=5, bonus=0.005, source_bonus=0.005)
select_related(chunks, set(), cap=5, bonus=0.005, source_bonus=0.005)
weak_hit_titles(chunks, bonus=0.005, source_bonus=0.005)
after = {
rc.chunk_id: (rc.score, rc.cosine, rc.fts_hit, rc.name_hit) for rc in chunks
rc.chunk_id: (
rc.score,
rc.cosine,
rc.fts_hit,
rc.name_hit,
rc.source_name_hit,
)
for rc in chunks
}
assert before == after
# ---------------------------------------------------------------------------
# Phase 191, D3 — the bounded SOURCE-name bonus (the second, independent
# knob; the phase-119 mirror: kill switch, bounded per-document apply,
# the fuse OR-merge, the settings defaults)
# ---------------------------------------------------------------------------
def test_selection_order_source_bonus_zero_is_byte_identical_to_pre_phase() -> None:
"""The kill switch (LOCKED A3, phase-119 mirror): with
*source_bonus* ``0`` the walk is byte-identical to the
pre-phase-191 walk even when EVERY chunk carries the flag — same
order, same effective scores (the phase-119 name bonus alone). With
BOTH knobs ``0`` the pre-phase golden walk returns byte-identical."""
chunks = _bonus_chunks()
for rc in chunks:
rc.source_name_hit = True
out = _selection_order(chunks, 0.005, 0.0)
assert [d.path for d, _eff, _cos, _idx in out] == [
"gitea/README.md", "a.md", "b.md", "c.md", "d.md"
]
# No +0.005 rides the effective scores — the name bonus alone.
assert out[0][1] == pytest.approx(0.016 + 0.005)
off = _selection_order(chunks, 0.0, 0.0)
assert [d.path for d, *_ in off] == GOLDEN_PRE_PHASE_ORDER
assert [eff for _d, eff, _cos, _idx in off] == [
0.0200, 0.0160, 0.0150, 0.0150, 0.0100
]
def test_selection_order_source_bonus_inert_without_source_hits() -> None:
"""No ``source_name_hit`` chunk present → the second bonus cannot
fire: with both knobs on the walk equals the phase-119 name-bonus
walk exactly (the flagged-signal presence, not just the knob, gates
the re-sort)."""
out = _selection_order(_bonus_chunks(), 0.005, 0.005)
assert [d.path for d, _eff, _cos, _idx in out] == [
"gitea/README.md", "a.md", "b.md", "c.md", "d.md"
]
assert out[0][1] == pytest.approx(0.016 + 0.005)
def test_selection_order_source_bonus_lifts_named_doc_over_equal_fused() -> None:
"""The boost (D3): a document whose source is a D2 hit
(``source_name_hit=True``) ranks ABOVE an equal-fused-score
non-hit — the fused tie normally resolves on cosine (a's 0.9 beats
the hit's 0.0), the 0.005 head start decides instead."""
gitserver = _doc("gitserver.md", "G" * 50)
a = _doc("a.md", "A" * 50)
chunks = [
_bonus_rc(a, 0.0200, 0.9),
_bonus_rc(gitserver, 0.0200, 0.0, source_name_hit=True),
]
assert [d.path for d, *_ in _selection_order(chunks, 0.0, 0.0)] == [
"a.md", "gitserver.md"
]
out = _selection_order(chunks, 0.0, 0.005)
assert [d.path for d, *_ in out] == ["gitserver.md", "a.md"]
assert out[0][1] == pytest.approx(0.0200 + 0.005)
def test_selection_order_two_bonuses_bounded_at_two_per_document() -> None:
"""A document carrying BOTH flags gets EXACTLY +0.010 (the bounded
two-bonus pin — no amplification): every one of the doc's THREE
chunks is flagged for both signals, yet each bonus applies ONCE per
document — best score + bonus + source_bonus, never more."""
gitserver = _doc("gitserver.md", "G" * 50)
a = _doc("a.md", "A" * 50)
chunks = [
_bonus_rc(a, 0.0300, 0.9),
_bonus_rc(gitserver, 0.0260, 0.0, 0, name_hit=True, source_name_hit=True),
_bonus_rc(gitserver, 0.0200, 0.0, 1, name_hit=True, source_name_hit=True),
_bonus_rc(gitserver, 0.0100, 0.0, 2, name_hit=True, source_name_hit=True),
]
out = _selection_order(chunks, 0.005, 0.005)
assert [d.path for d, *_ in out] == ["gitserver.md", "a.md"]
assert out[0][1] == pytest.approx(0.0260 + 0.010)
def test_selection_helpers_pull_the_source_bonus_from_settings(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""All three selection helpers default *source_bonus* to the LIVE
``BOR_SOURCE_NAME_HIT_BONUS`` setting (the phase-119 ``bonus``
pattern): the settings default 0.005 lifts the flagged doc, a
settings kill switch (``0``) and an explicit ``source_bonus=0``
both reproduce the pre-phase-191 walk."""
chunks = _bonus_chunks()
chunks[-1].score = 0.0199 # d.md just UNDER a.md's 0.0200
chunks[-1].source_name_hit = True
on = Settings(_env_file=None) # pyright: ignore[reportCallIssue]
assert on.source_name_hit_bonus == 0.005 # the production default
monkeypatch.setattr(retriever, "get_settings", lambda: on)
lifted = ["d.md", "gitea/README.md", "a.md", "b.md", "c.md"]
assert [d.path for d in select_suggested(chunks, n=5)] == lifted
assert [d.path for d in select_related(chunks, set(), cap=5)] == lifted
assert weak_hit_titles(chunks) == lifted
# Explicit kill switch — the pre-phase-191 (name-bonus-only) walk.
assert [d.path for d in select_suggested(chunks, n=5, source_bonus=0.0)] == [
"gitea/README.md", "a.md", "d.md", "b.md", "c.md"
]
# Settings kill switch (BOR_SOURCE_NAME_HIT_BONUS=0) — the same walk.
off = Settings(_env_file=None, source_name_hit_bonus=0.0) # pyright: ignore[reportCallIssue]
monkeypatch.setattr(retriever, "get_settings", lambda: off)
pre = ["gitea/README.md", "a.md", "d.md", "b.md", "c.md"]
assert [d.path for d in select_suggested(chunks, n=5)] == pre
assert [d.path for d in select_related(chunks, set(), cap=5)] == pre
assert weak_hit_titles(chunks) == pre
def test_fuse_or_s_source_name_hit_on_double_hit() -> None:
"""A vector row merged with a flagged source-name row keeps
``source_name_hit=True`` — the merge ORs the flag in (the
``name_hit`` mirror), so the selection tier still sees the hit on
the fused list."""
v = _rc("gitserver.md", cosine=0.9)
l1 = _rc("gitserver.md", cosine=0.1) # the lexical copy of the same chunk
l1.chunk_id = v.chunk_id
l1.source_name_hit = True
out = fuse([v], [l1], k=60)
assert len(out) == 1
assert out[0].source_name_hit is True
assert out[0].fts_hit is True
assert out[0].score == pytest.approx(2 / 61)
def test_fuse_keeps_source_name_hit_on_lexical_only_hit() -> None:
"""A source-name-hit row with no vector rank keeps
``source_name_hit=True`` through the fusion (the ``replace()``
copy carries the field — the ``name_hit`` mirror)."""
row = _rc("gitserver.md")
row.source_name_hit = True
out = fuse([], [row], k=60)
assert len(out) == 1
assert out[0].source_name_hit is True
def test_fuse_default_source_name_hit_stays_false_for_ordinary_rows() -> None:
"""Neither list flagged ⇒ fusion never invents a source-name hit —
ordinary vector and FTS rows are ``source_name_hit=False``."""
out = fuse([_rc("a.md", cosine=0.8)], [_rc("b.md", fts_hit=True)], k=60)
assert len(out) == 2
assert all(rc.source_name_hit is False for rc in out)
# ---------------------------------------------------------------------------
# Phase 162, D1 — the accent-folded name normalization (LOCKED A1): the
# 2026-10-07 live gap, the ASCII filename vs the accented content layer
+23
View File
@@ -253,6 +253,29 @@ def test_sync_session_bound_field_types() -> None:
assert Settings.model_fields["sync_prune_batch"].default == 500
def test_source_name_hit_bonus_row_is_a_plain_live_overridable_row() -> None:
"""Phase 191 (task 04, D3): the second selection-bonus row sits in
``Retrieval & honesty`` (right beside ``name_hit_bonus`` — the two
independently kill-able knobs) with the ``Source name hit bonus``
label and mirrors the ``name_hit_bonus`` entry's flags exactly:
plain — not ``secret`` (a float), not ``restart_required`` (the
selection walk reads ``get_settings()`` per turn, so a saved
override applies on the next question), overridable (the overlay is
generic over ``float`` fields). ``0`` is the byte-identical kill
switch; a negative value fails startup, not the panel."""
meta = CATALOG["source_name_hit_bonus"]
assert meta.group == "Retrieval & honesty"
assert meta.label == "Source name hit bonus"
assert meta.secret is False
assert meta.restart_required is False
assert meta.overridable is True
assert "source_name_hit_bonus" not in SECRETS
assert "source_name_hit_bonus" not in RESTART_REQUIRED
assert "source_name_hit_bonus" not in NON_OVERRIDABLE
assert Settings.model_fields["source_name_hit_bonus"].annotation is float
assert Settings.model_fields["source_name_hit_bonus"].default == 0.005
def test_labels_nonempty_and_unique() -> None:
labels = [meta.label for meta in CATALOG.values()]
assert all(label.strip() for label in labels), "empty label in the catalogue"
+370
View File
@@ -0,0 +1,370 @@
"""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()
+746
View File
@@ -0,0 +1,746 @@
"""Unit: the per-source name/label/tags editor on the Sources page
(phase 191, task 03).
``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." Tasks 01/02 stored that (``source_labels``,
migration 0030) and exposed it (``/api/source-labels``); this task is the
surface the owner types into — a SECOND list on ``/git-sources.html``
whose row list is the catalogue ``GET /api/source-labels`` (the union of
every registry row's DERIVED name and every indexed document source, so
git, local AND upload sources all get an editor without a second API):
* the markup — the ``#source-labels-section`` card inside the admin-gated
``#git-sources-content`` (the page's gate is this list's gate: the
anonymous path returns at the gate and never fetches it), its own
``.table-wrap`` (the phase-156 bounded panel + sticky header — the
class alone, no divergent card rule), its ``role="alert"`` load-error
line, its empty state, and ONE shared
``<template id="source-label-row-template">`` cloned per row: the
read-only mono identity cell, the three fields (Name ``maxlength=120``,
Label ``maxlength=200``, the Tags token input ``maxlength=32``), the
``type="button"`` Save / Reset pair and the ``role="status"`` save-state
line;
* THE ALIAS CONTRACT (D1/A1) — the identity cell is a ``<code>`` and the
template carries NO input there: a label row never renames
``Document.source``, a locator, a citation or a saved chat;
* the JS lifecycle — ``loadLabels`` → ``renderLabelRows`` → ``makeLabelRow``
(``template.content`` + ``cloneNode``, then the per-clone id rewrite so
every ``<label for>`` stays unique), the token input (Enter / comma adds
a chip, Backspace drops the last, the ``normalize_tags`` caps mirrored),
the dirty paint (``.is-dirty`` + "unsaved changes", per ROW), and Save /
Reset on the row's OWN ``saveInFlight`` flag — the ``PUT`` / ``DELETE``
response is the new truth (NO second GET), the 404 arm names itself
("That source no longer exists") and the ``finally`` settles every
outcome so "Saving…" can never stick (§7.4);
* styles.css — the chips and the state tag JOIN the phase-89 tag rule
(``.git-source-hidden-count`` / ``.git-source-branch-count`` /
``.source-label-state`` / ``.source-label-tag`` are ONE rule — no new
tag-input CSS), the fields take the ``.ignore-editor-token`` box
language, the buttons keep the ≥44px floor, the empty state is shared
with ``.git-sources-empty``, and the new block stays on palette
variables (no raw hex, no new hue).
The browser behaviour is E2E-gated by the phase-191 story suite
(``tests/e2e/test_source_name_labels.py``, task 06); like
``test_source_ignore_paths.py`` / ``test_git_source_branch_ui.py`` (the
house pattern for this page), this module pins the source-level contract a
silent regression would break.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
SHELL_HTML = FRONTEND / "index.html"
JS = FRONTEND / "assets" / "git-sources.js"
CSS = FRONTEND / "assets" / "styles.css"
#: The static ids the section ships (the no-collision guard keys on these
#: — the ids inside the template are the FIRST row's and makeLabelRow
#: rewrites the clones').
SECTION_IDS = (
"source-labels-section",
"source-labels-title",
"source-labels-error",
"source-labels-wrap",
"source-labels-table",
"source-labels-tbody",
"source-labels-empty",
"source-label-row-template",
)
#: The template's field ids (rewritten per clone to ``<base>-<n>``).
TEMPLATE_FIELD_IDS = (
"source-label-name",
"source-label-label",
"source-label-tags",
"source-label-editor",
)
#: The save-state line's copy, pinned verbatim (§7.4 + the 404 arm).
STATUS_SAVED = "saved"
STATUS_DIRTY = "unsaved changes"
STATUS_SAVING = "Saving…"
STATUS_RESETTING = "Resetting…"
NOTE_GONE = "That source no longer exists — reload the page."
#: The caps of ``app/rag/source_labels`` mirrored client-side.
TAG_MAX_CHARS = 32
TAGS_MAX_COUNT = 10
#: The functions this module inspects (all live inside ``mount(root)``).
LABEL_FNS = (
"storedFromEntry",
"hasStoredLabel",
"labelSummaryText",
"paintLabelTags",
"addLabelTags",
"readLabelForm",
"fillLabelForm",
"labelFormDirty",
"clearLabelError",
"setLabelStatus",
"paintLabelRowState",
"updateLabelRowUI",
"setLabelEditorOpen",
"labelFailureNote",
"saveLabelRow",
"resetLabelRow",
"makeLabelRow",
"loadLabels",
"renderLabelRows",
"showLabelsError",
"hideLabelsError",
)
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _html() -> str:
return _read(SHELL_HTML)
def _js() -> str:
return _read(JS)
def _css() -> str:
return _read(CSS)
def _fn(js: str, name: str) -> str:
"""The source of a (possibly async) function via balanced-brace
counting (the ``test_source_ignore_paths.py`` helper)."""
for prefix in ("async function ", "function "):
start = js.find(f"{prefix}{name}(")
if start != -1:
depth = 0
for i in range(js.find("{", start), len(js)):
if js[i] == "{":
depth += 1
elif js[i] == "}":
depth -= 1
if depth == 0:
return js[start : i + 1]
raise AssertionError(f"unbalanced braces in {name}()")
raise AssertionError(f"{name}() must exist in git-sources.js")
def _input_tag(html: str, class_attr: str) -> str:
m = re.search(rf"<input[^>]*{class_attr}[^>]*>", html, re.S)
assert m, f"missing <input class*=…{class_attr}> in the shell"
return re.sub(r"\s+", " ", m.group(0))
def _element_by_id(html: str, id_attr: str) -> str:
m = re.search(rf'<[a-z]+ [^>]*id="{id_attr}"[^>]*>', html, re.S)
assert m, f"missing the #{id_attr} element in the shell"
return re.sub(r"\s+", " ", m.group(0))
def _div_span(html: str, open_tag: str) -> str:
"""The substring from an opening ``<div …>`` to its matching
``</div>`` (balanced div-tag counting — the phase-174 helper)."""
start = html.index(open_tag)
depth = 0
for m in re.finditer(r"<div\b|</div>", html[start:]):
if m.group(0) == "</div>":
depth -= 1
if depth == 0:
return html[start : start + m.end()]
else:
depth += 1
raise AssertionError(f"unbalanced divs from {open_tag!r}")
def _label_for(html: str, field_id: str) -> str:
m = re.search(rf'<label[^>]*for="{field_id}"[^>]*>(.*?)</label>', html, re.S)
assert m, f"missing <label for={field_id}> (WCAG — never aria-label-only)"
return re.sub(r"\s+", " ", m.group(0))
def _css_rule(css: str, selector: str) -> str:
"""The BODY of one CSS rule (these rules carry no nested braces)."""
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
assert m is not None, f"rule for {selector!r} not found in styles.css"
return " ".join(m.group(1).split())
def _css_selector_list(css: str, needle: str) -> str:
"""The SELECTOR LIST of the rule that styles ``needle`` (the last
selector before the ``{``), so a shared rule is detectable."""
i = css.find(needle)
assert i != -1, f"{needle!r} is not in styles.css"
open_brace = css.find("{", i)
head = css.rfind("}", 0, i)
start = head + 1 if head != -1 else 0
return " ".join(css[start:open_brace].split())
def _template_block(html: str) -> str:
start = html.index('<template id="source-label-row-template">')
end = html.index("</template>", start)
return html[start:end]
# ---------------------------------------------------------------------------
# The section scaffold (index.html)
# ---------------------------------------------------------------------------
def test_section_ships_inside_the_admin_gated_content() -> None:
"""The card exists, is labelled by its own heading, and sits INSIDE
``#git-sources-content`` — the reveal git-sources.js performs only in
the ADMIN branch, which is what keeps the anonymous visitor from ever
fetching ``/api/source-labels`` (the page's gate is this list's gate)."""
html = _html()
section = _element_by_id(html, "source-labels-section")
assert 'class="source-labels"' in section
assert 'aria-labelledby="source-labels-title"' in section
content = _div_span(html, '<div id="git-sources-content" hidden>')
assert 'id="source-labels-section"' in content, (
"the section must live inside #git-sources-content (anonymous ships hidden)"
)
assert 'id="source-labels-title"' in content
# The heading is real copy about the alias, not a placeholder.
title = re.search(
r'<h2 class="source-labels-title" id="source-labels-title">(.*?)</h2>', html, re.S
)
assert title and "Names, labels and tags" in title.group(1)
def test_section_carries_its_own_error_line_and_empty_state() -> None:
"""``#source-labels-error`` is the list's own ``role="alert"`` line
(hidden until the GET fails) and ``#source-labels-empty`` the empty
state (hidden by default) — the two states a failed or empty catalogue
may reach, so the page is never stuck (the ``#git-sources-load-error``
discipline applied to this page's second list)."""
html = _html()
err = _element_by_id(html, "source-labels-error")
assert 'role="alert"' in err, "a failed catalogue is an alert, not silence"
assert " hidden" in err, "the error line ships hidden"
empty = _element_by_id(html, "source-labels-empty")
assert " hidden" in empty, "the empty state ships hidden"
assert "No sources to name yet." in html
def test_table_is_a_bounded_sticky_panel_with_scoped_columns() -> None:
"""The wrapper carries the shared ``.table-wrap`` class (the ONE
bounded-panel + sticky-header card rule, phase 156 — an unbounded
bespoke card would be the regression) and the table has two scoped
column headers, the second one naming all three fields."""
html = _html()
wrap = _element_by_id(html, "source-labels-wrap")
assert "table-wrap" in wrap, "the shared bounded/sticky card class"
assert 'role="region"' in wrap and 'tabindex="0"' in wrap, (
"a scroll region is keyboard-reachable and named (WCAG 2.1.1/1.3.1)"
)
assert 'id="source-labels-tbody"' in html
table_i = html.index('id="source-labels-table"')
tail = html[table_i : html.index("</table>", table_i)]
assert '<th scope="col">Source</th>' in tail
assert '<th scope="col">Name, label and tags</th>' in tail
assert '<caption class="visually-hidden">' in tail
def test_new_ids_appear_exactly_once() -> None:
"""The no-collision guard (the phase-46/76 contract): every static id
this task adds appears EXACTLY ONCE in the shell."""
html = _html()
for id_attr in SECTION_IDS + TEMPLATE_FIELD_IDS:
n = len(re.findall(rf'id="{re.escape(id_attr)}"', html))
assert n == 1, f'id="{id_attr}" appears {n} times in index.html (must be 1)'
# ---------------------------------------------------------------------------
# The shared row template + the alias contract
# ---------------------------------------------------------------------------
def test_template_ships_one_row_with_the_three_capped_fields() -> None:
"""ONE shared template, cloned per row: the Name (≤120), Label (≤200)
and Tags (≤32) inputs — ``type="text"`` + ``autocomplete="off"`` +
the ``maxlength`` mirroring the API caps (so an over-long submission
never round-trips as a 422) — each with a real visible
``<label for>`` (WCAG, never aria-label-only)."""
tpl = _template_block(_html())
assert "<tr" in tpl, "the template holds one row (a <tr> parses in a template)"
for cls, cap, field_id in (
("source-label-name-input", 120, "source-label-name"),
("source-label-label-input", 200, "source-label-label"),
("source-label-tags-input", 32, "source-label-tags"),
):
tag = _input_tag(tpl, cls)
assert 'type="text"' in tag, f"{cls} is a plain text field"
assert f'maxlength="{cap}"' in tag, f"{cls} mirrors the {cap}-char API cap"
assert 'autocomplete="off"' in tag, f"{cls} offers no browser autofill"
assert f'id="{field_id}"' in tag, f"{cls} carries the template id JS rewrites"
assert _label_for(tpl, field_id), f"{cls} has a visible <label for>"
def test_identity_cell_is_read_only_text_never_an_input() -> None:
"""D1/A1 pinned in markup: the identity cell is a ``<code>`` — the
derived source name — and the ONLY element the row's identity area
contains; the editor is a sibling cell, so a label can never become a
rename of ``Document.source``, a citation or a ``source/path`` locator."""
tpl = _template_block(_html())
m = re.search(
r'<td class="source-label-id-cell">(.*?)</td>', tpl, re.S
)
assert m, "the identity cell exists"
cell = m.group(1)
assert '<code class="source-label-source">' in cell
assert "<input" not in cell, "the identity cell has NO input (an alias, not a rename)"
assert 'class="source-label-state"' in cell, "the stored-label state tag (text, 1.4.1)"
id_i = tpl.index('class="source-label-id-cell"')
edit_i = tpl.index('class="source-label-edit-cell"')
assert id_i < edit_i, "the identity cell precedes the editor (it is not replaced)"
def test_editor_ships_closed_with_the_a11y_pair_and_state_line() -> None:
"""The expandable block: the toggle is a real ``type="button"`` with
``aria-expanded="false"``, the editor div ships ``hidden`` (JS sets
``aria-controls`` to the editor's per-clone id), Save ships
``disabled`` (nothing is dirty on paint) and Reset ships ``hidden``
(a row with no stored label has nothing to reset — the phase-168
per-row reset shape). The save-state line is a live region."""
tpl = _template_block(_html())
toggle = re.search(r'<button[^>]*class="source-label-toggle"[^>]*>', tpl, re.S)
assert toggle and 'type="button"' in toggle.group(0)
assert 'aria-expanded="false"' in toggle.group(0), "collapsed by default"
editor = re.search(r'<div class="source-label-editor"[^>]*>', tpl, re.S)
assert editor and " hidden" in editor.group(0), "the editor ships closed"
save = re.search(r'<button[^>]*class="source-label-save"[^>]*>', tpl, re.S)
assert save and 'type="button"' in save.group(0) and "disabled" in save.group(0)
reset = re.search(r'<button[^>]*class="source-label-reset"[^>]*>', tpl, re.S)
assert reset and 'type="button"' in reset.group(0) and " hidden" in reset.group(0)
status = re.search(r'<span class="source-label-status"[^>]*>', tpl, re.S)
assert status and 'role="status"' in status.group(0)
assert 'aria-live="polite"' in status.group(0), "the save state announces"
# ---------------------------------------------------------------------------
# The load path
# ---------------------------------------------------------------------------
def test_load_labels_fetches_the_catalogue_once_and_heals() -> None:
"""``loadLabels`` is ONE ``GET /api/source-labels`` (no method key =
GET), renders from ``data.labels``, and heals its own error line on
the success path BEFORE rendering (an error followed by a good
refresh clears it — the phase-77 rule for this page's lists). A
network blip and a non-2xx both land on the role=alert line."""
js = _js()
body = _fn(js, "loadLabels")
assert 'const LABELS_ENDPOINT = "/api/source-labels";' in js
assert "fetch(LABELS_ENDPOINT)" in body
assert 'method: "' not in body, "the catalogue read is a GET"
assert body.count("fetch(") == 1, "exactly one fetch per load"
assert "Array.isArray(data.labels)" in body, "renders from the labels array"
heal_i = body.find("hideLabelsError();")
render_i = body.find("renderLabelRows(")
assert -1 < heal_i < render_i, "the success path heals the error before rendering"
assert body.find("showLabelsError(") < body.find("hideLabelsError();")
for fallback in (
"Could not reach the server — the source names are not shown.",
"The server could not list the source names",
"The server sent an unreadable label list — try again.",
):
assert fallback in body, f"missing the failure line {fallback!r}"
def test_render_replaces_rows_and_syncs_the_list_states() -> None:
"""A re-render REPLACES the rows (never appends — a re-show must not
duplicate), and the table card / empty state flip off the row count."""
js = _js()
body = _fn(js, "renderLabelRows")
clear_i = body.find("labelsTbody.replaceChildren()")
append_i = body.find("labelsTbody.appendChild(")
assert -1 < clear_i < append_i, "the clear precedes the fill"
assert 'labelsWrap.hidden = !hasRows' in body
assert 'labelsEmptyEl.hidden = hasRows' in body
def test_error_state_hides_the_table_and_the_empty_state() -> None:
"""While the error is up it is the ONLY claim about the list (the
``showLoadError`` discipline): both the table and the empty state go
hidden, and ``hideLabelsError`` clears the text on the way out."""
js = _js()
show = _fn(js, "showLabelsError")
assert "labelsErrorEl.textContent = message" in show
assert "labelsErrorEl.hidden = false" in show
assert "labelsWrap.hidden = true" in show
assert "labelsEmptyEl.hidden = true" in show
hide = _fn(js, "hideLabelsError")
assert 'labelsErrorEl.textContent = ""' in hide
assert "labelsErrorEl.hidden = true" in hide
def test_labels_fetch_is_admin_only_and_refreshes_the_list_flows() -> None:
"""Every ``loadLabels()`` call site sits AFTER the ``fetchIsAdmin``
gate (anonymous never asks), the boot loads it alongside the sources,
and each flow that CHANGES the source list (add / remove / a finished
upload) refreshes it — so a new source is nameable without a reload."""
js = _js()
gate = js.find("const admin = await fetchIsAdmin();")
assert gate != -1
anon = js[gate : js.find("return;", gate)]
assert "loadLabels" not in anon, "the anonymous branch never fetches labels"
# The gate sits at the END of mount (the boot), so the pin is the
# branch itself + the boot's own call after it: nothing before the
# gate runs for an anonymous visitor, and the mount tail is where the
# first catalogue read happens.
boot = js[gate:]
assert "await loadLabels();" in boot, "the admin boot loads the catalogue"
assert boot.index("await loadLabels();") < boot.index("await loadSources();"), (
"the two lists load side by side at boot (neither waits on the other)"
)
# add / remove / upload settle paths
add = _fn(js, "wireAddForm")
assert "await loadSources(); // the new row lands in the table" in add
assert js.count("await loadLabels();") >= 3, "add + remove + boot refresh labels"
assert "loadLabels(); // phase 191: the uploaded source joins the name list" in js
# the re-show refresh keeps the phase-77 listener verbatim + a second
# listener for the second list (so an unrelated table reload never
# wipes a half-typed name draft)
refresh_listeners = re.findall(
r'addEventListener\("bor:view-refresh", \(\) => (\w+)\(\)\)', js
)
assert refresh_listeners == ["loadSources", "loadLabels"], refresh_listeners
# ---------------------------------------------------------------------------
# Save / Reset — the §7.4 contract, per row
# ---------------------------------------------------------------------------
def test_save_puts_the_whole_form_and_uses_the_response_as_truth() -> None:
"""Save: ``PUT /api/source-labels/<source>`` (``encodeURIComponent`` —
a source name is a path segment), the three keys always ride (an empty
Name is the deliberate CLEAR leg), and on 200 the ROW RE-READS ITS
STORED LABEL FROM THE RESPONSE — no second GET anywhere in the save
path (task 03: "the response is the new truth")."""
js = _js()
body = _fn(js, "saveLabelRow")
fetch_i = body.find(
"fetch(`${LABELS_ENDPOINT}/${encodeURIComponent(view.source)}`"
)
assert fetch_i != -1, "the PUT targets the per-source path"
assert 'method: "PUT"' in body[fetch_i : fetch_i + 220]
assert "body: JSON.stringify(form)" in body, "the whole form is sent"
form_i = body.find("const form = readLabelForm(view);")
assert -1 < form_i < fetch_i, "the form is read before the request"
ok_i = body.find("if (r.ok)")
stored_i = body.find("view.stored = storedFromEntry(", ok_i)
json_i = body.find("await r.json()", ok_i)
assert -1 < ok_i < json_i < stored_i, "the response body IS the new truth"
assert "loadLabels()" not in body, "no second GET after a save"
assert "loadSources()" not in body, "a save never reloads the sources table"
assert "fillLabelForm(view)" in body[stored_i:], "the fields re-paint from the response"
assert "setLabelStatus(view, LABEL_STATUS_SAVED, null)" in body
def test_save_and_reset_guard_on_the_rows_own_in_flight_flag() -> None:
"""The §7.4 guard scoped PER ROW (the phase-168 ``saveInFlight``
pattern): the early return precedes everything, the flag is set BEFORE
the fetch, and the ``finally`` drops it on every outcome — a second
click while a request is out is ignored and no row is ever left
claiming "Saving…" after a terminal frame."""
js = _js()
for name, label in (
("saveLabelRow", "LABEL_STATUS_SAVING"),
("resetLabelRow", "LABEL_STATUS_RESETTING"),
):
body = _fn(js, name)
guard_i = body.find("if (view.saveInFlight) return;")
set_i = body.find("view.saveInFlight = true;")
status_i = body.find(f"setLabelStatus(view, {label}, null);")
fetch_i = body.find("fetch(`")
finally_i = body.find("} finally {")
clear_i = body.find("view.saveInFlight = false;", finally_i)
assert -1 < guard_i < set_i < status_i < fetch_i, (
f"{name}: guard → flag → in-flight label → fetch"
)
assert finally_i != -1 and clear_i != -1, f"{name}: the finally must drop the flag"
assert body.index("view.saveBtn.disabled = true") < fetch_i
assert body.index("view.resetBtn.disabled = true") < fetch_i
assert "updateLabelRowUI(view);" in body[clear_i:], (
f"{name}: the settle re-decides Save from the dirty state"
)
def test_reset_deletes_and_clears_the_row() -> None:
"""Reset: ``DELETE /api/source-labels/<source>`` → the stored label is
nulled from the response and the fields clear with it, settling on the
same "saved" line (the row now matches the server)."""
body = _fn(_js(), "resetLabelRow")
fetch_i = body.find(
"fetch(`${LABELS_ENDPOINT}/${encodeURIComponent(view.source)}`"
)
assert fetch_i != -1
assert 'method: "DELETE"' in body[fetch_i : fetch_i + 200]
ok_i = body.find("if (r.ok)")
stored_i = body.find("view.stored = storedFromEntry(cleared || {})", ok_i)
assert stored_i != -1, "the stored label is nulled from the response"
assert body.find("fillLabelForm(view)", stored_i) != -1, "the fields clear"
assert "setLabelStatus(view, LABEL_STATUS_SAVED, null)" in body
assert "loadLabels()" not in body, "no second GET after a reset"
def test_the_404_note_names_the_arm() -> None:
"""A 404 means the source left the catalogue — a retry cannot help —
so the one-line note names it (task 03), while every other non-2xx
carries the server's own detail (``apiDetail``, 422 shape-aware)."""
js = _js()
assert f'const LABEL_NOTE_GONE = "{NOTE_GONE}";' in js
body = _fn(js, "labelFailureNote")
gone_i = body.find("if (r.status === 404) return LABEL_NOTE_GONE;")
detail_i = body.find("return await apiDetail(r, fallback);")
assert -1 < gone_i < detail_i, "the 404 arm first, then the server detail"
for name in ("saveLabelRow", "resetLabelRow"):
assert "labelFailureNote(r," in _fn(js, name), f"{name} uses the shared note"
def test_a_failed_saves_note_survives_the_dirty_paint() -> None:
"""A failed save leaves the row DIRTY, and the request's ``finally``
calls ``updateLabelRowUI`` — so the dirty hint used to overwrite the
note the same request had just written, and the one error whose advice
is a reload rather than a retype ("That source no longer exists —
reload the page.") disappeared in the tick that produced it (the
defect the phase-191 story suite walked into). Pinned: the error line
returns from the paint untouched, Save keeps its dirty-driven
``disabled`` decision (a retry is legitimate for every arm but the
404), and the ONLY thing that clears a note is the owner editing
again — the settle path never does."""
js = _js()
paint = _fn(js, "updateLabelRowUI")
disable_i = paint.find("view.saveBtn.disabled = !dirty;")
guard_i = paint.find('if (view.statusEl.classList.contains("is-error")) return;')
hint_i = paint.find('setLabelStatus(view, LABEL_STATUS_DIRTY, "hint")')
assert -1 < disable_i < guard_i < hint_i, (
"the error guard sits after the Save paint, before the hint"
)
assert "setLabelStatus" not in paint[guard_i:hint_i], "the note is untouched"
clear = _fn(js, "clearLabelError")
assert 'if (view.statusEl.classList.contains("is-error"))' in clear
assert 'setLabelStatus(view, "", null);' in clear
row = _fn(js, "makeLabelRow")
for opener in (
'input.addEventListener("input", () => {',
'tagsInput.addEventListener("keydown", (e) => {',
):
i = row.find(opener)
assert i != -1, f"missing {opener}"
assert "clearLabelError(view);" in row[i : i + 220], f"{opener} clears the stale note"
# A collapse clears it the same way (it refills from the stored label).
assert 'setLabelStatus(view, "", null);' in _fn(js, "setLabelEditorOpen")
def test_status_line_copy_is_pinned_verbatim() -> None:
"""The save-state line's whole vocabulary (§7.4 + the dirty hint) —
pinned so a silent copy change cannot break the E2E and the
screen-reader contract with it."""
js = _js()
for literal in (
f'const LABEL_STATUS_SAVED = "{STATUS_SAVED}";',
f'const LABEL_STATUS_DIRTY = "{STATUS_DIRTY}";',
f'const LABEL_STATUS_SAVING = "{STATUS_SAVING}";',
f'const LABEL_STATUS_RESETTING = "{STATUS_RESETTING}";',
):
assert literal in js, f"missing {literal!r}"
dirty_paint = _fn(js, "updateLabelRowUI")
assert 'setLabelStatus(view, LABEL_STATUS_DIRTY, "hint")' in dirty_paint
assert 'view.tr.classList.toggle("is-dirty", dirty)' in dirty_paint
assert "if (view.saveInFlight) return;" in dirty_paint, (
"a keystroke mid-request never overwrites the in-flight label"
)
# ---------------------------------------------------------------------------
# The token input
# ---------------------------------------------------------------------------
def test_token_input_adds_on_enter_or_comma_and_drops_the_last_on_backspace() -> None:
"""The token input's widget rules (task 03): Enter or a comma commits
the token (``preventDefault`` — a comma never types, Enter never
submits), Backspace on an empty input drops the LAST chip, and leaving
the field commits a half-typed token."""
js = _js()
body = _fn(js, "makeLabelRow")
enter_i = body.find('if (e.key === "Enter" || e.key === ",") {')
assert enter_i != -1
assert "e.preventDefault();" in body[enter_i : enter_i + 200]
add_i = body.find("addLabelTags(view, tagsInput.value);", enter_i)
back_i = body.find(
'if (e.key === "Backspace" && tagsInput.value === "" && view.tags.length)'
)
assert -1 < enter_i < add_i < back_i, "Enter/comma precedes the Backspace rule"
pop_i = body.find("view.tags.pop();", back_i)
assert -1 < back_i < pop_i < body.find("paintLabelTags(view);", back_i)
blur_i = body.find('tagsInput.addEventListener("blur"')
assert blur_i > back_i, "blur commits a pending token"
def test_tag_caps_are_the_servers_and_the_first_case_wins() -> None:
"""The caps are ``normalize_tags``' numbers mirrored client-side
(32 chars, 10 chips) and the de-dupe keeps the FIRST case — the chip
list the owner sees is the list the API stores."""
js = _js()
assert f"const LABEL_TAG_MAX_CHARS = {TAG_MAX_CHARS};" in js
assert f"const LABEL_TAGS_MAX_COUNT = {TAGS_MAX_COUNT};" in js
body = _fn(js, "addLabelTags")
assert ".slice(0, LABEL_TAG_MAX_CHARS)" in body
assert "if (!tag) continue;" in body, "an empty token is dropped, never stored"
assert "t.toLowerCase() === tag.toLowerCase()" in body, "case-insensitive de-dupe"
assert "view.tags.length >= LABEL_TAGS_MAX_COUNT" in body, "the 10-chip cap"
assert "LABEL_TAGS_MAX_COUNT" in _fn(js, "paintLabelTags")
def test_chips_render_as_text_in_the_shared_tag_class() -> None:
"""Each chip is an ``<li class="source-label-tag">`` written with
``textContent`` (the XSS contract — owner text is never HTML) and the
class is the one the shared phase-89 tag rule styles, so the widget
adds NO tag-input CSS (task 03 step 1)."""
js = _js()
body = _fn(js, "paintLabelTags")
assert 'li.className = "source-label-tag";' in body
assert "li.textContent = tag;" in body
assert "innerHTML" not in body
selector = _css_selector_list(_css(), ".source-label-tag {")
assert ".git-source-hidden-count" in selector, (
"the chip joins the phase-89 tag rule (no new tag-input CSS)"
)
assert ".source-label-state" in selector, "the state tag shares it too"
# ---------------------------------------------------------------------------
# makeLabelRow — the clone
# ---------------------------------------------------------------------------
def test_make_label_row_clones_the_template_and_rewrites_the_ids() -> None:
"""One shared template, cloned per row: ``template.content`` +
``cloneNode(true)``, then the per-clone id rewrite (input id + the
matching ``<label for>``) and the ``aria-controls`` link — without it
every clone would duplicate the template's ids and every label would
aim at the first match (WCAG 1.3.1)."""
js = _js()
body = _fn(js, "makeLabelRow")
assert "labelRowTemplate.content" in body
assert "proto.cloneNode(true)" in body
assert 'input.id = `${base}-${seq}`;' in body
assert 'lbl.setAttribute("for", input.id);' in body
assert 'editor.id = `source-label-editor-${seq}`;' in body
assert 'toggleBtn.setAttribute("aria-controls", editor.id);' in body
assert 'codeEl.textContent = source;' in body, "the identity is textContent"
for fn in LABEL_FNS:
assert "innerHTML" not in _fn(js, fn), f"{fn} must never touch innerHTML"
def test_row_state_paint_names_the_source_on_every_control() -> None:
"""The state paint: the "named" tag + summary appear only when a label
is stored, Reset is visible only then (the phase-168 per-row reset),
and every control's ``aria-label`` names the row's source (the
phase-89/105 idiom — the clipped label stays reachable)."""
js = _js()
body = _fn(js, "paintLabelRowState")
assert "view.stateTag.hidden = !labelled" in body
assert "view.resetBtn.hidden = !labelled" in body
assert 'labelled ? "Edit name" : "Name this source"' in body
for label in (
"${labelled ? \"Edit\" : \"Set\"} the name, label and tags for source: ${view.source}",
"Save the name, label and tags for source: ${view.source}",
"Reset the name, label and tags of source: ${view.source}",
):
assert label in body, f"missing the aria-label template {label!r}"
def test_collapse_discards_the_draft_instead_of_staling_it() -> None:
"""Closing the editor refills it from the STORED label and clears the
status line (no request) — a closed row never claims an edit the
server never got; a request in flight freezes the row."""
js = _js()
body = _fn(js, "setLabelEditorOpen")
assert "if (view.saveInFlight) return;" in body
assert 'view.toggleBtn.setAttribute("aria-expanded", open ? "true" : "false");' in body
open_i = body.find("view.editor.hidden = !open;")
fill_i = body.find("fillLabelForm(view);")
clear_i = body.find('setLabelStatus(view, "", null);')
assert -1 < open_i < fill_i, "a collapse refills from the stored label"
assert fill_i < clear_i < body.find("updateLabelRowUI(view);")
def test_stored_shape_folds_nulls_to_the_empty_form() -> None:
"""``storedFromEntry`` folds the API's nulls / missing keys to ``\"\"``
and ``[]`` (a missing row and a cleared label are ONE state — D1), and
``hasStoredLabel`` is what drives the "named" tag and Reset."""
js = _js()
body = _fn(js, "storedFromEntry")
assert 'typeof e.display_name === "string"' in body
assert "Array.isArray(e.tags)" in body
stored = _fn(js, "hasStoredLabel")
assert "stored.display_name || stored.label || stored.tags.length" in stored
# ---------------------------------------------------------------------------
# styles.css — the reuse contract
# ---------------------------------------------------------------------------
def test_new_block_reuses_the_page_language() -> None:
"""The new rules stay on the page's own language: the field box is the
``.ignore-editor-token`` box (mono, the line border, the 44px floor),
the row's three buttons keep the ≥44px touch target, the empty state is
SHARED with ``.git-sources-empty``, and the dirty mark is the phase-168
``.is-dirty`` stripe."""
css = _css()
field = _css_rule(css, ".source-label-input")
assert "min-height: 44px" in field
assert "font-family: var(--mono)" in field
assert "border: 1px solid var(--line)" in field
for selector in (".source-label-toggle", ".source-label-save", ".source-label-reset"):
assert "min-height: 44px" in _css_rule(css, selector), f"{selector} lost the floor"
selector = _css_selector_list(css, ".source-labels-empty {")
assert ".git-sources-empty" in selector, "the empty-state rule is shared, not copied"
dirty = _css_rule(css, ".source-label-row.is-dirty > td:first-child")
assert "box-shadow: inset 3px 0 0 var(--brand)" in dirty, "the phase-168 stripe"
def test_new_css_uses_palette_variables_only() -> None:
"""No new hue, no CDN: every colour in the block is a palette
variable (the phase-08/92 monochrome invariant — a raw hex would not
follow a saved theme)."""
css = _css()
start = css.index("/* ---------- Source names, labels and tags (phase 191, task 03)")
end = css.index("/* ---------- History refresh button", start)
block = css[start:end]
assert block, "the labelled block exists"
offenders = re.findall(r"#[0-9a-fA-F]{3,8}\b", block)
assert not offenders, f"raw hex colours in the phase-191 block: {offenders}"
assert "url(" not in block, "no external asset (the no-CDN rule)"
assert "blur(" not in block, "the phase-08 no-blur anchor"