diff --git a/.agents/phases/todo/191_source_name_labels_tags/00_phase.md b/.agents/phases/complete/191_source_name_labels_tags/00_phase.md
similarity index 100%
rename from .agents/phases/todo/191_source_name_labels_tags/00_phase.md
rename to .agents/phases/complete/191_source_name_labels_tags/00_phase.md
diff --git a/.agents/phases/todo/191_source_name_labels_tags/01_source_labels_table.md b/.agents/phases/complete/191_source_name_labels_tags/01_source_labels_table.md
similarity index 100%
rename from .agents/phases/todo/191_source_name_labels_tags/01_source_labels_table.md
rename to .agents/phases/complete/191_source_name_labels_tags/01_source_labels_table.md
diff --git a/.agents/phases/todo/191_source_name_labels_tags/02_source_labels_api.md b/.agents/phases/complete/191_source_name_labels_tags/02_source_labels_api.md
similarity index 100%
rename from .agents/phases/todo/191_source_name_labels_tags/02_source_labels_api.md
rename to .agents/phases/complete/191_source_name_labels_tags/02_source_labels_api.md
diff --git a/.agents/phases/todo/191_source_name_labels_tags/03_source_labels_ui.md b/.agents/phases/complete/191_source_name_labels_tags/03_source_labels_ui.md
similarity index 100%
rename from .agents/phases/todo/191_source_name_labels_tags/03_source_labels_ui.md
rename to .agents/phases/complete/191_source_name_labels_tags/03_source_labels_ui.md
diff --git a/.agents/phases/todo/191_source_name_labels_tags/04_source_name_boost.md b/.agents/phases/complete/191_source_name_labels_tags/04_source_name_boost.md
similarity index 100%
rename from .agents/phases/todo/191_source_name_labels_tags/04_source_name_boost.md
rename to .agents/phases/complete/191_source_name_labels_tags/04_source_name_boost.md
diff --git a/.agents/phases/todo/191_source_name_labels_tags/05_prompt_surfacing.md b/.agents/phases/complete/191_source_name_labels_tags/05_prompt_surfacing.md
similarity index 100%
rename from .agents/phases/todo/191_source_name_labels_tags/05_prompt_surfacing.md
rename to .agents/phases/complete/191_source_name_labels_tags/05_prompt_surfacing.md
diff --git a/.agents/phases/todo/191_source_name_labels_tags/06_e2e.md b/.agents/phases/complete/191_source_name_labels_tags/06_e2e.md
similarity index 100%
rename from .agents/phases/todo/191_source_name_labels_tags/06_e2e.md
rename to .agents/phases/complete/191_source_name_labels_tags/06_e2e.md
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__00_phase.a1.err b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__00_phase.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__00_phase.a1.md b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__00_phase.a1.md
new file mode 100644
index 0000000..8d3c1fa
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__00_phase.a1.md
@@ -0,0 +1,10 @@
+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 ``, 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/`.
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__00_phase.a1.md.status b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__00_phase.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__00_phase.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__00_phase.a1.validate b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__00_phase.a1.validate
new file mode 100644
index 0000000..d224b5a
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__00_phase.a1.validate
@@ -0,0 +1,150 @@
+........................................................................ [ 1%]
+........................................................................ [ 3%]
+........................................................................ [ 4%]
+........................................................................ [ 6%]
+........................................................................ [ 7%]
+........................................................................ [ 9%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 13%]
+........................................................................ [ 15%]
+........................................................................ [ 16%]
+........................................................................ [ 18%]
+........................................................................ [ 19%]
+........................................................................ [ 21%]
+........................................................................ [ 22%]
+........................................................................ [ 24%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 28%]
+........................................................................ [ 30%]
+........................................................................ [ 31%]
+........................................................................ [ 33%]
+........................................................................ [ 34%]
+........................................................................ [ 36%]
+........................................................................ [ 37%]
+........................................................................ [ 39%]
+........................................................................ [ 40%]
+........................................................................ [ 42%]
+........................................................................ [ 43%]
+........................................................................ [ 45%]
+........................................................................ [ 46%]
+........................................................................ [ 48%]
+........................................................................ [ 49%]
+........................................................................ [ 51%]
+........................................................................ [ 52%]
+........................................................................ [ 54%]
+........................................................................ [ 55%]
+........................................................................ [ 57%]
+........................................................................ [ 58%]
+........................................................................ [ 60%]
+........................................................................ [ 61%]
+........................................................................ [ 63%]
+........................................................................ [ 65%]
+........................................................................ [ 66%]
+........................................................................ [ 68%]
+........................................................................ [ 69%]
+........................................................................ [ 71%]
+........................................................................ [ 72%]
+........................................................................ [ 74%]
+........................................................................ [ 75%]
+........................................................................ [ 77%]
+........................................................................ [ 78%]
+........................................................................ [ 80%]
+........................................................................ [ 81%]
+........................................................................ [ 83%]
+........................................................................ [ 84%]
+........................................................................ [ 86%]
+........................................................................ [ 87%]
+........................................................................ [ 89%]
+........................................................................ [ 90%]
+........................................................................ [ 92%]
+........................................................................ [ 93%]
+........................................................................ [ 95%]
+........................................................................ [ 96%]
+........................................................................ [ 98%]
+........................................................................ [ 99%]
+........... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/admin_settings.py 114 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 298 1 99%
+app/api/chat_images.py 52 0 100%
+app/api/chats.py 111 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 239 1 99%
+app/api/git_sources.py 259 0 100%
+app/api/health.py 10 0 100%
+app/api/source_labels.py 52 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 231 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 39 0 100%
+app/config.py 332 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/compression.py 59 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 43 0 100%
+app/core/errors.py 5 0 100%
+app/core/http_cache.py 22 0 100%
+app/core/logging.py 13 0 100%
+app/core/memory.py 32 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/response_budget.py 63 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/settings_catalog.py 18 0 100%
+app/core/settings_migrate.py 32 0 100%
+app/core/settings_overlay.py 117 0 100%
+app/core/theming.py 45 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 107 0 100%
+app/models.py 148 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 494 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 220 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 134 0 100%
+app/rag/git_sources.py 77 0 100%
+app/rag/importer.py 865 3 99%
+app/rag/llm.py 253 1 99%
+app/rag/md_images.py 63 0 100%
+app/rag/overview.py 82 0 100%
+app/rag/pdf.py 192 0 100%
+app/rag/prompts.py 178 0 100%
+app/rag/retriever.py 270 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_labels.py 87 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 58 0 100%
+app/rag/svg_text.py 111 0 100%
+app/rag/verbatim.py 160 0 100%
+app/schemas.py 576 0 100%
+--------------------------------------------------
+TOTAL 7160 16 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__01_source_labels_table.a1.err b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__01_source_labels_table.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__01_source_labels_table.a1.md b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__01_source_labels_table.a1.md
new file mode 100644
index 0000000..491bd85
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__01_source_labels_table.a1.md
@@ -0,0 +1,18 @@
+Task 01 complete. Report:
+
+**Implemented**
+- `app/models.py`: new `SourceLabel` model (D1 spec: `source` String(120) PK, `display_name`/`label` plain-Text nullable, `tags` JSONB NOT NULL default `'[]'`, `updated_at` now()/onupdate) + module docstring table entry; alias-only contract documented.
+- `alembic/versions/0030_source_labels.py` (rev `0030` ← `0029`): additive CREATE TABLE, no backfill, `DROP TABLE` downgrade; verified up/down/up on the dev DB.
+- `app/rag/source_labels.py`: `SourceLabelView`, `get_label`/`label_for`/`all_labels`, `set_label` (upsert, `_UNSET`-sentinel partial-update — omitted untouched, explicit `None` clears; flush-only), `delete_label`, `normalize_tags` (trim/drop-empties, ≤10 / ≤32 ValueErrors, first-case case-insensitive de-dupe), `question_name_hits` (D2: reused `name_hit_tokens` + `_normalize_name`, 4-char floor on both sides, no digit rule), `question_name_hit_sources` (one query).
+- **Defect fix (pre-existing latent)**: `test_chunks_hnsw_index.py::test_invalid_index_is_dropped_and_rebuilt` stamped below head without dropping later migrations' tables — once 0030 landed, every following `upgrade(head)` died on `source_labels already exists` (221 cascade errors). Now genuinely `downgrade("0029")` before the stamp; consistent with AGENTS rule 16.
+
+**Gates** (all green)
+- `uv run pytest --cov=app --cov-report=term-missing` → **4667 passed**, TOTAL **99%**, `app/rag/source_labels.py` **100%**; dev DB left at `0030 (head)`.
+- `tests/unit/test_source_labels.py` 31 pins (D2 every branch, partial-update shape, tag guards) + `tests/integration/test_migration_0030.py` 6 pins (column contract, no-backfill, PK/NOT-NULL, A13 round-trip) pass.
+- `uv run ruff check .` clean; `uv run pyright` clean; `tests/e2e/test_smoke.py` 3 passed; `alembic upgrade head`/`downgrade 0029`/re-upgrade clean.
+
+**Docs**: no doc impact (pure data layer — README/AGENTS updates are task 06's per the phase plan; AGENTS rule 16's schema-move convention remains accurate).
+
+**Decisions**: `normalize_tags` count-guard runs before the de-dupe (11 typed = rejected, documented); whitespace-tag strictness is left to the task-02 API boundary per the task text.
+
+**Next pending task**: `191_source_name_labels_tags/02_source_labels_api.md`.
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__01_source_labels_table.a1.md.status b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__01_source_labels_table.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__01_source_labels_table.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__01_source_labels_table.a1.validate b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__01_source_labels_table.a1.validate
new file mode 100644
index 0000000..98040ab
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__01_source_labels_table.a1.validate
@@ -0,0 +1,147 @@
+........................................................................ [ 1%]
+........................................................................ [ 3%]
+........................................................................ [ 4%]
+........................................................................ [ 6%]
+........................................................................ [ 7%]
+........................................................................ [ 9%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 13%]
+........................................................................ [ 15%]
+........................................................................ [ 16%]
+........................................................................ [ 18%]
+........................................................................ [ 20%]
+........................................................................ [ 21%]
+........................................................................ [ 23%]
+........................................................................ [ 24%]
+........................................................................ [ 26%]
+........................................................................ [ 27%]
+........................................................................ [ 29%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 33%]
+........................................................................ [ 35%]
+........................................................................ [ 37%]
+........................................................................ [ 38%]
+........................................................................ [ 40%]
+........................................................................ [ 41%]
+........................................................................ [ 43%]
+........................................................................ [ 44%]
+........................................................................ [ 46%]
+........................................................................ [ 47%]
+........................................................................ [ 49%]
+........................................................................ [ 50%]
+........................................................................ [ 52%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 57%]
+........................................................................ [ 58%]
+........................................................................ [ 60%]
+........................................................................ [ 61%]
+........................................................................ [ 63%]
+........................................................................ [ 64%]
+........................................................................ [ 66%]
+........................................................................ [ 67%]
+........................................................................ [ 69%]
+........................................................................ [ 70%]
+........................................................................ [ 72%]
+........................................................................ [ 74%]
+........................................................................ [ 75%]
+........................................................................ [ 77%]
+........................................................................ [ 78%]
+........................................................................ [ 80%]
+........................................................................ [ 81%]
+........................................................................ [ 83%]
+........................................................................ [ 84%]
+........................................................................ [ 86%]
+........................................................................ [ 87%]
+........................................................................ [ 89%]
+........................................................................ [ 91%]
+........................................................................ [ 92%]
+........................................................................ [ 94%]
+........................................................................ [ 95%]
+........................................................................ [ 97%]
+........................................................................ [ 98%]
+........................................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/admin_settings.py 114 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 292 1 99%
+app/api/chat_images.py 52 0 100%
+app/api/chats.py 111 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 239 1 99%
+app/api/git_sources.py 259 0 100%
+app/api/health.py 10 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 231 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 39 0 100%
+app/config.py 325 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/compression.py 59 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 43 0 100%
+app/core/errors.py 5 0 100%
+app/core/http_cache.py 22 0 100%
+app/core/logging.py 13 0 100%
+app/core/memory.py 32 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/response_budget.py 63 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/settings_catalog.py 18 0 100%
+app/core/settings_migrate.py 32 0 100%
+app/core/settings_overlay.py 117 0 100%
+app/core/theming.py 45 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 105 0 100%
+app/models.py 148 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 494 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 220 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 134 0 100%
+app/rag/git_sources.py 77 0 100%
+app/rag/importer.py 865 3 99%
+app/rag/llm.py 253 1 99%
+app/rag/md_images.py 63 0 100%
+app/rag/overview.py 78 0 100%
+app/rag/pdf.py 192 0 100%
+app/rag/prompts.py 162 0 100%
+app/rag/retriever.py 259 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_labels.py 87 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 58 0 100%
+app/rag/svg_text.py 111 0 100%
+app/rag/verbatim.py 160 0 100%
+app/schemas.py 549 0 100%
+--------------------------------------------------
+TOTAL 7035 16 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__02_source_labels_api.a1.err b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__02_source_labels_api.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__02_source_labels_api.a1.md b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__02_source_labels_api.a1.md
new file mode 100644
index 0000000..da4f54e
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__02_source_labels_api.a1.md
@@ -0,0 +1,19 @@
+Everything is green. Final report:
+
+**Implemented (phase 191 task 02)**
+- `app/schemas.py` — `SourceLabelOut` / `SourceLabelList` / `SourceLabelUpdate` (`extra="forbid"`, trim-*before*-cap validators, `_Tag` item cap = the house `_Chip` item-cap convention).
+- `app/api/source_labels.py` (NEW) — admin-gated router: catalogue `GET` (`effective_sources` names via `resolve_source_name` — git + local + the env fallback — ∪ `SELECT DISTINCT documents.source`, de-duped, sorted, ONE `all_labels` read), `PUT` (404 arm first → `normalize_tags` `ValueError`→422 → `set_label` from `model_fields_set`: omitted keeps / `null` clears / value sets, router commits), `DELETE` (404 when no row; 200 body = the entry's cleared state).
+- `app/main.py` — registered in the admin-router block; `scripts/api_payload_audit.py` — the new endpoint in the byte audit.
+- `tests/integration/test_source_labels_api.py` (NEW, 26 tests): 403 gate for anonymous AND token users, the git+local+upload union (sorted, no dupes, env-only arm, exactly 3 SELECTs and no `documents.content` read), round trip, partial-update shape, every cap as the framework's 422 with the row byte-identical, both 404 arms, the `ValueError`→422 funnel, and the D1 "a rename writes nothing but the label row" arm.
+
+**Gates** — `uv run pytest` → **4693 passed**; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%**, `app/api/source_labels.py` **100%**, `app/schemas.py` **100%**; `uv run ruff check .` → clean; `uv run pyright` → **0 errors**; E2E sanity in isolation: `tests/e2e/test_smoke.py`, `tests/e2e/test_git_sources_admin.py` pass.
+
+**Docs** — README gains "Source names, labels and tags (phase 191)" (route table + the alias-only contract); AGENTS.md gains rule 17 (alias-never-identity, reuse `resolve_source_name`, 404/no-partial-write, the pin file).
+
+**Decisions / deviations**
+- Router uses the house prefix split (`prefix="/source-labels"` + include `prefix="/api"`) — wire paths are exactly D5's.
+- `DELETE` answers 200 with the cleared `SourceLabelOut` (the task's "200"; body mirrors phase-168 `DELETE /api/admin/settings/{key}` returning the reset item).
+- A whitespace-only/empty tag is the framework's 422 (trim runs before `min_length`), as this task's pins require; `normalize_tags` keeps its drop-empties contract for helper callers, still owns the case-insensitive de-dupe, and its `ValueError`→422 belt is pinned by substituting a raising normalizer.
+- The GET lists *sources* (registry ∪ indexed documents), not orphan label rows; `DELETE` never consults the registry, so an orphan row stays clearable (documented in the route docstring).
+
+**Next pending task:** `191_source_name_labels_tags/03_source_labels_ui.md`.
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__02_source_labels_api.a1.md.status b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__02_source_labels_api.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__02_source_labels_api.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__02_source_labels_api.a1.validate b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__02_source_labels_api.a1.validate
new file mode 100644
index 0000000..223afd8
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__02_source_labels_api.a1.validate
@@ -0,0 +1,149 @@
+........................................................................ [ 1%]
+........................................................................ [ 3%]
+........................................................................ [ 4%]
+........................................................................ [ 6%]
+........................................................................ [ 7%]
+........................................................................ [ 9%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 13%]
+........................................................................ [ 15%]
+........................................................................ [ 16%]
+........................................................................ [ 18%]
+........................................................................ [ 19%]
+........................................................................ [ 21%]
+........................................................................ [ 23%]
+........................................................................ [ 24%]
+........................................................................ [ 26%]
+........................................................................ [ 27%]
+........................................................................ [ 29%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 33%]
+........................................................................ [ 35%]
+........................................................................ [ 36%]
+........................................................................ [ 38%]
+........................................................................ [ 39%]
+........................................................................ [ 41%]
+........................................................................ [ 42%]
+........................................................................ [ 44%]
+........................................................................ [ 46%]
+........................................................................ [ 47%]
+........................................................................ [ 49%]
+........................................................................ [ 50%]
+........................................................................ [ 52%]
+........................................................................ [ 53%]
+........................................................................ [ 55%]
+........................................................................ [ 56%]
+........................................................................ [ 58%]
+........................................................................ [ 59%]
+........................................................................ [ 61%]
+........................................................................ [ 62%]
+........................................................................ [ 64%]
+........................................................................ [ 65%]
+........................................................................ [ 67%]
+........................................................................ [ 69%]
+........................................................................ [ 70%]
+........................................................................ [ 72%]
+........................................................................ [ 73%]
+........................................................................ [ 75%]
+........................................................................ [ 76%]
+........................................................................ [ 78%]
+........................................................................ [ 79%]
+........................................................................ [ 81%]
+........................................................................ [ 82%]
+........................................................................ [ 84%]
+........................................................................ [ 85%]
+........................................................................ [ 87%]
+........................................................................ [ 88%]
+........................................................................ [ 90%]
+........................................................................ [ 92%]
+........................................................................ [ 93%]
+........................................................................ [ 95%]
+........................................................................ [ 96%]
+........................................................................ [ 98%]
+........................................................................ [ 99%]
+............. [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/admin_settings.py 114 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 292 1 99%
+app/api/chat_images.py 52 0 100%
+app/api/chats.py 111 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 239 1 99%
+app/api/git_sources.py 259 0 100%
+app/api/health.py 10 0 100%
+app/api/source_labels.py 52 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 231 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 39 0 100%
+app/config.py 325 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/compression.py 59 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 43 0 100%
+app/core/errors.py 5 0 100%
+app/core/http_cache.py 22 0 100%
+app/core/logging.py 13 0 100%
+app/core/memory.py 32 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/response_budget.py 63 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/settings_catalog.py 18 0 100%
+app/core/settings_migrate.py 32 0 100%
+app/core/settings_overlay.py 117 0 100%
+app/core/theming.py 45 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 107 0 100%
+app/models.py 148 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 494 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 220 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 134 0 100%
+app/rag/git_sources.py 77 0 100%
+app/rag/importer.py 865 3 99%
+app/rag/llm.py 253 1 99%
+app/rag/md_images.py 63 0 100%
+app/rag/overview.py 78 0 100%
+app/rag/pdf.py 192 0 100%
+app/rag/prompts.py 162 0 100%
+app/rag/retriever.py 259 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_labels.py 87 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 58 0 100%
+app/rag/svg_text.py 111 0 100%
+app/rag/verbatim.py 160 0 100%
+app/schemas.py 576 0 100%
+--------------------------------------------------
+TOTAL 7116 16 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__03_source_labels_ui.a1.err b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__03_source_labels_ui.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__03_source_labels_ui.a1.md b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__03_source_labels_ui.a1.md
new file mode 100644
index 0000000..e6cd9fc
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__03_source_labels_ui.a1.md
@@ -0,0 +1,15 @@
+**Implemented (phase 191 task 03 — the per-source name/label/tags editor, D6)**
+- `frontend/index.html`: new **Names, labels and tags** card inside the admin-gated `#git-sources-content` — bounded `.table-wrap` table (sticky header), `role="alert"` load-error line, empty state, and ONE shared ``: read-only mono identity `` + Name ≤120 / Label ≤200 / Tags ≤32 fields (real ``) + Save/Reset + `role="status"` save-state line.
+- `frontend/assets/git-sources.js`: `loadLabels` → `renderLabelRows` → `makeLabelRow` (template clone + per-clone id/`aria-controls` rewrite); row list comes from `GET /api/source-labels` (so git, local **and** upload/document-only sources all get a row); token input (Enter/comma adds a chip, Backspace drops the last, ≤10×≤32, case-insensitive de-dupe); per-row dirty paint (`.is-dirty` + "unsaved changes"); per-row `saveInFlight` guard; Save `PUT` / Reset `DELETE` re-read the row **from the response** (no second GET), 404 → "That source no longer exists…", `finally` never leaves "Saving…"; collapsing discards the draft; refreshed at boot / view-refresh / add / remove / finished upload; anonymous never fetches it. Existing sync/upload/git-row flows untouched.
+- `frontend/assets/styles.css`: new block, palette variables only; `.source-label-state` + `.source-label-tag` **joined** the phase-89 tag rule (no bespoke tag-input CSS); `.source-labels-empty` shares `.git-sources-empty`; 44px floors.
+- New `tests/unit/test_source_labels_editor_ui.py` (25 marker pins: markup, alias contract, load/save/reset/404 note, token rules, clone, CSS reuse, no-hex).
+- Fixed two stale selector-list pins (phase-105/135) broken by the joined tag rule; docs: README sources section now documents the editor; AGENTS.md rule 17 gains the UI clause.
+
+**Gates** — `uv run pytest --cov=app --cov-report=term-missing` → **4718 passed**, `TOTAL 99%`; `uv run ruff check .` → **All checks passed**; `uv run pyright` → **0 errors**. E2E in isolation (`--no-cov`): smoke 3, git_sources_admin 6, git_sources_card_rhythm 4, source_ignore_paths 6, hidden_folders_toggle 6, archive_upload_sources 5, git_source_branch 4, git_source_tokens 4 — **all pass**. Behavior verified in a real browser with a temporary 5-scenario Playwright scratch suite (3 source kinds render, chip typing + save→DB + reset→DB, reload persistence, no horizontal overflow at 375px, anonymous fetches nothing) — scratch file deleted; dev DB left clean.
+
+**Notable decisions / deviations**
+- Row list = the `GET /api/source-labels` catalogue (per the task), rendered as its own table card rather than a column of `#git-sources-table`, so a source with documents but no registry row is still nameable.
+- The task's "phase-89 token input" precedent does not exist as a chip widget (phase 89 ships a one-path-per-line textarea), so the tags widget's *behavior* is new while its *styling* reuses the house tag rule + the `.ignore-editor-token` field box; no new hue, no CDN.
+- Kept the phase-77 `() => loadSources()` refresh listener verbatim (pinned) and added a second `bor:view-refresh` listener for the label list — an unrelated table reload (ignore-paths/hidden saves) therefore never wipes a half-typed name draft.
+
+**Next pending task:** `191_source_name_labels_tags/04_source_name_boost.md`.
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__03_source_labels_ui.a1.md.status b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__03_source_labels_ui.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__03_source_labels_ui.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__03_source_labels_ui.a1.validate b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__03_source_labels_ui.a1.validate
new file mode 100644
index 0000000..0af60a0
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__03_source_labels_ui.a1.validate
@@ -0,0 +1,149 @@
+........................................................................ [ 1%]
+........................................................................ [ 3%]
+........................................................................ [ 4%]
+........................................................................ [ 6%]
+........................................................................ [ 7%]
+........................................................................ [ 9%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 13%]
+........................................................................ [ 15%]
+........................................................................ [ 16%]
+........................................................................ [ 18%]
+........................................................................ [ 19%]
+........................................................................ [ 21%]
+........................................................................ [ 22%]
+........................................................................ [ 24%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 28%]
+........................................................................ [ 30%]
+........................................................................ [ 32%]
+........................................................................ [ 33%]
+........................................................................ [ 35%]
+........................................................................ [ 36%]
+........................................................................ [ 38%]
+........................................................................ [ 39%]
+........................................................................ [ 41%]
+........................................................................ [ 42%]
+........................................................................ [ 44%]
+........................................................................ [ 45%]
+........................................................................ [ 47%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 51%]
+........................................................................ [ 53%]
+........................................................................ [ 54%]
+........................................................................ [ 56%]
+........................................................................ [ 57%]
+........................................................................ [ 59%]
+........................................................................ [ 61%]
+........................................................................ [ 62%]
+........................................................................ [ 64%]
+........................................................................ [ 65%]
+........................................................................ [ 67%]
+........................................................................ [ 68%]
+........................................................................ [ 70%]
+........................................................................ [ 71%]
+........................................................................ [ 73%]
+........................................................................ [ 74%]
+........................................................................ [ 76%]
+........................................................................ [ 77%]
+........................................................................ [ 79%]
+........................................................................ [ 80%]
+........................................................................ [ 82%]
+........................................................................ [ 83%]
+........................................................................ [ 85%]
+........................................................................ [ 86%]
+........................................................................ [ 88%]
+........................................................................ [ 90%]
+........................................................................ [ 91%]
+........................................................................ [ 93%]
+........................................................................ [ 94%]
+........................................................................ [ 96%]
+........................................................................ [ 97%]
+........................................................................ [ 99%]
+...................................... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/admin_settings.py 114 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 292 1 99%
+app/api/chat_images.py 52 0 100%
+app/api/chats.py 111 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 239 1 99%
+app/api/git_sources.py 259 0 100%
+app/api/health.py 10 0 100%
+app/api/source_labels.py 52 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 231 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 39 0 100%
+app/config.py 325 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/compression.py 59 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 43 0 100%
+app/core/errors.py 5 0 100%
+app/core/http_cache.py 22 0 100%
+app/core/logging.py 13 0 100%
+app/core/memory.py 32 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/response_budget.py 63 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/settings_catalog.py 18 0 100%
+app/core/settings_migrate.py 32 0 100%
+app/core/settings_overlay.py 117 0 100%
+app/core/theming.py 45 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 107 0 100%
+app/models.py 148 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 494 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 220 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 134 0 100%
+app/rag/git_sources.py 77 0 100%
+app/rag/importer.py 865 3 99%
+app/rag/llm.py 253 1 99%
+app/rag/md_images.py 63 0 100%
+app/rag/overview.py 78 0 100%
+app/rag/pdf.py 192 0 100%
+app/rag/prompts.py 162 0 100%
+app/rag/retriever.py 259 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_labels.py 87 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 58 0 100%
+app/rag/svg_text.py 111 0 100%
+app/rag/verbatim.py 160 0 100%
+app/schemas.py 576 0 100%
+--------------------------------------------------
+TOTAL 7116 16 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.a1.err b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.a1.md b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.a1.md
new file mode 100644
index 0000000..b3ccd33
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.a1.md
@@ -0,0 +1,19 @@
+**Task 04 complete — all gates green.**
+
+**Implemented (D2/D3 source-name selection boost):**
+- `app/config.py` — `source_name_hit_bonus: float = 0.005` knob + `>= 0` validator (`"source_name_hit_bonus must be >= 0 (0 = off)"`); env `BOR_SOURCE_NAME_HIT_BONUS`.
+- `app/core/settings_catalog.py` — "Source name hit bonus" row in *Retrieval & honesty*, flags mirrored from `name_hit_bonus` (plain, live, overridable).
+- `app/rag/retriever.py` — `RetrievedChunk.source_name_hit: bool = False`; `fuse` OR-merges it on the double-hit; `_selection_order(chunks, bonus, source_bonus=0.0)` gains `any_source_hit`, the second `+ source_bonus` term, and the extended re-sort condition (neither bonus active → no re-sort, byte-identical); `weak_hit_titles` / `select_suggested` / `select_related` gain `source_bonus: float | None = None` pulled from settings.
+- `app/api/chat.py` — ONE `question_name_hit_sources(step_db, question)` read per turn after `retrieve`, before the first selection call; flag loop over the retrieved chunks (no per-chunk query); per-turn log line untouched.
+- Battery record written to `.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.battery.md` (task/phase files are harness-owned, so the three-run record lives in the reports dir): baseline vs `bonus=0` vs default-0.005 (no label rows) → **all three outputs line-for-line identical** (`diff` exit 0).
+
+**Tests/lint/coverage:**
+- 8 new `test_retriever.py` pins (kill-switch byte-identity, inert-without-hits, equal-fused lift, bounded +0.010 two-bonus, three helpers' settings defaults + both kill arms, 3 `fuse` pins); phase-119 untouched-fields pin extended; 2 `test_config.py` pins; 1 catalogue-completeness row pin.
+- `uv run pytest --cov=app --cov-report=term-missing` → **4729 passed**, `app/` coverage **99%** (>90%).
+- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
+
+**Docs:** README — phase-191 section gains the "name focuses retrieval" paragraph + `BOR_SOURCE_NAME_HIT_BONUS` settings-table row; `.env.example` gains the knob; `AGENTS.md` rule 17 gains the task-04 boost clause (per the phase's docs note).
+
+**Notes:** battery app is the empty dev KB (0 docs → all deflects) — the byte-identity claim holds there; the observable-boost evidence is the unit pins + the task-06 E2E arm, per the task's testing mandate. No separate integration module (task explicitly defers the flag-loop arm to task 06; chat.py stays at 99%).
+
+**Next pending task:** `191_source_name_labels_tags/05_prompt_surfacing.md`.
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.a1.md.status b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.a1.validate b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.a1.validate
new file mode 100644
index 0000000..248e06e
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.a1.validate
@@ -0,0 +1,149 @@
+........................................................................ [ 1%]
+........................................................................ [ 3%]
+........................................................................ [ 4%]
+........................................................................ [ 6%]
+........................................................................ [ 7%]
+........................................................................ [ 9%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 13%]
+........................................................................ [ 15%]
+........................................................................ [ 16%]
+........................................................................ [ 18%]
+........................................................................ [ 19%]
+........................................................................ [ 21%]
+........................................................................ [ 22%]
+........................................................................ [ 24%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 28%]
+........................................................................ [ 30%]
+........................................................................ [ 31%]
+........................................................................ [ 33%]
+........................................................................ [ 35%]
+........................................................................ [ 36%]
+........................................................................ [ 38%]
+........................................................................ [ 39%]
+........................................................................ [ 41%]
+........................................................................ [ 42%]
+........................................................................ [ 44%]
+........................................................................ [ 45%]
+........................................................................ [ 47%]
+........................................................................ [ 48%]
+........................................................................ [ 50%]
+........................................................................ [ 51%]
+........................................................................ [ 53%]
+........................................................................ [ 54%]
+........................................................................ [ 56%]
+........................................................................ [ 57%]
+........................................................................ [ 59%]
+........................................................................ [ 60%]
+........................................................................ [ 62%]
+........................................................................ [ 63%]
+........................................................................ [ 65%]
+........................................................................ [ 66%]
+........................................................................ [ 68%]
+........................................................................ [ 70%]
+........................................................................ [ 71%]
+........................................................................ [ 73%]
+........................................................................ [ 74%]
+........................................................................ [ 76%]
+........................................................................ [ 77%]
+........................................................................ [ 79%]
+........................................................................ [ 80%]
+........................................................................ [ 82%]
+........................................................................ [ 83%]
+........................................................................ [ 85%]
+........................................................................ [ 86%]
+........................................................................ [ 88%]
+........................................................................ [ 89%]
+........................................................................ [ 91%]
+........................................................................ [ 92%]
+........................................................................ [ 94%]
+........................................................................ [ 95%]
+........................................................................ [ 97%]
+........................................................................ [ 98%]
+................................................. [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/admin_settings.py 114 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 297 2 99%
+app/api/chat_images.py 52 0 100%
+app/api/chats.py 111 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 239 1 99%
+app/api/git_sources.py 259 0 100%
+app/api/health.py 10 0 100%
+app/api/source_labels.py 52 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 231 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 39 0 100%
+app/config.py 332 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/compression.py 59 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 43 0 100%
+app/core/errors.py 5 0 100%
+app/core/http_cache.py 22 0 100%
+app/core/logging.py 13 0 100%
+app/core/memory.py 32 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/response_budget.py 63 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/settings_catalog.py 18 0 100%
+app/core/settings_migrate.py 32 0 100%
+app/core/settings_overlay.py 117 0 100%
+app/core/theming.py 45 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 107 0 100%
+app/models.py 148 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 494 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 220 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 134 0 100%
+app/rag/git_sources.py 77 0 100%
+app/rag/importer.py 865 3 99%
+app/rag/llm.py 253 1 99%
+app/rag/md_images.py 63 0 100%
+app/rag/overview.py 78 0 100%
+app/rag/pdf.py 192 0 100%
+app/rag/prompts.py 162 0 100%
+app/rag/retriever.py 270 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_labels.py 87 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 58 0 100%
+app/rag/svg_text.py 111 0 100%
+app/rag/verbatim.py 160 0 100%
+app/schemas.py 576 0 100%
+--------------------------------------------------
+TOTAL 7139 17 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.battery.md b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.battery.md
new file mode 100644
index 0000000..3eee644
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__04_source_name_boost.battery.md
@@ -0,0 +1,17 @@
+# Phase 191 task 04 — retrieval battery record (the phase-119 convention)
+
+Command (all three arms): `uv run python -m scripts.eval_retrieval --from-file tests/fixtures/retrieval_battery.txt`
+Run against the battery app = the dev Postgres (`brain-of-reese_db_1`) with **0 documents / 0 chunks / 0 `source_labels` rows** — every question deflects (`best_cosine=0.0000 fts_hits=0 -> LOW`). That is the state the task's no-row baseline names: with no label rows the D2 rule can produce no hit, so all three arms must — and do — agree line-for-line.
+
+| Arm | Setting | Output | Result |
+| --- | --- | --- | --- |
+| (a) baseline (pre-change) | shipped defaults | `/tmp/battery191/baseline.txt` (29 lines) | reference |
+| (b) post-change kill switch | `BOR_SOURCE_NAME_HIT_BONUS=0` | `/tmp/battery191/post_bonus0.txt` | `diff (a) (b)` = **line-for-line identical** |
+| (c) post-change default | `source_name_hit_bonus = 0.005` (no label rows exist) | `/tmp/battery191/post_default.txt` | `diff (a) (c)` = **line-for-line identical** |
+
+The observable-boost evidence therefore lives in the unit pins (the
+kill-switch byte-identity, the bounded two-bonus application, the fuse
+OR-merge and the three settings-default helpers in
+`tests/unit/test_retriever.py`), and the named-source surfacing arm lands
+with the task-06 E2E suite (`tests/e2e/test_source_name_labels.py`),
+which runs its own seeded app fixture.
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__05_prompt_surfacing.a1.err b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__05_prompt_surfacing.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__05_prompt_surfacing.a1.md b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__05_prompt_surfacing.a1.md
new file mode 100644
index 0000000..5497b2c
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__05_prompt_surfacing.a1.md
@@ -0,0 +1,20 @@
+**Task 05 (`191_source_name_labels_tags/05_prompt_surfacing.md`) — done**
+
+**Implemented**
+- `app/rag/prompts.py`: `build_high_prompt(..., names: Mapping[str, SourceLabelView] | None = None)`; the `` head gains additive ` name="…"` (right after `source="…"`) then ` label="…"`, locked order `source→name?→label?→path→title→date`; new `_attr` escaper; unset field adds NOTHING.
+- `app/rag/overview.py`: `build_overview_prompt(..., names: Mapping[str, str] | None = None)` swaps the display name into the lite model's INPUT line only; `regenerate_overview` performs ONE `all_labels` read (after the zero-doc gate) and derives the name map from those views — stored row + `Sources:` tail stay derived-name.
+- `app/api/chat.py`: task-04 call site refactored to the shared `labels = all_labels(step_db)` (pure `question_name_hits` inlined, flag loop unchanged) and `plan_turn(names=labels)` → `build_high_prompt(names=…)`; `question_name_hit_sources` no longer runs in a turn. `retriever.py`/`source_labels.py` docstrings aligned.
+- Tests: 12 pins in `tests/unit/test_prompts.py` (kill pins None/{}/no-entry, attribute-order, label-only, escaping, per-source scoping, LOW-prompt clean), 12 in `tests/unit/test_overview.py` (kill pins, line swap, cap, `regenerate_*` display-name/inventory/single-read + marker pins), 5 in `tests/unit/test_chat_gate.py` (plan_turn names/kill/LOW + chat.py one-read marker pin + an endpoint pin that the ONE read sets both the boost flag and the prompt attributes).
+
+**Gates**
+- `uv run pytest --cov=app --cov-report=term-missing` → **4762 passed**, TOTAL 99% (`prompts.py`/`overview.py`/`source_labels.py` 100%, `chat.py` 99% — only the pre-existing 503-cap line).
+- `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
+- Sanity E2E (unaffected-by-design check): `uv run pytest tests/e2e/test_chat_rag.py tests/e2e/test_kb_overview.py tests/e2e/test_name_signal_read_chips.py --no-cov` → 11 passed.
+
+**Docs**: README — new "The name reaches the model, too." paragraph in the phase-191 section; AGENTS.md rule 17 — task-04 clause corrected to the ONE `all_labels` read and extended with the task-05 surfacing contract.
+
+**Decisions / deviations**
+- Phase-file D4 sketched `display_name (label) — path` prose; task 05 (the executable spec) specifies the additive `name=`/`label=` attributes — implemented per task 05.
+- Verified L934: the block writer never escaped `source`/`path`/`title`, so there was no shared helper. Added `_attr` (`&`,`<`,`>`,`"`) applied ONLY to the two new owner-supplied values — escaping the identity attributes would have broken the "label-less block byte-identical" criterion. Single quotes deliberately kept (`Reese's` stays readable).
+
+**Next pending task**: `191_source_name_labels_tags/06_e2e_and_gates.md`.
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__05_prompt_surfacing.a1.md.status b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__05_prompt_surfacing.a1.md.status
new file mode 100644
index 0000000..9766475
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__05_prompt_surfacing.a1.md.status
@@ -0,0 +1 @@
+ok
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__05_prompt_surfacing.a1.validate b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__05_prompt_surfacing.a1.validate
new file mode 100644
index 0000000..dd04a4d
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__05_prompt_surfacing.a1.validate
@@ -0,0 +1,150 @@
+........................................................................ [ 1%]
+........................................................................ [ 3%]
+........................................................................ [ 4%]
+........................................................................ [ 6%]
+........................................................................ [ 7%]
+........................................................................ [ 9%]
+........................................................................ [ 10%]
+........................................................................ [ 12%]
+........................................................................ [ 13%]
+........................................................................ [ 15%]
+........................................................................ [ 16%]
+........................................................................ [ 18%]
+........................................................................ [ 19%]
+........................................................................ [ 21%]
+........................................................................ [ 22%]
+........................................................................ [ 24%]
+........................................................................ [ 25%]
+........................................................................ [ 27%]
+........................................................................ [ 28%]
+........................................................................ [ 30%]
+........................................................................ [ 31%]
+........................................................................ [ 33%]
+........................................................................ [ 34%]
+........................................................................ [ 36%]
+........................................................................ [ 37%]
+........................................................................ [ 39%]
+........................................................................ [ 40%]
+........................................................................ [ 42%]
+........................................................................ [ 43%]
+........................................................................ [ 45%]
+........................................................................ [ 46%]
+........................................................................ [ 48%]
+........................................................................ [ 49%]
+........................................................................ [ 51%]
+........................................................................ [ 52%]
+........................................................................ [ 54%]
+........................................................................ [ 55%]
+........................................................................ [ 57%]
+........................................................................ [ 58%]
+........................................................................ [ 60%]
+........................................................................ [ 61%]
+........................................................................ [ 63%]
+........................................................................ [ 65%]
+........................................................................ [ 66%]
+........................................................................ [ 68%]
+........................................................................ [ 69%]
+........................................................................ [ 71%]
+........................................................................ [ 72%]
+........................................................................ [ 74%]
+........................................................................ [ 75%]
+........................................................................ [ 77%]
+........................................................................ [ 78%]
+........................................................................ [ 80%]
+........................................................................ [ 81%]
+........................................................................ [ 83%]
+........................................................................ [ 84%]
+........................................................................ [ 86%]
+........................................................................ [ 87%]
+........................................................................ [ 89%]
+........................................................................ [ 90%]
+........................................................................ [ 92%]
+........................................................................ [ 93%]
+........................................................................ [ 95%]
+........................................................................ [ 96%]
+........................................................................ [ 98%]
+........................................................................ [ 99%]
+.......... [100%]
+=============================== warnings summary ===============================
+.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
+ /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
+ from starlette.testclient import TestClient as TestClient # noqa
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+================================ tests coverage ================================
+_______________ coverage: platform linux, python 3.13.13-final-0 _______________
+
+Name Stmts Miss Cover
+--------------------------------------------------
+app/__init__.py 1 0 100%
+app/api/__init__.py 0 0 100%
+app/api/admin_settings.py 114 0 100%
+app/api/auth.py 52 0 100%
+app/api/chat.py 298 1 99%
+app/api/chat_images.py 52 0 100%
+app/api/chats.py 111 0 100%
+app/api/config.py 13 0 100%
+app/api/doc_drafts.py 99 0 100%
+app/api/docs.py 239 1 99%
+app/api/git_sources.py 259 0 100%
+app/api/health.py 10 0 100%
+app/api/source_labels.py 52 0 100%
+app/api/steering.py 42 0 100%
+app/api/suggestions.py 33 0 100%
+app/api/sync.py 231 0 100%
+app/api/tokens.py 40 0 100%
+app/api/ui_settings.py 39 0 100%
+app/config.py 332 0 100%
+app/core/__init__.py 0 0 100%
+app/core/auth.py 45 0 100%
+app/core/caching.py 124 0 100%
+app/core/compression.py 59 0 100%
+app/core/debugging.py 29 2 93%
+app/core/docs_push.py 43 0 100%
+app/core/errors.py 5 0 100%
+app/core/http_cache.py 22 0 100%
+app/core/logging.py 13 0 100%
+app/core/memory.py 32 0 100%
+app/core/rate_limit.py 44 0 100%
+app/core/response_budget.py 63 0 100%
+app/core/security_headers.py 20 0 100%
+app/core/settings_catalog.py 18 0 100%
+app/core/settings_migrate.py 32 0 100%
+app/core/settings_overlay.py 117 0 100%
+app/core/theming.py 45 0 100%
+app/core/tokens.py 44 0 100%
+app/db.py 22 0 100%
+app/main.py 107 0 100%
+app/models.py 148 0 100%
+app/rag/__init__.py 0 0 100%
+app/rag/agent.py 494 1 99%
+app/rag/archive_upload.py 134 0 100%
+app/rag/chunker.py 220 4 98%
+app/rag/doc_dates.py 18 0 100%
+app/rag/folder_summaries.py 134 0 100%
+app/rag/git_sources.py 77 0 100%
+app/rag/importer.py 865 3 99%
+app/rag/llm.py 253 1 99%
+app/rag/md_images.py 63 0 100%
+app/rag/overview.py 82 0 100%
+app/rag/pdf.py 192 0 100%
+app/rag/prompts.py 178 0 100%
+app/rag/retriever.py 270 3 99%
+app/rag/scaffolding.py 55 0 100%
+app/rag/source_labels.py 87 0 100%
+app/rag/source_removal.py 41 0 100%
+app/rag/sources_meta.py 16 0 100%
+app/rag/suggestions.py 27 0 100%
+app/rag/summarizer.py 58 0 100%
+app/rag/svg_text.py 111 0 100%
+app/rag/verbatim.py 160 0 100%
+app/schemas.py 576 0 100%
+--------------------------------------------------
+TOTAL 7160 16 99%
+coverage gate: app/ 99% (>90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__06_e2e.a1.err b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__06_e2e.a1.err
new file mode 100644
index 0000000..e69de29
diff --git a/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__06_e2e.a1.md b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__06_e2e.a1.md
new file mode 100644
index 0000000..874f145
--- /dev/null
+++ b/.agents/reports/191_source_name_labels_tags/191_source_name_labels_tags__06_e2e.a1.md
@@ -0,0 +1,17 @@
+**Task 06 (`191_source_name_labels_tags/06_e2e.md`) — done**
+
+**Added** `tests/e2e/test_source_name_labels.py` (7 tests, own module app on port 8208). Fixture registers + indexes **all three source kinds** through the real APIs — a real `git init` repo cloned by the sync over `file://` (`forgecore`), a local dir (`homelab`), a tar.gz through `/api/git-sources/upload` (`notesdrop`) — with a token-controlled corpus whose target (`forgecore/notes/b6.md`) is pinned by the app's own retrieval at setup as rank 6 (out of the top-5 seed) and rank 1 once its source is named.
+- (a) editor saves Name/Label/Tags on a git, local **and** upload row → per-row "saved", reload re-populates, identity cell stays the read-only derived ``;
+- (b) control turn: target in the "Nearby docs" row, no `` block for it; after saving "the git server"/"self-hosted forge"/tags the **same** question seeds it with the wire-exact `90%) OK
+All checks passed!
+0 errors, 0 warnings, 0 informations
+WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
+Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
+
+validation OK
diff --git a/.agents/screenshots/doc_modal_scrollbar_1280.png b/.agents/screenshots/doc_modal_scrollbar_1280.png
index e691e36..9a95f71 100644
Binary files a/.agents/screenshots/doc_modal_scrollbar_1280.png and b/.agents/screenshots/doc_modal_scrollbar_1280.png differ
diff --git a/.env.example b/.env.example
index 4ba8d4c..07f8639 100644
--- a/.env.example
+++ b/.env.example
@@ -102,6 +102,7 @@ BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant
BOR_RECENCY_BOOST=0.0007 # max additive fused-score bonus a zero-age doc gets (phase 106); 0 = off (pre-phase ranking byte-identical)
BOR_RECENCY_HALF_LIFE_DAYS=365 # recency decay timescale, days (phase 106); the boost decays by e^-1 (~0.37x) per timescale of age
BOR_NAME_HIT_BONUS=0.005 # selection-time bonus a name-hit doc's best fused score gets (phase 119); 0 = off (pre-phase order byte-identical)
+BOR_SOURCE_NAME_HIT_BONUS=0.005 # selection-time bonus a source-name-hit doc gets — the question names the source label row's name/label/tag (phase 191); independent knob, 0 = off
# --- Agent document tools (grounded turns may extend context: ls / read / grep) ---
# BOR_AGENT_MAX_ROUNDS=10 # hard cap on agent tool rounds per turn (0 = no tools); every tool result carries the model's remaining count (phase 160)
diff --git a/AGENTS.md b/AGENTS.md
index 6e6ce01..a0a8e94 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -312,6 +312,99 @@
quoted cosine/lexical margins, so editing a note there moves the numbers in
the module docstring and the pins together.
+17. **A source label is an alias, never an identity (phase 191):** the
+ `source_labels` row (alembic `0030`, PK = the DERIVED source name
+ exactly as `Document.source` carries it) holds the owner's
+ `display_name` / `label` / `tags`, and only
+ `app/api/source_labels.py` writes it — `GET /api/source-labels` (the
+ catalogue: the `effective_sources` rows of BOTH kinds — or the
+ `BOR_GIT_SOURCES` names while the table is empty — plus every indexed
+ document source no row derives, de-duplicated and sorted, ONE
+ `all_labels` read for the whole list), `PUT` (partial update: omitted
+ keeps, explicit `null` clears, `tags: []` clears) and `DELETE` (the
+ reset); `require_admin` router-wide, and every name resolution goes
+ through `app.rag.source_removal.resolve_source_name` — never
+ re-derive the basename here or the row keys nothing. A `PUT` for a
+ name that is neither a registry row's derived name nor an indexed
+ document source is 404 (no orphan label rows) and a validation
+ failure writes NOTHING. NEVER rename, resolve or join on the display
+ name: `Document.source`, the `source/path` read/grep/`show_text`
+ locators, the citations, the KB tree and the verbatim caption stay on
+ the derived name, so a rename cannot invalidate a saved chat, a
+ shared page or a locator — and the name reaches the model only
+ through the prompt/retrieval layers (tasks 04/05), never by replacing
+ the identity in a chip, a table or a caption. Pinned by
+ `tests/integration/test_source_labels_api.py` (the 403 gate for
+ anonymous AND token users, the git+local+upload union sorted and
+ de-duplicated at three SELECTs with no `documents.content` read, the
+ partial-update shape, every cap as the framework's 422 with the row
+ byte-identical, both 404 arms, the `ValueError` → 422 funnel, and the
+ "a rename writes nothing but the label row" arm).
+ The Sources-page editor (task 03) is the owner-visible half of the SAME
+ contract and must stay that way: its row list comes from
+ `GET /api/source-labels` (the catalogue union — never a re-derived
+ basename, never a join on the display name), the row's identity cell is a
+ read-only `` of the DERIVED name (an input there WOULD be the
+ alias-contract bug), Save/Reset `PUT`/`DELETE` the per-source path and
+ re-read the ROW from that response (no second GET), the save state is
+ scoped PER ROW (`saveInFlight` — one request per row, the `finally` always
+ settles it, the 404 note names the arm, a collapse discards the draft) —
+ and a FAILED save's note SURVIVES that settle: `updateLabelRowUI` returns
+ before the dirty hint whenever the line carries `.is-error`, so
+ "That source no longer exists — reload the page." cannot be painted over
+ by "unsaved changes" in the tick that wrote it (the defect the task-06
+ story suite walked into); only the next edit (`clearLabelError`) or a
+ collapse clears a note, never the settle path),
+ and the Tags token input's chips reuse the phase-89 tag rule
+ (`.source-label-tag` sits in the `.git-source-hidden-count` rule group — no
+ bespoke tag-input CSS). Pinned by
+ `tests/unit/test_source_labels_editor_ui.py` and, through the real UI over
+ one git + one local + one uploaded source, by
+ `tests/e2e/test_source_name_labels.py` (task 06: the editor saves and
+ re-populates all three kinds, a question that USES the name moves that
+ source's document into the seeded `` tier with
+ `name`/`label` on the wire while the same row with a name-free question
+ moves nothing, the vanished-source 404 note is visible, Reset reverts the
+ tier, and the alias never appears in a locator, a nearby-doc link, the KB
+ tree or the viewer); the chat source chips, the RAG
+ source tree and the verbatim caption keep the derived name untouched.
+ The selection boost (task 04) is the retrieval half of the SAME
+ contract: ONE `all_labels` read per turn at the
+ retrieval boundary (`app/api/chat.py`, after `retrieve`, before the
+ first selection call) flags `RetrievedChunk.source_name_hit` on the
+ chunks whose `document.source` is a D2 hit (the pure
+ `question_name_hits` rule applied per view — a loop over the few
+ dozen chunks, never a per-chunk query; that ONE result is also what
+ task 05's prompt surfaces consume, so a turn issues one label query
+ and the standalone `question_name_hit_sources` wrapper never runs in
+ one), and `_selection_order` adds
+ `source_name_hit_bonus` (default 0.005; `0` = the byte-identical
+ kill switch; a negative value refuses startup) as a SECOND,
+ independent bonus — once per DOCUMENT, at most two bonuses stacked
+ with the phase-119 `name_hit_bonus` (bounded 0.010 on the fused
+ scale), SELECTION layer only (`score`/`cosine`/`fts_hit`/
+ `query_log.top_score` and the per-turn log line untouched). Pinned
+ by `tests/unit/test_retriever.py` (the kill-switch byte-identity,
+ the bounded two-bonus application, the `fuse` OR-merge, the three
+ selection helpers pulling the knob from settings).
+ The prompt surfacing (task 05) is the model-facing half of the SAME
+ read: `build_high_prompt(names=…)` gives the suggested ``
+ block the ADDITIVE ` name="…"` (immediately after `source="…"`) and
+ ` label="…"` attributes — the order is locked
+ (`source` → `name?` → `label?` → `path` → `title` → `date`), the two
+ values escape through the ONE `_attr` helper, and the identity
+ attributes keep their pre-phase unescaped bytes; `regenerate_overview`
+ derives the KB-overview INPUT lines' name map from its own ONE
+ `all_labels` read (`build_overview_prompt(names=…)` swaps the raw
+ source name for the display name in the lite model's input ONLY — the
+ stored outline and its deterministic `Sources: …` tail stay derived
+ name). An unset field adds NOTHING (never `name=""`), tags never
+ render, the LOW prompt never carries a name, and no map / no entry is
+ byte-identical to pre-phase on BOTH builders (the kill pins in
+ `tests/unit/test_prompts.py` and `tests/unit/test_overview.py`, which
+ also marker-pin the one-read-per-surface contract, as
+ `tests/unit/test_chat_gate.py` does on the chat call site).
+
## Quick reference
```bash
podman compose up -d db # start Postgres 17 + pgvector
diff --git a/README.md b/README.md
index fba30e3..1dc33f9 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ client-side router. Direct URLs deep-link to the matching view.
|------|-----|----------------|
| **Chat** | `/` | Everyone (token gate for non-shared) |
| **RAG / Knowledge base** | `/sources.html` | Admin only |
-| **Git sources** | `/git-sources.html` | Admin only |
+| **Git sources** (nav label **Sources**) | `/git-sources.html` | Admin only |
| **Tuning** | `/tuning.html` | Admin only |
| **Saved chats** | `/history.html` | Admin only |
| **Access tokens** | `/tokens.html` | Admin only |
@@ -269,12 +269,16 @@ just looked at costs a bodiless 304 (see *Caching & deploys*).
### Git sources (`/git-sources.html`)
-The admin-managed source registry: add or remove git repositories, upload
-source archives (`.tar`, `.tar.gz`, `.tgz`, `.zip`), and register local
-directories — all in one table with a `kind` discriminator. No `.env`
-editing, no restart. The **Sync sources** button — which lives on the
-**RAG** page (`/sources.html`), not here — clones/pulls/walks every
-source and re-imports in one click.
+The admin-managed source registry (labelled **Sources** in the nav): add or
+remove git repositories, upload source archives (`.tar`, `.tar.gz`, `.tgz`,
+`.zip`), and register local directories — all in one table with a `kind`
+discriminator. No `.env` editing, no restart. Below the table sits the
+**Names, labels and tags** card, where each source gets your own name,
+label and focus tags (phase 191 — see
+[Source names, labels and tags](#source-names-labels-and-tags-phase-191)).
+The **Sync sources** button — which lives on the **RAG** page
+(`/sources.html`), not here — clones/pulls/walks every source and re-imports
+in one click.
### Tuning (`/tuning.html`)
@@ -635,6 +639,74 @@ discriminator: `git` | `local`), **one admin page**, and **one Sync button**.
- The directory is re-verified to exist at sync time; a missing directory
fails the run loudly.
+### Source names, labels and tags (phase 191)
+
+Every source — git, local or uploaded — can carry **your** words for it: a
+name ("the git server"), a one-line label ("self-hosted forge + Gitea") and
+up to 10 focus tags. The storage is one row per source (`source_labels`,
+migration 0030, created empty — an unnamed source behaves exactly as before).
+
+**Edit them on the Sources page** (`/git-sources.html`, admin only), in the
+**Names, labels and tags** card below the sources table. Its row list is the
+catalogue `GET /api/source-labels` — the union of every registered source and
+every indexed document source — so git repos, local directories **and**
+uploads all get an editor without a second API. Each row leads with its
+read-only mono source name (the identity), expands into the three fields
+(Name ≤ 120, Label ≤ 200, Tags: type + **Enter** or a **comma** adds a chip,
+**Backspace** drops the last, ten chips of ≤ 32 chars) plus **Save** /
+**Reset** and its own status line — "unsaved changes" → "Saving…" → "saved",
+or a one-line note on failure (a 404 says *that source no longer exists*, so
+you know to reload rather than retry). A failure note **stays** on the row —
+the row never repaints it back to "unsaved changes" over the reason; only
+editing the fields again or collapsing the editor clears it. One save per row
+at a time; the save response is what the row re-reads, so nothing on the page
+claims more than the server has. A row with no stored label shows no **Reset**, and collapsing
+an editor discards the un-saved draft instead of silently half-saving.
+
+The API behind it:
+
+| Route | What it does |
+| --- | --- |
+| `GET /api/source-labels` | The full catalogue: every registered source (git **and** local rows, or the `BOR_GIT_SOURCES` names while the table is empty) plus every indexed source no row derives (the uploads), de-duplicated and sorted by name, each with its label or `null`/`[]`. |
+| `PUT /api/source-labels/{source}` | The upsert, partial-update style: an omitted field keeps its value, an explicit `null` clears it, `tags: []` clears the tags. Values are trimmed, the caps are 120 / 200 / ten tags of 32, tags de-duplicate case-insensitively (first case kept). A source that matches no row and no document is **404** — no label can be created for a source that does not exist; an over-cap or malformed field is the framework's **422** and writes nothing. |
+| `DELETE /api/source-labels/{source}` | The reset — the row goes, and the **200** body is the entry's cleared state. No row to remove is **404**. |
+
+Admin-only (all three, like the Theme and Settings surfaces): anonymous
+callers and token users get `403 admin only`.
+
+**A name is an alias, never a rename.** The identity of a source stays the
+derived name the importer stores in `Document.source` — the clone directory,
+the local directory, the upload file stem. A label row never rewrites a
+document, a `source/path` locator in a saved or shared chat, a citation, a
+verbatim panel caption or the RAG tree, so renaming a source cannot break
+anything that was already written. What the name is *for* — focusing
+retrieval and the prompt on the words you use — is the rest of the phase.
+
+**The name focuses retrieval.** When a question token equals (case- and
+accent-insensitively, 4-char minimum on both sides) a token of a source's
+name, label or any tag, that source is a *source-name hit* for the turn:
+every retrieved document from it gets a small, bounded head start in the
+selection walk that picks the suggested tier — `source_name_hit_bonus`
+(default `0.005`, env `BOR_SOURCE_NAME_HIT_BONUS`, `0` = off, a negative
+value refuses startup). The bonus applies **once per document**, at most
+two bonuses stacked (the phase-119 path-name bonus plus this one = 0.010
+on the fused scale), and it lives in the selection layer only — chunk
+scores, cosines and the honesty-gate inputs are untouched, so a deflected
+answer stays deflected. With no label rows anywhere the selection walk is
+byte-identical to the pre-phase one (the kill switch), which is also what
+the retrieval battery records at `0`.
+
+**The name reaches the model, too.** One label read per chat turn feeds both
+the boost above and the prompt: each suggested document's `` block
+carries ` name="the git server" label="self-hosted forge"` beside its
+`source` / `path`, so the model knows which documents your words refer to —
+and the KB outline is generated from a document list that names your sources
+the way you do (the display name replaces the raw source name in that one
+model input; the stored outline and its deterministic `Sources: …` line keep
+the derived name). Nothing here changes what the model can cite: it still
+reads, greps and cites `source/path`, and a source without a label row is
+byte-for-byte the pre-phase prompt.
+
### Sync from the UI
The **Sync sources** button on the **RAG** page (`/sources.html`, admin
@@ -1806,6 +1878,7 @@ startup (the DB rows shadow the env, `.env` is never rewritten).
| `BOR_RECENCY_BOOST` | `0.0007` | Max additive fused-score bonus a zero-age doc gets (phase 106); `0` = off |
| `BOR_RECENCY_HALF_LIFE_DAYS` | `365` | Recency decay timescale, days (phase 106) — the boost decays by e^-1 (~0.37x) per timescale of age |
| `BOR_NAME_HIT_BONUS` | `0.005` | Selection-time bonus a name-hit doc's best fused score gets (phase 119); `0` = off |
+| `BOR_SOURCE_NAME_HIT_BONUS` | `0.005` | Selection-time bonus a **source-name**-hit doc's best fused score gets — the question names the source's label-row name/label/tag (phase 191); independent knob, `0` = off |
### Agent tools
diff --git a/alembic/versions/0030_source_labels.py b/alembic/versions/0030_source_labels.py
new file mode 100644
index 0000000..513ed47
--- /dev/null
+++ b/alembic/versions/0030_source_labels.py
@@ -0,0 +1,85 @@
+"""source_labels: per-source name, label and tags (phase 191, task 01)
+
+Revision ID: 0030
+Revises: 0029
+Create Date: 2026-10-18
+
+``TODO.md`` L4: "Sources need a name, a label, tag(s) so you can focus
+the model when prompting if you have a project name or a standard name
+you reference things by." This revision carries the storage half
+(phase 191 D1) — the matching rule (D2), the boost (D3), the API (D5)
+and the UI (D6) are the later tasks.
+
+* ``source_labels`` — one row per SOURCE the owner has named:
+
+ * ``source`` — VARCHAR(120) NOT NULL, PRIMARY KEY. The source name
+ EXACTLY as ``documents.source`` carries it (the derived basename —
+ the clone dir / local dir / upload file stem): the join key to the
+ documents and to the ``git_sources`` row. The PK is the uniqueness
+ guard (one label row per source — the ``app_settings`` 0028
+ reasoning). **An ALIAS key only (D1):** a label row never renames a
+ document, a ``source/path`` locator, a citation or the KB tree.
+ * ``display_name`` — TEXT nullable (the owner's name for the source,
+ "the git server"). Plain TEXT: the ≤ 120-char trimmed limit is
+ enforced at the API boundary (the phase-121 ``git_sources.branch``
+ pattern — plain column, boundary validation). NULL = no name.
+ * ``label`` — TEXT nullable (the short one-line description /
+ category, ≤ 200 trimmed, same boundary rule). NULL = no label.
+ * ``tags`` — JSONB NOT NULL with server default ``'[]'`` (the
+ phase-89 ``git_sources.ignore_paths`` shape): the focus tokens,
+ each ≤ 32 chars, ≤ 10 entries, guarded at the API boundary
+ (``app.rag.source_labels.normalize_tags``).
+ * ``updated_at`` — TIMESTAMPTZ NOT NULL, ``now()`` server default
+ (the ``app_settings`` / ``folder_summaries`` house style).
+
+NO backfill (D1): the table is created EMPTY — a source without a row
+has no name/label/tags and behaves exactly as pre-phase, so "no label
+row → every surface byte-identical" holds without a data step. No index
+beyond the PK: every read is a PK lookup or a whole-table walk of a
+handful of rows (the ``app_settings`` "never a query surface" note).
+
+One additive, fully reversible migration (A13).
+"""
+from __future__ import annotations
+
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+from alembic import op
+
+revision = "0030"
+down_revision = "0029"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # No index beyond the PK (every read is a PK lookup or the whole
+ # handful-row table), no backfill (an empty table = every source
+ # keeps its pre-phase "no row" behavior).
+ op.create_table(
+ "source_labels",
+ sa.Column("source", sa.String(length=120), primary_key=True),
+ sa.Column("display_name", sa.Text(), nullable=True),
+ sa.Column("label", sa.Text(), nullable=True),
+ sa.Column(
+ "tags",
+ postgresql.JSONB(astext_type=sa.Text()),
+ server_default=sa.text("'[]'"),
+ nullable=False,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.func.now(),
+ nullable=False,
+ ),
+ )
+
+
+def downgrade() -> None:
+ # The table is the only 0030 artefact — dropping it leaves 0029's
+ # schema byte-identical (A13, fully reversible). No owner data is
+ # trapped: the names/labels/tags are re-enterable from the Sources
+ # page (task 03), and nothing else was mutated by 0030.
+ op.drop_table("source_labels")
diff --git a/app/api/chat.py b/app/api/chat.py
index 561d844..2f8a068 100644
--- a/app/api/chat.py
+++ b/app/api/chat.py
@@ -258,7 +258,7 @@ import asyncio
import json
import logging
import time
-from collections.abc import AsyncIterator, Sequence
+from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
@@ -301,6 +301,11 @@ from app.rag.retriever import (
weak_hit_titles,
)
from app.rag.scaffolding import ScaffoldingFilter # phase 71: the streaming filter
+from app.rag.source_labels import (
+ SourceLabelView,
+ all_labels,
+ question_name_hits,
+)
from app.rag.suggestions import derive_suggestions
from app.rag.summarizer import (
IMAGE_FALLBACK_MIME,
@@ -461,6 +466,7 @@ def plan_turn(
notes: Sequence[str] | None = None,
kb_overview: str | None = None,
folder_lines: Sequence[str] = (),
+ names: Mapping[str, SourceLabelView] | None = None,
) -> TurnPlan:
"""Apply the honesty gate (A8, revised) and assemble prompt + context.
@@ -502,6 +508,15 @@ def plan_turn(
phase-118 shape. The :class:`TurnPlan`, the durable record, and the
per-turn log line are untouched.
+ *names* (phase 191, D4): the source-name → ``SourceLabelView`` map of
+ the owner's label rows — the SAME single ``all_labels`` read the
+ endpoint performs for the task-04 selection boost (ONE query per
+ turn). The HIGH branch hands it to ``build_high_prompt`` (the
+ additive ``name`` / ``label`` attributes of the ````
+ blocks); the LOW branch IGNORES it — the deflected prompt stays
+ byte-identical. ``None`` ⇒ every block is byte-identical to
+ pre-phase.
+
``summary_hits`` (phase 30; phase 118 redefinition) counts the hit
chunks with ``is_summary`` whose parent document is in the
SUGGESTED set — both the HIGH and the LOW branch record it.
@@ -561,6 +576,10 @@ def plan_turn(
# byte-identical to pre-phase (no clause, and agent_tools
# offers four tools, not five).
show_text=settings.answer_text,
+ # Phase 191 (D4, task 05): the owner's source names ride the
+ # same map the boost flags came from — one all_labels read
+ # per turn, no second label query here.
+ names=names,
),
suggested,
related_docs,
@@ -860,6 +879,33 @@ async def chat(
kb_overview = load_kb_overview(step_db)
with SessionLocal() as step_db:
chunks = retrieve(step_db, request.message, question_vec)
+ # Phase 191 (D3 task 04 + D4 task 05): ONE
+ # all_labels read per turn feeds BOTH surfaces —
+ # the source-name hit flags here AND the prompt
+ # surfaces (the map rides into plan_turn →
+ # build_high_prompt's name/label attributes). The
+ # hit set is the pure D2 rule per view, then a
+ # loop over the few dozen retrieved chunks against
+ # the handful of hit sources (never a per-chunk
+ # query — task 01's question_name_hit_sources is
+ # this same pair, inlined here so the read is
+ # shared). The flags ride the chunk objects into
+ # EVERY selection call below (plan_turn's walk
+ # included); the source_name_hit_bonus itself is
+ # pulled from settings inside the selection
+ # helpers. No label rows → empty map → no flags
+ # and no name attributes → every surface is the
+ # pre-phase one; the per-turn log line is
+ # untouched (the boost is invisible to it).
+ labels = all_labels(step_db)
+ source_name_hits = {
+ view.source
+ for view in labels.values()
+ if question_name_hits(view, request.message)
+ }
+ for rc in chunks:
+ if rc.document.source in source_name_hits:
+ rc.source_name_hit = True
# Phase 119 (D3, LOCKED A4): the suggested-folder
# context — the SAME deterministic suggested walk
# plan_turn performs internally (one extra cheap
@@ -880,6 +926,10 @@ async def chat(
notes=steering_notes,
kb_overview=kb_overview,
folder_lines=folder_lines,
+ # Phase 191 (D4, task 05): the SAME single read —
+ # the HIGH prompt learns the name→docs mapping
+ # from these views (no second label query).
+ names=labels,
)
except Exception: # noqa: BLE001 — DB failure mid-turn
logger.exception(
diff --git a/app/api/source_labels.py b/app/api/source_labels.py
new file mode 100644
index 0000000..dd52df1
--- /dev/null
+++ b/app/api/source_labels.py
@@ -0,0 +1,249 @@
+"""Source name/label/tags admin API (phase 191, task 02).
+
+``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." Task 01 stored that (the ``source_labels``
+table + :mod:`app.rag.source_labels`); this module is its whole HTTP
+surface — the API the Sources page's per-source editor (task 03)
+drives, admin-gated like the phase-91 Theme tab and the phase-168
+Settings panel.
+
+Routes (under ``/api`` via the ``main`` registration — the house prefix
+convention, the :mod:`app.api.ui_settings` /
+:mod:`app.api.admin_settings` style, so the wire paths are exactly the
+D5 ones):
+
+* ``GET /api/source-labels`` — the FULL source catalogue: (a) every
+ ``git_sources`` row's DERIVED source name (git AND local rows —
+ :func:`app.rag.source_removal.resolve_source_name`, the one helper
+ that mirrors the import pipeline's naming for both kinds — and, while
+ the table is empty, the ``BOR_GIT_SOURCES`` env names, which
+ :func:`app.rag.git_sources.effective_sources` already surfaces as
+ synthetic git rows), plus (b) every indexed document source no row
+ derives (the uploads — ONE ``SELECT DISTINCT source``, no ``content``,
+ the phase-126 load discipline). De-duplicated (the phase-69 sibling
+ case: two rows, one name) and sorted by ``source``, so the page and
+ the phase's E2E always see the same order. Each entry carries its
+ label row or the ``null``/``[]`` defaults — one
+ :func:`app.rag.source_labels.all_labels` read for the whole list,
+ never a query per source. Reads only; creates nothing.
+* ``PUT /api/source-labels/{source}`` — the upsert. The path param is
+ capped at the ``documents.source`` 120 (the framework's 422 above
+ it); a ``source`` matching NO registry row AND NO indexed document is
+ a 404 BEFORE anything is written (D5: no orphan label rows — a label
+ for a source that exists nowhere would be a dead boost input, and
+ nothing in the UI could ever list it). The body is the partial-update
+ shape (the phase-168 precedent): an omitted field keeps its stored
+ value, an explicit ``null`` clears it, a value sets it. The tags go
+ through :func:`app.rag.source_labels.normalize_tags` — the ONE tag
+ rule (trim, case-insensitive de-dupe, first case wins) — and its
+ ``ValueError`` becomes the framework's 422, NEVER a 500. The router
+ owns the session here (the phase-91 commit convention): ``set_label``
+ flushes, this route commits, and a rejected request writes NOTHING.
+ 200 → the stored :class:`SourceLabelOut`.
+* ``DELETE /api/source-labels/{source}`` — the reset: the row goes, 404
+ when there is no row to remove. Like the phase-168
+ ``DELETE /api/admin/settings/{key}`` the 200 body is the entry's
+ post-reset state — the same shape with the fields cleared — so the
+ row re-renders from the response it already has.
+
+One invariant the routes exist to protect (D1, owner-confirmed): a
+label row is an ALIAS. ``Document.source``, the ``source/path`` document
+identity, the read/grep/``show_text`` locators, the citations and the KB
+tree are ALL untouched by anything here — calling "gitea-forge" "the git
+server" changes only what the lite model and the chat model are TOLD
+(tasks 04/05), never what a saved chat, a shared page or a verbatim
+panel points at. That is why ``source`` is a path param that is LOOKED
+UP and never rewritten, and why the catalogue's identity column stays
+the derived name.
+
+Logging (AGENTS rule 10): one INFO line per WRITE, carrying the action
+and the source, never the values (the ``admin_settings`` discipline —
+the owner's text belongs in the row, not in ``server.log``). GETs log
+nothing here: :class:`app.core.response_budget.ResponseBudgetMiddleware`
+already writes the per-response ``api method=… bytes=…`` line for every
+``/api/*`` route.
+"""
+from __future__ import annotations
+
+import logging
+from typing import Annotated, Any
+
+from fastapi import APIRouter, Depends, HTTPException, Path
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.core.auth import require_admin
+from app.db import get_db
+from app.models import Document
+from app.rag.git_sources import effective_sources
+from app.rag.source_labels import (
+ SourceLabelView,
+ all_labels,
+ delete_label,
+ normalize_tags,
+ set_label,
+)
+from app.rag.source_removal import resolve_source_name
+from app.schemas import SourceLabelList, SourceLabelOut, SourceLabelUpdate
+
+logger = logging.getLogger("app.api.source_labels")
+
+router = APIRouter(
+ prefix="/source-labels",
+ tags=["source-labels"],
+ dependencies=[Depends(require_admin)], # phase 191: the owner's naming surface
+)
+
+
+def _catalogue_names(db: Session) -> list[str]:
+ """Every source the owner can label, de-duplicated and sorted.
+
+ Exactly the two reads the D5 catalogue is made of — no third:
+
+ (a) the registry: :func:`app.rag.git_sources.effective_sources` —
+ the ``git_sources`` rows of BOTH kinds in ``(added_at, id)``
+ order, or the synthetic env rows while the table is empty —
+ named by :func:`app.rag.source_removal.resolve_source_name`, the
+ single helper that spells a row's name the way the importer
+ stores it in ``Document.source`` (``repo_name`` of the URL for
+ git rows, the expanded path's basename for local rows). Reused,
+ never re-derived: an expression copied here would drift from the
+ importer and the label rows would key nothing.
+ (b) the indexed documents: ``SELECT DISTINCT source`` — one query
+ over the indexed corpus (the column only, never the entity: no
+ ``content``, phase-126/185 discipline), so an upload — or any
+ document whose registry row is gone — still lists and stays
+ labelable.
+
+ Sorted because the page renders this order and the phase's E2E pins
+ it; the set collapses the phase-69 sibling case (two rows resolving
+ to one name) to a single entry.
+ """
+ rows, _origin = effective_sources(db)
+ names: set[str] = {resolve_source_name(row) for row in rows}
+ names.update(db.scalars(select(Document.source).distinct()).all())
+ return sorted(names)
+
+
+def _shape(source: str, view: SourceLabelView | None) -> SourceLabelOut:
+ """One entry of the catalogue: the label row's values, or the
+ ``null``/``[]`` defaults when the source has no row (the pre-phase
+ state — indistinguishable from a cleared label, and that is the
+ point: D1 says a label is data beside the name, never a rename)."""
+ return SourceLabelOut(
+ source=source,
+ display_name=view.display_name if view else None,
+ label=view.label if view else None,
+ tags=list(view.tags) if view else [],
+ )
+
+
+@router.get("", response_model=SourceLabelList)
+def list_source_labels(
+ db: Session = Depends(get_db), # noqa: B008
+) -> SourceLabelList:
+ """The catalogue: every source of every kind, each with its label or
+ the null defaults, sorted by ``source``.
+
+ Two reads for the names (:func:`_catalogue_names`) plus ONE for all
+ the label rows — the table carries a handful of rows, so the list is
+ assembled in Python instead of issuing a lookup per source (the
+ phase-119 one-query convention).
+
+ The catalogue is the SOURCE list, not the label list: a label row
+ whose source has since lost both its registry row and its documents
+ does not appear here (it can boost nothing — the D2 match runs
+ against retrieved chunks' ``document.source``), and it stays
+ clearable through ``DELETE`` below, which never consults the
+ registry.
+ """
+ labels = all_labels(db)
+ return SourceLabelList(
+ labels=[_shape(name, labels.get(name)) for name in _catalogue_names(db)]
+ )
+
+
+@router.put("/{source}", response_model=SourceLabelOut)
+def put_source_label(
+ source: Annotated[str, Path(max_length=120)],
+ payload: SourceLabelUpdate,
+ db: Session = Depends(get_db), # noqa: B008
+) -> SourceLabelOut:
+ """Set or clear one source's name/label/tags (200 → the stored row).
+
+ The order is the D5 contract:
+
+ 1. **404 first** — *source* matches no registry row name and no
+ indexed document (the same two reads the GET makes): a label for
+ a source that exists nowhere is refused and nothing is written.
+ A typo'd name would otherwise sit in the table with no row to
+ join, no documents to boost and nothing in the UI to list it.
+ 2. **Then the tags** — :func:`~app.rag.source_labels.normalize_tags`
+ (trim, drop the entries that are empty afterwards, de-duplicate
+ case-insensitively with the first case kept); its ``ValueError``
+ becomes a 422 carrying its own fixed, input-free reason. The
+ request shape caps the same numbers, so today the raise is the
+ belt — the pin is that the belt can never fire as a 500.
+ 3. **Then the write** — only the fields the request actually carried
+ (``model_fields_set`` — the phase-168 partial-update shape:
+ omitted keeps, ``null`` clears, a value sets) reach
+ :func:`~app.rag.source_labels.set_label`, and the commit happens
+ here (the router owns the session, the phase-91 convention). A
+ rejection at step 1 or 2 leaves any existing row byte-identical.
+
+ An all-omitted body is therefore a legitimate 200 no-op (the
+ ``GitSourcePatchIn`` "all absent → 200 no-op" precedent): the row is
+ touched and reported, nothing changes.
+ """
+ if source not in _catalogue_names(db):
+ raise HTTPException(status_code=404, detail="source not found")
+
+ write: dict[str, Any] = {}
+ carried = payload.model_fields_set
+ if "display_name" in carried:
+ write["display_name"] = payload.display_name
+ if "label" in carried:
+ write["label"] = payload.label
+ if "tags" in carried:
+ try:
+ write["tags"] = normalize_tags(payload.tags or [])
+ except ValueError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+ row = set_label(db, source, **write)
+ db.commit()
+ # The action and the key only — never the owner's text (AGENTS rule
+ # 10, the ``admin_settings`` write-line discipline).
+ logger.info(
+ "source labels action=set source=%s fields=%s",
+ source,
+ ",".join(sorted(write)) or "none",
+ )
+ return SourceLabelOut(
+ source=row.source,
+ display_name=row.display_name,
+ label=row.label,
+ tags=list(row.tags or []),
+ )
+
+
+@router.delete("/{source}", response_model=SourceLabelOut)
+def delete_source_label(
+ source: Annotated[str, Path(max_length=120)],
+ db: Session = Depends(get_db), # noqa: B008
+) -> SourceLabelOut:
+ """Reset one source — the row goes and the next turn sees the
+ pre-phase state.
+
+ 404 (one fixed detail, ``source label not found``) when there is no
+ label row for it. Unlike the PUT this does NOT consult the registry
+ or the documents: the reset has to work for a row whose source has
+ since been pruned or whose registry row was deleted (phase 69's
+ total removal), or the label would be un-clearable by construction.
+ """
+ if not delete_label(db, source):
+ raise HTTPException(status_code=404, detail="source label not found")
+ db.commit()
+ logger.info("source labels action=clear source=%s", source)
+ return SourceLabelOut(source=source, display_name=None, label=None, tags=[])
diff --git a/app/config.py b/app/config.py
index 5c2156f..7092901 100644
--- a/app/config.py
+++ b/app/config.py
@@ -430,6 +430,23 @@ class Settings(BaseSettings):
#: records the realized margins).
name_hit_bonus: float = 0.005
+ #: (phase 191, D3, LOCKED A3): the SECOND, independent selection-time
+ #: bonus — a document whose SOURCE is named by the question (the D2
+ #: rule over the ``source_labels`` row: a question token equals, in
+ #: normalized form, a token of the source's display_name / label /
+ #: tags) gets this added to its best fused chunk score in the SAME
+ #: shared selection walk as the phase-119 ``name_hit_bonus`` (a
+ #: separate knob: the two signals stay independently kill-able).
+ #: Default 0.005 = the phase-119 starting point; ``0`` = off (the
+ #: walk returns byte-identical to pre-phase — the kill switch); a
+ #: negative value fails startup loudly. Bounded per DOCUMENT, applied
+ #: ONCE, at most TWO bonuses on the fused scale (name_hit + source
+ #: name_hit = 0.010 maximum head start — no amplification); the
+ #: bonus lives in the SELECTION layer only — ``score`` / ``cosine`` /
+ #: ``fts_hit`` / ``query_log.top_score`` are untouched (the
+ #: phase-119 LOCKED A3 clause, mirrored).
+ source_name_hit_bonus: float = 0.005
+
# --- Admin & sign-in (phase 16; A10 revised 2026-08-22) ---
# Single-admin auth via a signed session cookie (Starlette
# SessionMiddleware — no new services, no DB tables). Both secrets are
@@ -972,6 +989,17 @@ class Settings(BaseSettings):
raise ValueError("name_hit_bonus must be >= 0 (0 = off)")
return v
+ @field_validator("source_name_hit_bonus")
+ @classmethod
+ def _source_name_hit_bonus_non_negative(cls, v: float) -> float:
+ """``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 (the ``name_hit_bonus``
+ pattern, phase 191)."""
+ if v < 0:
+ raise ValueError("source_name_hit_bonus must be >= 0 (0 = off)")
+ return v
+
@field_validator("db_pool_size")
@classmethod
def _db_pool_size_positive(cls, v: int) -> int:
diff --git a/app/core/settings_catalog.py b/app/core/settings_catalog.py
index 3e0a222..b687f28 100644
--- a/app/core/settings_catalog.py
+++ b/app/core/settings_catalog.py
@@ -225,6 +225,13 @@ CATALOG: dict[str, SettingMeta] = {
"name_hit_bonus": SettingMeta(
"name_hit_bonus", "Retrieval & honesty", "Name hit bonus"
),
+ # Phase 191 (D3): the second, independent selection bonus — the SAME
+ # live shape as name_hit_bonus (the selection walk reads
+ # get_settings() per turn, so a saved override applies on the next
+ # question).
+ "source_name_hit_bonus": SettingMeta(
+ "source_name_hit_bonus", "Retrieval & honesty", "Source name hit bonus"
+ ),
# --- Chunking & embedding ---
"chunk_target_chars": SettingMeta(
"chunk_target_chars", "Chunking & embedding", "Chunk target chars"
diff --git a/app/main.py b/app/main.py
index aa13e89..9c359d9 100644
--- a/app/main.py
+++ b/app/main.py
@@ -39,6 +39,7 @@ from app.api.doc_drafts import router as doc_drafts_router
from app.api.docs import router as docs_router
from app.api.git_sources import router as git_sources_router
from app.api.health import router as health_router
+from app.api.source_labels import router as source_labels_router
from app.api.steering import router as steering_router
from app.api.suggestions import router as suggestions_router
from app.api.sync import router as sync_router
@@ -234,6 +235,11 @@ def create_app() -> FastAPI:
# the app_settings overrides — DB-over-env, live on Save) — admin-only
# (router-wide require_admin; a token user stays 403).
app.include_router(admin_settings_router, prefix="/api")
+ # Phase 191 (task 02): the per-source name/label/tags surface (the
+ # catalogue GET + the upsert + the reset) — admin-only (router-wide
+ # require_admin; a token user stays 403). The display name is an
+ # ALIAS beside the derived source name, never a rename (D1).
+ app.include_router(source_labels_router, prefix="/api")
# Phase 51: the anonymous shared-chat read — NO admin dependency.
# /api/shared/ is the JSON snapshot; /shared/ (the
# page route below, registered without a prefix) is the page.
diff --git a/app/models.py b/app/models.py
index e50ef61..16b270f 100644
--- a/app/models.py
+++ b/app/models.py
@@ -77,6 +77,14 @@ Data model — see ``.agents/PLAN.md`` §Data Model:
name, the value in its env-native string form;
the row WINS over the environment (resolved
inside ``app.config.get_settings``).
+* ``source_labels`` — one row per source the owner has named (phase
+ 191, TODO.md L4): the display name ("the git
+ server"), the short label/category, and the tag
+ list used to focus the model when prompting.
+ ``source`` mirrors ``documents.source`` (the
+ DERIVED name) and is an ALIAS key only — a label
+ row never renames a document, a locator, or a
+ citation (phase 191 D1).
"""
from __future__ import annotations
@@ -717,3 +725,57 @@ class AppSetting(Base):
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+
+
+class SourceLabel(Base):
+ """One owner-given name / label / tag set per source (phase 191, task 01).
+
+ ``TODO.md`` L4: "Sources need a name, a label, tag(s) so you can
+ focus the model when prompting if you have a project name or a
+ standard name you reference things by." One row per SOURCE (not per
+ document), keyed by the source name exactly as ``Document.source``
+ carries it (the derived basename — the clone dir / local dir /
+ upload file stem), so the row joins the documents and the
+ ``git_sources`` row without a second identity.
+
+ **The display name is an ALIAS ONLY (D1, owner-confirmed):**
+ ``Document.source``, the ``source/path`` document identity, the
+ read/grep/``show_text`` locators, the citations and the KB tree are
+ ALL untouched by a label row — renaming a source never invalidates
+ a saved chat, a shared page or a verbatim locator. The derived name
+ stays the wire identity; the display name rides beside it and is
+ consumed by the retrieval boost (task 04) and the prompt surfacing
+ (task 05) only.
+
+ No row = no name/label/tags = the pre-phase behavior exactly (the
+ migration creates an EMPTY table — no backfill, A13 reversible).
+ """
+
+ __tablename__ = "source_labels"
+
+ #: Mirrors ``documents.source`` (String(120)) — the DERIVED source
+ #: name, the join key and the sole PK (one label row per source;
+ #: the ``folder_summaries`` PK-part precedent).
+ source: Mapped[str] = mapped_column(String(120), primary_key=True)
+ #: The owner's name for the source ("the git server"). Plain TEXT —
+ #: the ≤ 120-char trimmed limit is enforced at the API boundary
+ #: (the phase-121 ``git_sources.branch`` pattern: plain column,
+ #: boundary validation). NULL = no display name (pre-phase).
+ display_name: Mapped[str | None] = mapped_column(Text, default=None)
+ #: The short one-line description / category ("self-hosted forge +
+ #: Gitea"). Plain TEXT, ≤ 200 trimmed enforced at the API. NULL =
+ #: no label.
+ label: Mapped[str | None] = mapped_column(Text, default=None)
+ #: The focus tokens (the owner's "tag(s)"): ≤ 10 entries, each ≤ 32
+ #: chars, non-empty, de-duplicated case-insensitively — normalized
+ #: and guarded at the API boundary (``app.rag.source_labels.normalize_tags``).
+ #: JSONB NOT NULL with server default ``'[]'`` (the phase-89
+ #: ``ignore_paths`` shape): a source with no tags reads ``[]``.
+ tags: Mapped[list] = mapped_column(
+ JSONB, default=list, server_default=text("'[]'"), nullable=False
+ )
+ #: Fresh stamp on every write (the ``sources_meta.updated_at`` house
+ #: style: server-side now(), onupdate now()).
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
diff --git a/app/rag/overview.py b/app/rag/overview.py
index fea4929..b31e885 100644
--- a/app/rag/overview.py
+++ b/app/rag/overview.py
@@ -43,6 +43,14 @@ tail (the model input stays byte-identical). The longest-prefix cap in
one-line tail (~15 chars/source) always fit; a smaller owner-set cap
cuts the tail first — accepted, the cap is owner-tunable.
+Source names (phase 191, D4): when a source has a label row with a
+``display_name``, the INPUT line of its documents leads with that name
+instead of the derived source name (``build_overview_prompt``'s *names*
+map, derived from the ONE ``all_labels`` read :func:`regenerate_overview`
+performs) — the owner's vocabulary reaches the outline. The stored text,
+the catalogue rows and the deterministic ``Sources: …`` inventory tail
+keep the DERIVED name (D1 — a label is an alias, never an identity).
+
The ``KB_OVERVIEW_MODE`` marker follows the ``SUMMARY_MODE`` /
``DEFLECT_MODE`` convention: the deterministic E2E mock LLM keys on it
in the system prompt (wired in phase 31, task 05).
@@ -50,7 +58,7 @@ in the system prompt (wired in phase 31, task 05).
from __future__ import annotations
import logging
-from collections.abc import Sequence
+from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
from typing import Protocol
@@ -62,6 +70,7 @@ from app.db import SessionLocal
from app.models import Document, KbOverview
from app.rag.llm import LLMError
from app.rag.retriever import TRUNCATION_MARKER
+from app.rag.source_labels import all_labels
logger = logging.getLogger("app.rag.overview")
@@ -146,7 +155,9 @@ def _source_inventory(rows: Sequence[tuple[str, str, str, str | None]]) -> str:
def build_overview_prompt(
- rows: Sequence[tuple[str, str, str, str | None]], max_chars: int | None = None
+ rows: Sequence[tuple[str, str, str, str | None]],
+ max_chars: int | None = None,
+ names: Mapping[str, str] | None = None,
) -> tuple[str, str]:
"""The ``(system, user)`` message pair for one overview call.
@@ -166,11 +177,25 @@ def build_overview_prompt(
cap and the shared ``[…truncated…]`` marker is appended on its own
line, so the model never sees more than the cap and the cut is
visible (summarizer convention, phase 30).
+
+ *names* (phase 191, D4) maps a DERIVED source name to the owner's
+ display name for it: the input line leads with the display name
+ instead of the raw source name, so the ``lite`` outline is written in
+ the owner's vocabulary ("the git server" rather than
+ ``gitea-infra-docs``). The alias contract (D1) is untouched — the
+ ``path`` / ``title`` / summary halves of the line, the stored overview
+ row and its ``Sources: …`` inventory tail all keep the derived name;
+ only this model input changes. ``None`` / an empty map / no entry for
+ a source (or an entry with an empty name) → the pre-phase line,
+ byte-identical.
"""
limit = max_chars if max_chars is not None else get_settings().overview_input_max_chars
lines: list[str] = []
for source, path, title, summary in rows:
- line = f"{source} — {path} — {title}"
+ # Phase 191 (D4): the display name REPLACES the raw source name in
+ # the lite model's input only — never in the stored outline.
+ display = names.get(source) if names else None
+ line = f"{display or source} — {path} — {title}"
first = _first_summary_line(summary)
if first:
line += f" — {first}"
@@ -220,6 +245,10 @@ async def regenerate_overview(
never sees the tail; the zero-doc and ``LLMError`` paths below
leave a previous row byte-identical — an existing tail is part of
the stored content and is preserved on failure.
+ * Phase 191 (D4): the ONE :func:`app.rag.source_labels.all_labels`
+ read happens AFTER the zero-doc gate (no label query when there is
+ nothing to outline) and feeds only the model input — the stored
+ content and its inventory tail stay derived-name text.
* :class:`LLMError` → ``overview: regeneration failed — …`` is
logged and ``False`` is returned with the previous row intact —
an old outline is better than none.
@@ -249,7 +278,17 @@ async def regenerate_overview(
)
return False
- system, user = build_overview_prompt(rows)
+ # Phase 191 (D4, task 05): ONE all_labels read for the whole run —
+ # the display-name map for the model input is derived from these
+ # views in this loop, never re-queried (the same single-read
+ # contract the chat turn holds, task 04/05). Rows without a
+ # display name are absent from the map, so their lines stay
+ # byte-identical to pre-phase.
+ labels = all_labels(session)
+ name_map = {
+ src: view.display_name for src, view in labels.items() if view.display_name
+ }
+ system, user = build_overview_prompt(rows, names=name_map)
try:
text = await llm.chat(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
diff --git a/app/rag/prompts.py b/app/rag/prompts.py
index 7c252f6..32989c7 100644
--- a/app/rag/prompts.py
+++ b/app/rag/prompts.py
@@ -100,16 +100,32 @@ owner decision). The deterministic lever is the honesty gate itself
(A8 revised 2026-09-14: an FTS hit flips HIGH only when
``best_cosine >= lexical_support_floor``, so the misleading docs are
no longer injected — see :mod:`app.api.chat`).
+
+Source names, labels and tags (phase 191, D4): a ```` block can
+carry the owner's label row for its source as two ADDITIVE attributes —
+``name="…"`` (the source's ``display_name``) immediately after
+``source="…"``, then ``label="…"`` (the one-line description) — so the
+model learns the name→docs mapping ("the git server" is a source with
+these documents). The attribute order is fixed
+(``source`` → ``name?`` → ``label?`` → ``path`` → ``title`` → ``date``),
+an unset field adds NOTHING, and no *names* map / no entry for the
+source leaves the block byte-identical to pre-phase (the alias contract,
+D1: the identity attributes stay the DERIVED name — a display name never
+replaces ``source`` or ``path``). Only the two new values are XML-escaped
+(they are free-form owner text); the identity attributes keep their
+pre-phase unescaped bytes.
"""
from __future__ import annotations
import re
-from collections.abc import Sequence
+from collections.abc import Mapping, Sequence
from typing import Any
+from xml.sax.saxutils import escape
from app.config import Settings, get_settings
from app.models import Document
from app.rag.retriever import TRUNCATION_MARKER
+from app.rag.source_labels import SourceLabelView
from app.schemas import HistoryTurn
#: PLAN §6 verbatim (line wrapping included); ``{relevance}`` is filled by
@@ -769,6 +785,47 @@ def _extract_context_line(image_context: str) -> str | None:
)
+def _attr(value: str) -> str:
+ """XML-escape one attribute value (phase 191, D4) — ``&``/``<``/``>``
+ and the double quote (``"``), the delimiter the block uses.
+
+ Applied ONLY to the two additive label attributes (``name`` /
+ ``label``): they hold free-form OWNER text where a ``"`` is realistic
+ and must not break the attribute. The pre-existing identity
+ attributes (``source`` / ``path`` / ``title`` / ``date``) deliberately
+ keep their unescaped pre-phase bytes — escaping them here would move
+ every label-less block, which the phase's byte-identity criterion
+ forbids.
+ """
+ return escape(value, {'"': """})
+
+
+def _source_label_attrs(
+ doc: Document, names: Mapping[str, SourceLabelView] | None
+) -> str:
+ """The additive `` name="…"`` / `` label="…"`` fragment of one
+ ```` block (phase 191, D4) — the empty string unless the
+ source has a label row with that field set.
+
+ ``None`` / an empty map / no entry for ``doc.source`` / a row with
+ neither field set all render ``""`` — the block is then byte-identical
+ to pre-phase, and a label-only row adds exactly the one ``label``
+ attribute (an unset field adds NOTHING, it is never an empty
+ attribute).
+ """
+ if not names:
+ return ""
+ view = names.get(doc.source)
+ if view is None:
+ return ""
+ attrs = ""
+ if view.display_name:
+ attrs += f' name="{_attr(view.display_name)}"'
+ if view.label:
+ attrs += f' label="{_attr(view.label)}"'
+ return attrs
+
+
def _image_context_line(doc: Document) -> str | None:
"""The phase-125 context line for one suggested document (task 05,
LOCKED A4) — ``None`` in the common case (EVERY text doc, and every
@@ -832,6 +889,7 @@ def build_high_prompt(
folder_lines: Sequence[str] = (),
show_image: bool = False,
show_text: bool = False,
+ names: Mapping[str, SourceLabelView] | None = None,
) -> str:
"""Grounded turn: locked persona (+ steering, + KB overview) + the
top-ranked documents' SUMMARY blocks + the ```` instructions
@@ -917,6 +975,21 @@ def build_high_prompt(
(:func:`_document_body`; the NULL/whitespace-summary preview
fallback is LOCKED A5; an enriched image doc's summary leads with
the phase-125 one-line context line, LOCKED A4).
+
+ *names* (phase 191, D4): the source-name → :class:`SourceLabelView`
+ map of the owner's label rows (the SAME single ``all_labels`` read
+ that feeds the task-04 selection boost — one query per turn). When a
+ doc's source has a row, the block head gains `` name="{display_name}"``
+ immediately after ``source="…"`` and `` label="{label}"`` after that
+ (the fixed order ``source`` → ``name?`` → ``label?`` → ``path`` →
+ ``title`` → ``date``); the two values are XML-escaped
+ (:func:`_attr`), the identity attributes are not. An unset field adds
+ NOTHING, so a label-only row gains exactly the one attribute, and
+ ``None`` / ``{}`` / no entry for the source keeps the block
+ byte-identical to pre-phase (the default, and the kill pin in
+ :mod:`tests.unit.test_prompts`). This is surfacing only — the alias
+ contract (D1) keeps ``source`` / ``path`` as the document identity
+ the tools, citations and locators use.
"""
# Phase 106 (D5): every document the model sees carries its
# creation date — the block's ``date`` attribute (the row's
@@ -926,7 +999,8 @@ def build_high_prompt(
# stays out of the prompt (the capped ``read`` tool is the only
# full-text path).
blocks = [
- f'\n'
f"{_document_body(doc)}\n"
" "
diff --git a/app/rag/retriever.py b/app/rag/retriever.py
index 46708d0..ba9aff4 100644
--- a/app/rag/retriever.py
+++ b/app/rag/retriever.py
@@ -291,6 +291,16 @@ class RetrievedChunk:
:func:`fuse`'s ``replace()`` copies it (the double-hit merge ORs it
in — a vector row that is also the name hit's representative chunk
keeps the flag).
+ * ``source_name_hit`` — True for a chunk whose parent document's
+ SOURCE the question named (phase 191, D2/D3): the flag is set at
+ the retrieval boundary in ``app/api/chat.py`` from the turn's ONE
+ ``all_labels`` read (the pure D2 rule per view — the same read that
+ feeds the ``name``/``label`` prompt attributes, task 05) — the
+ selection tier's SECOND bonus input (``source_name_hit_bonus``, a
+ separate knob). Default
+ ``False`` keeps every ordinary construction valid; :func:`fuse`'s
+ ``replace()`` copies it (the double-hit merge ORs it in, mirroring
+ ``name_hit``).
"""
chunk_id: uuid.UUID
@@ -302,6 +312,7 @@ class RetrievedChunk:
fts_hit: bool = False
is_summary: bool = False
name_hit: bool = False
+ source_name_hit: bool = False
def lexical_tsquery(question: str) -> str | None:
@@ -339,9 +350,11 @@ def fuse(
Lexical-only hits (no vector rank) enter with ``cosine=0.0`` and
``fts_hit=True``; vector chunks matched by the lexical list get
``fts_hit=True`` in place (the input objects are mutated — callers
- should not reuse them afterwards), and ``name_hit=True`` is ORed in
+ should not reuse them afterwards), ``name_hit=True`` is ORed in
(a vector row that is also the name hit's representative chunk is a
- name-hit row — the phase-119 selection tier must see it).
+ name-hit row — the phase-119 selection tier must see it), and
+ ``source_name_hit`` is ORed in the SAME way (phase 191, D3 — a
+ vector row merged with a flagged source-name row keeps the flag).
"""
if k <= 0:
raise ValueError("rrf k must be > 0")
@@ -355,7 +368,10 @@ def fuse(
if rc.chunk_id in by_id:
existing = by_id[rc.chunk_id]
by_id[rc.chunk_id] = replace(
- existing, fts_hit=True, name_hit=existing.name_hit or rc.name_hit
+ existing,
+ fts_hit=True,
+ name_hit=existing.name_hit or rc.name_hit,
+ source_name_hit=existing.source_name_hit or rc.source_name_hit,
)
fused[rc.chunk_id] += term
else:
@@ -754,17 +770,23 @@ def retrieve(
def _selection_order(
chunks: Sequence[RetrievedChunk],
bonus: float,
+ source_bonus: float = 0.0,
) -> list[tuple[Document, float, float, int]]:
"""The shared selection walk of the phase-119 name-hit bonus (D2,
LOCKED A3) — one walk for ``select_suggested``, ``select_related``,
- and ``weak_hit_titles``.
+ and ``weak_hit_titles``, carrying the phase-191 source-name bonus
+ (D3) as its SECOND, independent knob.
Returns ``(document, effective_score, best_cosine, first_seen_index)``
for each distinct document, where *effective_score* is the
document's best fused chunk score plus *bonus* when ANY of its
chunks carries ``name_hit`` (the D1 path match — the bonus is per
DOCUMENT, applied ONCE no matter how many of the document's chunks
- are name hits).
+ are name hits), plus *source_bonus* when ANY of its chunks carries
+ ``source_name_hit`` (the phase-191 D2 source-name match — the SAME
+ per-DOCUMENT, apply-ONCE rule; at most TWO bonuses per document
+ on the fused scale, bounded: name_hit_bonus + source_name_hit_bonus
+ = 0.010 at the shipped defaults, never more).
The walk keeps the EXISTING selection semantics (not just the loop):
the same stable score-descending order as
@@ -773,16 +795,18 @@ def _selection_order(
best cosine tracked across ALL of its chunks (the tiered walk's
tracking).
- * *bonus* ``== 0`` (the kill switch) or no name-hit chunk present:
+ * *bonus* ``== 0`` (the kill switch) or no name-hit chunk present,
+ AND *source_bonus* ``== 0`` or no source-name-hit chunk present:
the document order is IDENTICAL to the pre-phase walk — no re-sort
- happens at all (byte-identical, LOCKED A3);
+ happens at all (byte-identical, LOCKED A3, mirrored for the second
+ bonus by LOCKED A3 of phase 191);
* otherwise the documents are ordered by
``(−effective_score, −best_cosine, document.path,
first_seen_index)`` — the bounded re-rank: a name-hit document
gets a head start on the fused scale, and an effective-score tie
resolves by cosine, then path, then the pre-bonus rank.
- The bonus lives in the SELECTION layer only: the chunk objects are
+ The bonuses live in the SELECTION layer only: the chunk objects are
never modified — their ``score``/``cosine``/``fts_hit`` (the A8
gate's inputs) and ``query_log.top_score`` are untouched.
"""
@@ -790,6 +814,7 @@ def _selection_order(
best_score: dict[uuid.UUID, float] = {}
best_cosine: dict[uuid.UUID, float] = {}
any_name_hit: dict[uuid.UUID, bool] = {}
+ any_source_hit: dict[uuid.UUID, bool] = {}
first_seen: dict[uuid.UUID, int] = {}
for idx, rc in enumerate(sorted(chunks, key=lambda c: c.score, reverse=True)):
doc = rc.document
@@ -798,17 +823,24 @@ def _selection_order(
best_cosine[doc.id] = rc.cosine
if rc.name_hit:
any_name_hit[doc.id] = True
+ if rc.source_name_hit:
+ any_source_hit[doc.id] = True
else:
order.append(doc)
best_score[doc.id] = rc.score
best_cosine[doc.id] = rc.cosine
any_name_hit[doc.id] = rc.name_hit
+ any_source_hit[doc.id] = rc.source_name_hit
first_seen[doc.id] = idx
effective = {
- doc.id: best_score[doc.id] + (bonus if any_name_hit[doc.id] else 0.0)
+ doc.id: best_score[doc.id]
+ + (bonus if any_name_hit[doc.id] else 0.0)
+ + (source_bonus if any_source_hit[doc.id] else 0.0)
for doc in order
}
- if bonus > 0.0 and any(any_name_hit[doc.id] for doc in order):
+ if (bonus > 0.0 and any(any_name_hit[doc.id] for doc in order)) or (
+ source_bonus > 0.0 and any(any_source_hit[doc.id] for doc in order)
+ ):
order.sort(
key=lambda doc: (
-effective[doc.id],
@@ -826,17 +858,26 @@ def _selection_order(
def weak_hit_titles(
chunks: Sequence[RetrievedChunk],
bonus: float | None = None,
+ source_bonus: float | None = None,
) -> list[str]:
"""Distinct parent-document titles of *chunks*, best SELECTION score
- first (the phase-119 name-hit bonus applied — see
- :func:`_selection_order`, D2, LOCKED A3).
+ first (the phase-119 name-hit bonus and the phase-191 source-name
+ bonus applied — see :func:`_selection_order`, D2/D3, LOCKED A3).
Deflection mode (PLAN §6, A8) is built from these *titles only* — the
LOW prompt and the "Maybe try" chips never see document content.
+
+ *source_bonus* defaults to the ``BOR_SOURCE_NAME_HIT_BONUS`` setting
+ (0.005); ``0`` reproduces the pre-phase-191 walk byte-identically.
"""
if bonus is None:
bonus = get_settings().name_hit_bonus
- return [doc.title for doc, _eff, _cos, _idx in _selection_order(chunks, bonus)]
+ if source_bonus is None:
+ source_bonus = get_settings().source_name_hit_bonus
+ return [
+ doc.title
+ for doc, _eff, _cos, _idx in _selection_order(chunks, bonus, source_bonus)
+ ]
def select_documents_tiered(
@@ -953,6 +994,7 @@ def select_suggested(
chunks: Sequence[RetrievedChunk],
n: int | None = None,
bonus: float | None = None,
+ source_bonus: float | None = None,
) -> list[Document]:
"""Top-N distinct parent documents in SELECTION rank order — the
phase-118 "start here" suggestion tier (LOCKED A3, re-revised by
@@ -975,7 +1017,10 @@ def select_suggested(
*bonus* defaults to the ``BOR_NAME_HIT_BONUS`` setting (0.005 — the
owner-tunable starting point); ``0`` reproduces the pre-phase walk
- byte-identically (the kill switch, LOCKED A3).
+ byte-identically (the kill switch, LOCKED A3). *source_bonus*
+ defaults to the ``BOR_SOURCE_NAME_HIT_BONUS`` setting (phase 191,
+ D3 — the same bounded shape, the same kill switch, an independent
+ knob).
The returned rows carry the full document content, byte-identical —
the content is what the agent's ``read`` tool serves later (never
@@ -985,7 +1030,14 @@ def select_suggested(
top_n = n if n is not None else get_settings().suggested_docs
if bonus is None:
bonus = get_settings().name_hit_bonus
- return [doc for doc, _eff, _cos, _idx in _selection_order(chunks, bonus)[:top_n]]
+ if source_bonus is None:
+ source_bonus = get_settings().source_name_hit_bonus
+ return [
+ doc
+ for doc, _eff, _cos, _idx in _selection_order(chunks, bonus, source_bonus)[
+ :top_n
+ ]
+ ]
def select_related(
@@ -993,6 +1045,7 @@ def select_related(
excluded_ids: set[uuid.UUID],
cap: int,
bonus: float | None = None,
+ source_bonus: float | None = None,
) -> list[Document]:
"""The documents ranked AFTER *excluded_ids* — the phase-118 related
tier (rank 6+ for the contiguous top-5 suggestion set), up to *cap*
@@ -1012,7 +1065,10 @@ def select_related(
*bonus* defaults to the ``BOR_NAME_HIT_BONUS`` setting (0.005 — the
owner-tunable starting point); ``0`` reproduces the pre-phase walk
- byte-identically (the kill switch, LOCKED A3).
+ byte-identically (the kill switch, LOCKED A3). *source_bonus*
+ defaults to the ``BOR_SOURCE_NAME_HIT_BONUS`` setting (phase 191,
+ D3 — the same bounded shape, the same kill switch, an independent
+ knob).
The returned rows carry the full document content, byte-identical
(the tier is metadata for the ``done`` frame and the durable
@@ -1020,8 +1076,10 @@ def select_related(
"""
if bonus is None:
bonus = get_settings().name_hit_bonus
+ if source_bonus is None:
+ source_bonus = get_settings().source_name_hit_bonus
out: list[Document] = []
- for doc, _eff, _cos, _idx in _selection_order(chunks, bonus):
+ for doc, _eff, _cos, _idx in _selection_order(chunks, bonus, source_bonus):
if len(out) >= cap:
break
if doc.id in excluded_ids:
diff --git a/app/rag/source_labels.py b/app/rag/source_labels.py
new file mode 100644
index 0000000..c82552f
--- /dev/null
+++ b/app/rag/source_labels.py
@@ -0,0 +1,254 @@
+"""Source name/label/tags storage + the D2 source-name matching rule (phase 191, task 01).
+
+``TODO.md`` L4: "Sources need a name, a label, tag(s) so you can focus
+the model when prompting if you have a project name or a standard name
+you reference things by." This module is the whole non-HTTP surface of
+that feature: the upsert/get/delete helpers over the ``source_labels``
+table (migration 0030) and the pure matching rule the retrieval boost
+(task 04) consumes.
+
+**The alias contract (D1, owner-confirmed):** a label row is an ALIAS
+beside the derived source name — ``Document.source``, the ``source/path``
+document identity, the read/grep/``show_text`` locators, the citations
+and the KB tree are ALL untouched by it. Renaming a source never
+invalidates a saved chat, a shared page or a verbatim locator. Every
+function here keys on the DERIVED name (``Document.source``); the
+display name is data, never identity.
+
+**The matching rule (D2):** a source is a SOURCE-NAME HIT when at least
+one :func:`app.rag.retriever.name_hit_tokens` candidate (the phase-119
+class-agnostic token list, reused UNCHANGED) equals — in the
+:func:`app.rag.retriever._normalize_name` form, so case-insensitive and
+accent-folded (phase 162) — a token of the source's ``display_name``,
+``label`` or any ``tags`` entry. The phase-119 digit-class rule does
+NOT apply to user-given names (the owner's names are not paths —
+"gitea" must hit even though it is digitless; the digit rule exists to
+keep prose tokens from matching PATHS). The one filter is
+:data:`app.rag.retriever.NAME_TOKEN_MIN_LEN` (4) applied to the SOURCE
+side too — a 2-char tag never matches.
+
+Session convention (the phase-53 ``sources_meta`` style): the write
+helpers only *flush* — the **caller** commits.
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from typing import Final
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.models import SourceLabel
+from app.rag.retriever import NAME_TOKEN_MIN_LEN, _normalize_name, name_hit_tokens
+
+#: Boundary limits (phase 191 D1/D5) — the API (task 02) turns the
+#: ``normalize_tags`` raises into the framework's standard 422; the
+#: display-name / label caps are enforced there against these constants.
+DISPLAY_NAME_MAX_CHARS: Final = 120
+LABEL_MAX_CHARS: Final = 200
+TAG_MAX_CHARS: Final = 32
+TAGS_MAX_COUNT: Final = 10
+
+
+class _Unset:
+ """Marker type for "field omitted" in :func:`set_label`.
+
+ The partial-update shape (phase-168 D5): an OMITTED field leaves the
+ stored value untouched, an explicit ``None`` CLEARS it — the two are
+ indistinguishable with plain ``None`` defaults, so the sentinel owns
+ the difference (the API passes only the fields the request carried).
+ """
+
+ __slots__ = ()
+
+
+_UNSET: Final = _Unset()
+
+
+@dataclass(frozen=True)
+class SourceLabelView:
+ """Immutable read-side snapshot of one label row (D1).
+
+ ``tags`` is a ``tuple`` so the view stays hashable/mutable-proof
+ after the ORM row leaves the session; ``display_name`` / ``label``
+ are ``None`` when unset (never the empty string — the API trims
+ empty submissions to NULL).
+ """
+
+ source: str
+ display_name: str | None
+ label: str | None
+ tags: tuple[str, ...]
+
+
+def _view(row: SourceLabel) -> SourceLabelView:
+ return SourceLabelView(
+ source=row.source,
+ display_name=row.display_name,
+ label=row.label,
+ tags=tuple(row.tags or ()),
+ )
+
+
+def get_label(db: Session, source: str) -> SourceLabel | None:
+ """The ORM row for *source* (one PK read); ``None`` when absent."""
+ return db.get(SourceLabel, source)
+
+
+def label_for(db: Session, source: str) -> SourceLabelView | None:
+ """The frozen :class:`SourceLabelView` for *source* (one PK read);
+ ``None`` when absent — the "no label row = pre-phase" contract."""
+ row = db.get(SourceLabel, source)
+ return None if row is None else _view(row)
+
+
+def all_labels(db: Session) -> dict[str, SourceLabelView]:
+ """Every label row as a frozen view, keyed by ``source``.
+
+ ONE query (the phase-119 one-query convention): the table carries a
+ handful of rows, so callers match in Python against it instead of
+ issuing a query per source. Ordered by ``source`` for determinism.
+ """
+ rows = db.scalars(select(SourceLabel).order_by(SourceLabel.source)).all()
+ return {row.source: _view(row) for row in rows}
+
+
+def set_label(
+ db: Session,
+ source: str,
+ *,
+ display_name: str | None | _Unset = _UNSET,
+ label: str | None | _Unset = _UNSET,
+ tags: Sequence[str] | None | _Unset = _UNSET,
+) -> SourceLabel:
+ """Upsert the label row for *source*; return the ORM row.
+
+ The partial-update shape (phase 191 D5, the phase-168 precedent):
+ a field left at the ``_UNSET`` sentinel keeps its stored value, an
+ explicitly passed value OVERWRITES it, an explicit ``None`` CLEARS
+ (``tags`` clears to ``[]`` — the column is NOT NULL). A missing row
+ is created with the passed values (omitted fields = their defaults:
+ ``NULL`` / ``[]``). Values arrive ALREADY VALIDATED (the API layer
+ trims/enforces the caps via :func:`normalize_tags`); this helper
+ stores what it is given.
+
+ Flush-only (the phase-53 session convention): the **caller** commits.
+ """
+ row = db.get(SourceLabel, source)
+ if row is None:
+ row = SourceLabel(
+ source=source,
+ display_name=None if isinstance(display_name, _Unset) else display_name,
+ label=None if isinstance(label, _Unset) else label,
+ tags=[] if isinstance(tags, _Unset) or tags is None else list(tags),
+ )
+ db.add(row)
+ else:
+ if not isinstance(display_name, _Unset):
+ row.display_name = display_name
+ if not isinstance(label, _Unset):
+ row.label = label
+ if not isinstance(tags, _Unset):
+ row.tags = [] if tags is None else list(tags)
+ db.flush()
+ return row
+
+
+def delete_label(db: Session, source: str) -> bool:
+ """Remove the label row for *source*; ``False`` when absent (the
+ API's 404 shape), ``True`` when a row was deleted. Flush-only."""
+ row = db.get(SourceLabel, source)
+ if row is None:
+ return False
+ db.delete(row)
+ db.flush()
+ return True
+
+
+def normalize_tags(raw: Sequence[str]) -> list[str]:
+ """Normalize one tag submission (pure, the API's 422 source).
+
+ * trim every entry, drop the ones that are empty afterwards
+ (``[]`` is legal — it clears the tags);
+ * raise :class:`ValueError` above :data:`TAGS_MAX_COUNT` entries
+ (counted on the trimmed non-empty list, BEFORE the case-insensitive
+ de-dupe: 11 typed tags are 11 rejected tags);
+ * raise :class:`ValueError` when any entry exceeds
+ :data:`TAG_MAX_CHARS` afterwards;
+ * de-duplicate case-insensitively, preserving the FIRST case seen
+ (``Gitea`` then ``gitea`` keeps ``Gitea``), order otherwise kept.
+ """
+ cleaned = [tag.strip() for tag in raw]
+ cleaned = [tag for tag in cleaned if tag]
+ if len(cleaned) > TAGS_MAX_COUNT:
+ raise ValueError(f"at most {TAGS_MAX_COUNT} tags are allowed")
+ for tag in cleaned:
+ if len(tag) > TAG_MAX_CHARS:
+ raise ValueError(f"each tag must be at most {TAG_MAX_CHARS} characters")
+ out: list[str] = []
+ seen: set[str] = set()
+ for tag in cleaned:
+ key = tag.casefold()
+ if key in seen:
+ continue
+ seen.add(key)
+ out.append(tag)
+ return out
+
+
+def _source_side_tokens(view: SourceLabelView) -> set[str]:
+ """The normalized token set of one label row (the D2 source side).
+
+ Every whitespace token of ``display_name`` / ``label`` / every
+ ``tags`` entry, folded through :func:`_normalize_name` (the single
+ normalization BOTH sides of name matching pass through, phase 162)
+ and kept at :data:`NAME_TOKEN_MIN_LEN` chars minimum — the 4-char
+ floor applies to the SOURCE side too (a 2-char tag never matches).
+ """
+ tokens: set[str] = set()
+ fields: list[str] = [view.display_name or "", view.label or "", *view.tags]
+ for field in fields:
+ for raw in field.split():
+ norm = _normalize_name(raw)
+ if len(norm) >= NAME_TOKEN_MIN_LEN:
+ tokens.add(norm)
+ return tokens
+
+
+def question_name_hits(view: SourceLabelView | None, question: str) -> bool:
+ """The D2 rule (pure): does *question* name the source behind *view*?
+
+ ``None`` (no label row) → ``False`` — the pre-phase contract. Any
+ :func:`name_hit_tokens` candidate EQUAL (in the normalized form) to
+ a source-side token is a hit: no digit-class rule, no prefix match
+ (user-given names have no prose-precision problem — the phase-119
+ digit rule guards PATHS, not names).
+ """
+ if view is None:
+ return False
+ candidates = name_hit_tokens(question)
+ if not candidates:
+ return False
+ source_tokens = _source_side_tokens(view)
+ return any(candidate in source_tokens for candidate in candidates)
+
+
+def question_name_hit_sources(db: Session, question: str) -> set[str]:
+ """The derived source names the question names (D2) — the standalone
+ one-query entry point (no other per-turn work needed).
+
+ ONE :func:`all_labels` read + the pure rule per row (a handful of
+ rows, matched in Python — the phase-119 one-query convention). The
+ empty set is the common case: no label rows, or no name in the
+ question.
+
+ The chat turn does NOT call this: since task 05 it inlines the same
+ pair against its own single ``all_labels`` read — the read that also
+ feeds the prompt surfaces (D4) — so a turn issues ONE label query.
+ """
+ return {
+ view.source
+ for view in all_labels(db).values()
+ if question_name_hits(view, question)
+ }
diff --git a/app/schemas.py b/app/schemas.py
index a0ac3c0..76afdd3 100644
--- a/app/schemas.py
+++ b/app/schemas.py
@@ -2246,3 +2246,118 @@ class AdminSettingsCleared(BaseModel):
deleted."""
cleared: int
+
+
+#: One focus tag (phase 191, D1/D5): the owner's short token, capped at
+#: the ``app.rag.source_labels.TAG_MAX_CHARS`` 32 (the ``_Chip`` item-cap
+#: precedent — an ``Annotated`` alias, so the JSON shape stays a plain
+#: string and the cap rides on every list of tags, request AND response).
+_Tag = Annotated[str, Field(min_length=1, max_length=32)]
+
+
+class SourceLabelOut(BaseModel):
+ """One source's name/label/tags (``GET /api/source-labels`` entry,
+ ``PUT``/``DELETE`` response — phase 191, task 02).
+
+ ``source`` is the DERIVED source name — the name ``Document.source``
+ carries (the clone dir / local dir / upload file stem): the row's
+ identity on the wire and the join key to the documents
+ (phase 191 D1). The display name is an ALIAS beside it: no locator,
+ citation or KB-tree node is renamed by a label row.
+
+ ``display_name`` / ``label`` are ``null`` when the owner set nothing
+ (never the empty string — the request validator folds a
+ whitespace-only submission to ``None``), ``tags`` ``[]``. No row =
+ the same null/``[]`` shape: a source without a label row is
+ indistinguishable from one whose label was cleared, which is exactly
+ the pre-phase state (D1).
+
+ ``extra="forbid"`` (the :class:`GitSourceOut` structural contract):
+ a key outside this shape cannot be built into a response — the
+ row's ``updated_at`` and anything else that leaks later raise at
+ construction instead of silently widening the wire shape.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ #: The derived source name (``documents.source``, ``String(120)``).
+ source: str = Field(max_length=120)
+ display_name: str | None = Field(default=None, max_length=120)
+ label: str | None = Field(default=None, max_length=200)
+ #: At most 10 tags of at most 32 chars each (:data:`_Tag`).
+ tags: list[_Tag] = Field(default_factory=list, max_length=10)
+
+
+class SourceLabelList(BaseModel):
+ """``GET /api/source-labels`` response (phase 191, task 02): the
+ whole source catalogue — every ``git_sources`` row (git AND local,
+ or the ``BOR_GIT_SOURCES`` env names while the table is empty) plus
+ every indexed document source no row derives (the uploads), one
+ entry per name, de-duplicated and sorted by ``source`` (the stable
+ order the Sources page and the phase's E2E rely on). Each entry
+ carries its label row or the null/``[]`` defaults."""
+
+ labels: list[SourceLabelOut]
+
+
+class SourceLabelUpdate(BaseModel):
+ """``PUT /api/source-labels/{source}`` body (phase 191, task 02).
+
+ The partial-update shape (D5, the phase-168 precedent): an OMITTED
+ field leaves the stored value untouched, an explicit ``null`` CLEARS
+ it, a value SETS it — the two ``None`` legs are distinguishable
+ because the router reads ``model_fields_set``, not the value.
+
+ Every string is trimmed *before* the length constraints run (the
+ ``GitSourceIn._trim_url`` precedent), so a value with surrounding
+ spaces stores clean and a whitespace-only ``display_name``/``label``
+ folds to ``None`` — the CLEAR leg, since storing an empty string
+ would make "no name" and "an empty name" two different things the
+ prompt builders (task 05) would have to distinguish.
+
+ ``tags`` carries the RAW list the token input produced. Every entry
+ is trimmed by ``_trim_tags`` BEFORE the constraints run, so an entry
+ that is empty afterwards is the framework's standard 422 (an empty
+ focus token matches nothing — a silent drop would tell the owner
+ "saved" while their tag vanished); the count cap (10) and the entry
+ cap (32, :data:`_Tag`) are the same numbers
+ :func:`app.rag.source_labels.normalize_tags` guards, and the router
+ still runs the submission through it for the case-insensitive
+ de-dupe (first case wins) and maps its ``ValueError`` to a 422, so
+ the rule can never turn into a 500 if this shape is ever relaxed.
+ ``[]`` is legal and clears.
+
+ ``extra="forbid"`` rejects a key outside the shape (the phase-91
+ voice) — a client cannot half-write a field nobody declared.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ display_name: str | None = Field(default=None, max_length=120)
+ label: str | None = Field(default=None, max_length=200)
+ tags: list[_Tag] | None = Field(default=None, max_length=10)
+
+ @field_validator("display_name", "label", mode="before")
+ @classmethod
+ def _trim_to_none(cls, v: object) -> object:
+ """Trim, and fold an empty result to ``None`` (the CLEAR leg).
+
+ ``mode='before'`` so the trim runs BEFORE ``max_length`` (the
+ ``GitSourceIn._trim_url`` convention): 121 chars that trim to
+ 120 are legal, 121 chars of text are not."""
+ if isinstance(v, str):
+ trimmed = v.strip()
+ return trimmed or None
+ return v
+
+ @field_validator("tags", mode="before")
+ @classmethod
+ def _trim_tags(cls, v: object) -> object:
+ """Trim every entry BEFORE the item constraints run (the same
+ ``GitSourceIn`` ordering as :meth:`_trim_to_none`): ``" gitea "``
+ passes as ``gitea``, ``" "`` becomes ``""`` and the item's
+ ``min_length`` rejects it. Non-list / non-string input is passed
+ through untouched so pydantic reports it in its own voice."""
+ if isinstance(v, list):
+ return [tag.strip() if isinstance(tag, str) else tag for tag in v]
+ return v
diff --git a/frontend/assets/git-sources.js b/frontend/assets/git-sources.js
index 817902a..e5ddb05 100644
--- a/frontend/assets/git-sources.js
+++ b/frontend/assets/git-sources.js
@@ -228,6 +228,36 @@
* token-free with no per-site change; the UI must never re-embed a
* credential.
*
+ * Phase 191 (task 03) — the per-source name / label / tags editor (the
+ * owner-visible half of "Sources need a name, a label, tag(s) …"): a
+ * SECOND list on the page, its own card below the sources table, whose
+ * ROW LIST is the phase-191 catalogue GET /api/source-labels (task 02) —
+ * the union of every registry row's DERIVED source name and every indexed
+ * document source — so every kind (git, local, upload) gets an editor
+ * without a second API (loadLabels → renderLabelRows → makeLabelRow, the
+ * page's ONE cloned per row).
+ * The mono identity cell is the derived name, textContent-only and never
+ * an input: a label row is an ALIAS (D1/A1) — renaming here touches no
+ * document, citation, KB-tree node, saved chat or source/path locator.
+ * Each row expands (aria-expanded + aria-controls) into the three fields
+ * (Name ≤120 / Label ≤200 / the Tags token input — Enter or comma adds a
+ * chip, Backspace drops the last, ≤10 chips of ≤32 chars, the caps of the
+ * API's normalize_tags mirrored so an over-long submission never
+ * round-trips as a 422) + Save / Reset + the row's own §7.4 save-state
+ * line (role=status): dirty → "unsaved changes" + the .is-dirty row mark
+ * → "Saving…"/"Resetting…" → "saved" or the ONE-LINE error note, whose
+ * 404 arm names itself ("That source no longer exists") because a retry
+ * cannot help — the source left the catalogue. ONE in-flight request per
+ * ROW (the phase-168 saveInFlight pattern scoped per row — a second click
+ * while a request is out is ignored); the PUT / DELETE response is the
+ * new truth (no second GET), Reset clears the fields and the stored label,
+ * and a collapse discards the draft instead of leaving the row claiming
+ * something the server never got. The fetch sits inside the existing
+ * admin-gated content reveal (the page's gate is this list's gate) and
+ * every source-list change (add / remove / a finished upload) refreshes
+ * it, so a new source is nameable without a reload. The existing sync /
+ * upload / git-row flows are untouched — the editor is purely additive.
+ *
* Phase 135 (task 04) — the branch pin (the field, the tri-state
* asymmetry, the tag rule): the add form gains the optional
* `#git-source-branch` (maxlength 200, autocomplete=off, the visible
@@ -354,6 +384,15 @@ export async function mount(root) {
(the checkbox is a table-cell control — no dialog of its own —
so its failure announces here, next to the table). */
const hiddenErrorEl = root.querySelector("#git-sources-hidden-error");
+ /* Phase 191 (task 03): the name/label/tags catalogue — this page's
+ SECOND list (its own endpoint, its own union of every source kind),
+ rendered into its own bounded table card. The template is the ONE
+ row skeleton cloned per source (makeLabelRow). */
+ const labelsErrorEl = root.querySelector("#source-labels-error");
+ const labelsWrap = root.querySelector("#source-labels-wrap");
+ const labelsTbody = root.querySelector("#source-labels-tbody");
+ const labelsEmptyEl = root.querySelector("#source-labels-empty");
+ const labelRowTemplate = root.querySelector("#source-label-row-template");
/* Polite live region: the screen-reader confirmation for loads, adds,
and removals (the phase-15 announcer pattern). */
@@ -724,6 +763,7 @@ export async function mount(root) {
// app-managed files).
closeRemoveConfirm(); // focus returns to the row's Remove button
await loadSources(); // the row leaves the table
+ await loadLabels(); // phase 191: and its label row leaves with it
// The removal confirmation is the LAST announcement: the
// reload's "N sources listed." must not overwrite it (the
// announcer is the screen-reader confirmation for the
@@ -1013,6 +1053,497 @@ export async function mount(root) {
if (ignoreBackdrop) ignoreBackdrop.addEventListener("click", cancelIgnoreEditor);
if (ignoreSaveBtn) ignoreSaveBtn.addEventListener("click", saveIgnorePaths);
+ /* ---------- source names, labels and tags (/api/source-labels) — 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 and exposed that; this is the
+ * surface the owner types into.
+ *
+ * WHY A SECOND LIST (and not a column of #git-sources-table): the row list
+ * comes from GET /api/source-labels — the union of (a) every registry row's
+ * DERIVED source name and (b) every indexed document source — so a source
+ * is nameable even when it has no git_sources row of its own, and the page
+ * never has to guess the server's naming (the importer's derivation is the
+ * ONE owner, app/rag/source_removal.resolve_source_name). One extra fetch,
+ * inside the admin branch only (the anonymous path returns at the gate and
+ * this file never asks).
+ *
+ * WHY THE IDENTITY CELL IS READ-ONLY (D1/A1, owner-confirmed): the derived
+ * name is the wire identity of every document, citation, KB-tree node,
+ * saved chat, shared page and source/path locator. The name typed here is
+ * an ALIAS the prompt layers read (tasks 04/05) — never a rename — so
+ * nothing in this block writes, resolves or joins on it: it is looked up
+ * (encodeURIComponent in the path) exactly as the API's PUT/DELETE take it.
+ *
+ * THE SAVE CONTRACT (§7.4 never-stale, the phase-168 shape scoped PER ROW):
+ * an edit marks the row (.is-dirty) + "unsaved changes" + enables that row's
+ * Save; Save/Reset set the row's own `saveInFlight` (a second click while a
+ * request is out is IGNORED, and the in-flight label owns the status line),
+ * the row settles on "saved" or the one-line error note — never a stuck
+ * "Saving…" on ANY outcome (the finally always drops the flag). The PUT /
+ * DELETE RESPONSE is the new truth (no second GET — task 03): the stored
+ * label, the fields, the state tag and the summary all re-paint from the
+ * body the server just wrote, so the row can never show something the
+ * server does not have. Collapsing the editor DISCARDS the draft (no
+ * request) and refills from the stored label for the same reason. */
+
+ /* The caps of app/rag/source_labels (D1/D5), mirrored client-side so a
+ long tag never costs a round trip: the input's maxlength enforces the
+ 32/120/200 char caps in the markup, this pair owns the chip count. */
+ const LABELS_ENDPOINT = "/api/source-labels";
+ const LABEL_TAG_MAX_CHARS = 32;
+ const LABEL_TAGS_MAX_COUNT = 10;
+
+ /* The save-state line's copy (role=status, one line per row). */
+ const LABEL_STATUS_SAVED = "saved";
+ const LABEL_STATUS_DIRTY = "unsaved changes";
+ const LABEL_STATUS_SAVING = "Saving…";
+ const LABEL_STATUS_RESETTING = "Resetting…";
+ /* The 404 arm NAMED (task 03): the source is gone from the catalogue
+ (removed elsewhere, or its last document pruned), so a retry cannot
+ help — the owner needs a reload, not a retype. */
+ const LABEL_NOTE_GONE = "That source no longer exists — reload the page.";
+ const LABEL_FAIL_SAVE = "Could not save the name — try again.";
+ const LABEL_FAIL_RESET = "Could not reset the name — try again.";
+
+ /* Per-clone id sequence: the template ships ONE id per field, and a
+ literal clone N times over would duplicate it (WCAG 1.3.1 — every
+ would aim at the first match). makeLabelRow rewrites the
+ input ids, the matching s and the editor id (the toggle's
+ aria-controls target) with this counter. */
+ let labelRowSeq = 0;
+
+ function storedFromEntry(entry) {
+ /* One label row in its SERVER shape → the row's stored state. Nulls /
+ missing keys fold to "" / [] (the API's "no row" shape is identical
+ to a cleared label — D1), so a row with nothing stored renders the
+ empty form and no Reset. */
+ const e = entry || {};
+ return {
+ display_name: typeof e.display_name === "string" ? e.display_name : "",
+ label: typeof e.label === "string" ? e.label : "",
+ tags: Array.isArray(e.tags) ? e.tags.map((tag) => String(tag)) : [],
+ };
+ }
+
+ function hasStoredLabel(stored) {
+ return Boolean(stored.display_name || stored.label || stored.tags.length);
+ }
+
+ /* The collapsed row's read-out of the stored label — name · label · the
+ tag list. "" renders nothing (the row shows the toggle instead). */
+ function labelSummaryText(stored) {
+ const parts = [];
+ if (stored.display_name) parts.push(stored.display_name);
+ if (stored.label) parts.push(stored.label);
+ if (stored.tags.length) parts.push(stored.tags.join(", "));
+ return parts.join(" · ");
+ }
+
+ /* The token input's chip list, re-painted from the row's draft array.
+ The chips are the phase-89 tag idiom (.source-label-tag joins the
+ .git-source-ignore-count / -hidden-count / -branch-count rule — TEXT +
+ background, never color alone), so NO new tag-input CSS is needed
+ beyond the list's own layout. At the cap the input disables (the tenth
+ chip is the last one the API accepts). */
+ function paintLabelTags(view) {
+ view.tagsList.replaceChildren(
+ ...view.tags.map((tag) => {
+ const li = document.createElement("li");
+ li.className = "source-label-tag";
+ li.textContent = tag; // textContent only (the XSS contract)
+ return li;
+ }),
+ );
+ const full = view.tags.length >= LABEL_TAGS_MAX_COUNT;
+ view.tagsInput.disabled = full;
+ view.tagsInput.placeholder = full
+ ? `${LABEL_TAGS_MAX_COUNT} tags at most`
+ : "gitea";
+ }
+
+ /* Type + Enter (or a comma) adds a chip. The rules are the API's
+ normalize_tags, applied client-side: trim, drop the empties, keep the
+ FIRST case of a duplicate, cap the count — a duplicate chip would
+ otherwise show the owner two chips the server de-duplicates to one. */
+ function addLabelTags(view, raw) {
+ for (const piece of String(raw).split(",")) {
+ const tag = piece.trim().slice(0, LABEL_TAG_MAX_CHARS);
+ if (!tag) continue;
+ if (view.tags.some((t) => t.toLowerCase() === tag.toLowerCase())) continue;
+ if (view.tags.length >= LABEL_TAGS_MAX_COUNT) break;
+ view.tags.push(tag);
+ }
+ view.tagsInput.value = "";
+ paintLabelTags(view);
+ updateLabelRowUI(view);
+ }
+
+ function readLabelForm(view) {
+ return {
+ display_name: view.nameInput.value.trim(),
+ label: view.labelInput.value.trim(),
+ tags: view.tags.slice(),
+ };
+ }
+
+ function fillLabelForm(view) {
+ view.nameInput.value = view.stored.display_name;
+ view.labelInput.value = view.stored.label;
+ view.tagsInput.value = "";
+ view.tags = view.stored.tags.slice();
+ paintLabelTags(view);
+ }
+
+ function labelFormDirty(view) {
+ const form = readLabelForm(view);
+ return (
+ form.display_name !== view.stored.display_name ||
+ form.label !== view.stored.label ||
+ form.tags.join("\n") !== view.stored.tags.join("\n")
+ );
+ }
+
+ function setLabelStatus(view, text, kind) {
+ view.statusEl.textContent = text;
+ view.statusEl.classList.toggle("is-error", kind === "error");
+ view.statusEl.classList.toggle("is-hint", kind === "hint");
+ }
+
+ /* The row's stored-state paint: the "named" tag, the summary line, the
+ Reset button (a row with nothing stored has nothing to reset — the
+ phase-168 per-row reset shape) and the toggle's label + aria-label
+ (the ONLY place the source name rides on the controls — setAttribute,
+ never innerHTML, the phase-89/105 idiom). */
+ function paintLabelRowState(view) {
+ const labelled = hasStoredLabel(view.stored);
+ if (view.stateTag) view.stateTag.hidden = !labelled;
+ if (view.summaryEl) {
+ const text = labelSummaryText(view.stored);
+ view.summaryEl.textContent = text;
+ view.summaryEl.hidden = text === "";
+ }
+ view.resetBtn.hidden = !labelled;
+ view.toggleBtn.textContent = labelled ? "Edit name" : "Name this source";
+ view.toggleBtn.setAttribute(
+ "aria-label",
+ `${labelled ? "Edit" : "Set"} the name, label and tags for source: ${view.source}`,
+ );
+ view.saveBtn.setAttribute(
+ "aria-label",
+ `Save the name, label and tags for source: ${view.source}`,
+ );
+ view.resetBtn.setAttribute(
+ "aria-label",
+ `Reset the name, label and tags of source: ${view.source}`,
+ );
+ }
+
+ /* Drop a failure note the moment the draft moves again: the note
+ explains the submission the server refused, and that submission is
+ gone as soon as the owner types. Called by the edit handlers ONLY —
+ never by updateLabelRowUI, which is exactly what a finishing request
+ calls (and would erase the note it just wrote). */
+ function clearLabelError(view) {
+ if (view.statusEl.classList.contains("is-error")) setLabelStatus(view, "", null);
+ }
+
+ /* The per-row dirty paint (the phase-168 .is-dirty shape, scoped to ONE
+ row instead of the panel). While a request is out the in-flight label
+ owns the status line — a keystroke mid-save must not overwrite it. */
+ function updateLabelRowUI(view) {
+ const dirty = labelFormDirty(view);
+ view.tr.classList.toggle("is-dirty", dirty);
+ if (view.saveInFlight) return;
+ view.saveBtn.disabled = !dirty;
+ /* A FAILED save's note survives this paint (§7.4): the dirty hint and
+ the note are the same one line, and the hint is the weaker claim —
+ silently replacing "That source no longer exists — reload the
+ page." with "unsaved changes" hides the one error the owner cannot
+ fix by retrying. The note clears on the next edit (clearLabelError)
+ or on a collapse, never by re-deriving the dirty state. */
+ if (view.statusEl.classList.contains("is-error")) return;
+ if (dirty) setLabelStatus(view, LABEL_STATUS_DIRTY, "hint");
+ else if (view.statusEl.classList.contains("is-hint")) {
+ setLabelStatus(view, "", null);
+ }
+ }
+
+ /* Expand / collapse. A collapse discards the draft (no request) and
+ refills from the stored label, so a closed row never claims an edit the
+ server never got; a save in flight freezes the row. */
+ function setLabelEditorOpen(view, open) {
+ if (view.saveInFlight) return;
+ view.editor.hidden = !open;
+ view.toggleBtn.setAttribute("aria-expanded", open ? "true" : "false");
+ if (!open) {
+ fillLabelForm(view);
+ setLabelStatus(view, "", null);
+ }
+ updateLabelRowUI(view);
+ }
+
+ /* The one-line error note. The 404 arm names itself (a retry cannot fix a
+ source that left the catalogue); every other non-2xx carries the server
+ detail (apiDetail, 422 shape-aware — the cap that was crossed names
+ itself there). */
+ async function labelFailureNote(r, fallback) {
+ if (r.status === 404) return LABEL_NOTE_GONE;
+ return await apiDetail(r, fallback);
+ }
+
+ /* PUT the whole form (the three keys always ride — an empty Name is the
+ deliberate CLEAR leg, the API folds "" to NULL; the tags array is the
+ chip list). 200 → the response body IS the row's new stored label. */
+ async function saveLabelRow(view) {
+ if (view.saveInFlight) return; // §7.4 — one request per row at a time
+ const form = readLabelForm(view);
+ view.saveInFlight = true;
+ view.saveBtn.disabled = true;
+ view.resetBtn.disabled = true;
+ setLabelStatus(view, LABEL_STATUS_SAVING, null);
+ try {
+ const r = await fetch(`${LABELS_ENDPOINT}/${encodeURIComponent(view.source)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(form),
+ });
+ if (r.ok) {
+ /* The response is the new truth — NO second GET (task 03). A body
+ that cannot be parsed degrades to what was just sent (never to
+ "unknown": the write succeeded either way). */
+ let saved = null;
+ try {
+ saved = await r.json();
+ } catch {
+ saved = null;
+ }
+ view.stored = storedFromEntry(
+ saved || {
+ display_name: form.display_name || null,
+ label: form.label || null,
+ tags: form.tags,
+ },
+ );
+ fillLabelForm(view);
+ paintLabelRowState(view);
+ hideLabelsError(); // a healed save heals the catalogue line too
+ setLabelStatus(view, LABEL_STATUS_SAVED, null);
+ return;
+ }
+ setLabelStatus(view, await labelFailureNote(r, LABEL_FAIL_SAVE), "error");
+ } catch {
+ setLabelStatus(view, `${LABEL_FAIL_SAVE} Is the app reachable?`, "error");
+ } finally {
+ // Never stale on ANY outcome (§7.4): the flag drops, the buttons
+ // come back, and the dirty paint re-decides Save.
+ view.saveInFlight = false;
+ view.resetBtn.disabled = false;
+ updateLabelRowUI(view);
+ }
+ }
+
+ /* DELETE the row — the fields clear, the stored label nulls, and the
+ row settles on the same "saved" line (the row now matches the server,
+ which is all the line has ever claimed). */
+ async function resetLabelRow(view) {
+ if (view.saveInFlight) return; // the SAME per-row guard covers Reset
+ view.saveInFlight = true;
+ view.saveBtn.disabled = true;
+ view.resetBtn.disabled = true;
+ setLabelStatus(view, LABEL_STATUS_RESETTING, null);
+ try {
+ const r = await fetch(`${LABELS_ENDPOINT}/${encodeURIComponent(view.source)}`, {
+ method: "DELETE",
+ });
+ if (r.ok) {
+ // The 200 body is the entry's cleared state (task 02) — the row
+ // re-reads it instead of assuming, and the fields clear with it.
+ let cleared = null;
+ try {
+ cleared = await r.json();
+ } catch {
+ cleared = null;
+ }
+ view.stored = storedFromEntry(cleared || {});
+ fillLabelForm(view);
+ paintLabelRowState(view);
+ hideLabelsError();
+ setLabelStatus(view, LABEL_STATUS_SAVED, null);
+ return;
+ }
+ setLabelStatus(view, await labelFailureNote(r, LABEL_FAIL_RESET), "error");
+ } catch {
+ setLabelStatus(view, `${LABEL_FAIL_RESET} Is the app reachable?`, "error");
+ } finally {
+ view.saveInFlight = false;
+ view.resetBtn.disabled = false;
+ updateLabelRowUI(view);
+ }
+ }
+
+ /* Clone the page's ONE row template for one catalogue entry. The
+ template is static markup (no data): every value lands through
+ textContent / .value, and the row's own object (`view`) carries its
+ state — stored label, chip draft, saveInFlight — closed over by its
+ listeners. */
+ function makeLabelRow(entry) {
+ const proto =
+ labelRowTemplate && labelRowTemplate.content
+ ? labelRowTemplate.content.querySelector("tr")
+ : null;
+ if (!proto) return null; // defensive — the template ships with the page
+ const tr = proto.cloneNode(true);
+ labelRowSeq += 1;
+ const seq = labelRowSeq;
+ const pick = (sel) => tr.querySelector(sel);
+ const nameInput = pick(".source-label-name-input");
+ const labelInput = pick(".source-label-label-input");
+ const tagsInput = pick(".source-label-tags-input");
+ const tagsList = pick(".source-label-tags");
+ const editor = pick(".source-label-editor");
+ const toggleBtn = pick(".source-label-toggle");
+ const saveBtn = pick(".source-label-save");
+ const resetBtn = pick(".source-label-reset");
+ const statusEl = pick(".source-label-status");
+ if (!nameInput || !labelInput || !tagsInput || !tagsList || !editor ||
+ !toggleBtn || !saveBtn || !resetBtn || !statusEl) {
+ return null; // a template without the fields is a build error, not a row
+ }
+ /* Rewrite the template's static ids (and the s that point at
+ them) so every row keeps a unique id and a real label pair; the
+ editor id is the toggle's aria-controls target. */
+ for (const [input, base] of [
+ [nameInput, "source-label-name"],
+ [labelInput, "source-label-label"],
+ [tagsInput, "source-label-tags"],
+ ]) {
+ input.id = `${base}-${seq}`;
+ const lbl = tr.querySelector(`label[for="${base}"]`);
+ if (lbl) lbl.setAttribute("for", input.id);
+ }
+ editor.id = `source-label-editor-${seq}`;
+ toggleBtn.setAttribute("aria-controls", editor.id);
+
+ const codeEl = pick(".source-label-source");
+ const source = String((entry && entry.source) || "");
+ if (codeEl) codeEl.textContent = source; // the identity: read-only text
+ const cell = pick(".source-label-id-cell");
+ if (cell) cell.title = source; // long names hover in full (house idiom)
+
+ const view = {
+ source,
+ tr,
+ stored: storedFromEntry(entry),
+ tags: [],
+ saveInFlight: false,
+ nameInput,
+ labelInput,
+ tagsInput,
+ tagsList,
+ editor,
+ toggleBtn,
+ saveBtn,
+ resetBtn,
+ statusEl,
+ stateTag: pick(".source-label-state"),
+ summaryEl: pick(".source-label-summary"),
+ };
+ fillLabelForm(view);
+ paintLabelRowState(view);
+ updateLabelRowUI(view);
+
+ for (const input of [nameInput, labelInput]) {
+ input.addEventListener("input", () => {
+ clearLabelError(view);
+ updateLabelRowUI(view);
+ });
+ }
+ tagsInput.addEventListener("keydown", (e) => {
+ clearLabelError(view);
+ // Enter or a comma commits the token; the comma never "types".
+ if (e.key === "Enter" || e.key === ",") {
+ e.preventDefault();
+ addLabelTags(view, tagsInput.value);
+ return;
+ }
+ // Backspace on an empty input drops the LAST chip (the token rule).
+ if (e.key === "Backspace" && tagsInput.value === "" && view.tags.length) {
+ e.preventDefault();
+ view.tags.pop();
+ paintLabelTags(view);
+ updateLabelRowUI(view);
+ }
+ });
+ // Leaving the field commits a half-typed token (nothing is lost).
+ tagsInput.addEventListener("blur", () => {
+ if (tagsInput.value.trim()) addLabelTags(view, tagsInput.value);
+ });
+ toggleBtn.addEventListener("click", () => setLabelEditorOpen(view, editor.hidden));
+ saveBtn.addEventListener("click", () => saveLabelRow(view));
+ resetBtn.addEventListener("click", () => resetLabelRow(view));
+ return tr;
+ }
+
+ /* GET the label catalogue (ONE fetch — the union the server assembles).
+ A failure is this list's own role=alert line: the sources table above
+ keeps working (the two lists are independent), and the table + empty
+ state hide while the error is up so the note is the only claim about
+ the rows (the #git-sources-load-error discipline). */
+ async function loadLabels() {
+ let r;
+ try {
+ r = await fetch(LABELS_ENDPOINT);
+ } catch {
+ showLabelsError("Could not reach the server — the source names are not shown.");
+ return;
+ }
+ if (!r.ok) {
+ showLabelsError(
+ await apiDetail(r, `The server could not list the source names (${r.status}).`),
+ );
+ return;
+ }
+ let data;
+ try {
+ data = await r.json();
+ } catch {
+ showLabelsError("The server sent an unreadable label list — try again.");
+ return;
+ }
+ hideLabelsError();
+ renderLabelRows(Array.isArray(data.labels) ? data.labels : []);
+ }
+
+ function renderLabelRows(entries) {
+ if (!labelsTbody) return;
+ labelsTbody.replaceChildren(); // the list is REPLACED, never appended
+ for (const entry of entries) {
+ const tr = makeLabelRow(entry);
+ if (tr) labelsTbody.appendChild(tr);
+ }
+ const hasRows = labelsTbody.children.length > 0;
+ if (labelsWrap) labelsWrap.hidden = !hasRows;
+ if (labelsEmptyEl) labelsEmptyEl.hidden = hasRows;
+ }
+
+ function showLabelsError(message) {
+ if (labelsErrorEl) {
+ labelsErrorEl.textContent = message;
+ labelsErrorEl.hidden = false;
+ }
+ // The list state is unknown — hide the table AND the empty state.
+ if (labelsWrap) labelsWrap.hidden = true;
+ if (labelsEmptyEl) labelsEmptyEl.hidden = true;
+ }
+
+ function hideLabelsError() {
+ if (labelsErrorEl) {
+ labelsErrorEl.textContent = "";
+ labelsErrorEl.hidden = true;
+ }
+ }
+
/* ---------- add (POST /api/git-sources) — the git form ----------
* wireAddForm gives the form the §7.4 never-stale lifecycle: while
* the request is out the button disables + relabels "Adding…" and
@@ -1065,6 +1596,7 @@ export async function mount(root) {
if (branchInput) branchInput.value = ""; // the branch is stored (NULL when blank)
announce(opts.addedMessage);
await loadSources(); // the new row lands in the table
+ await loadLabels(); // phase 191: and it is nameable straight away
focusNewRow(createdId); // a11y: land the caret on the new row
return;
}
@@ -1333,6 +1865,7 @@ export async function mount(root) {
uploadFileInput.value = "";
restoreUploadButton();
loadSources(); // the row lands / refreshes
+ loadLabels(); // phase 191: the uploaded source joins the name list
return;
}
if (status.state === "failed") {
@@ -1524,6 +2057,13 @@ export async function mount(root) {
the whoami gate passed: anonymous shows the gate and must never
fetch /api/git-sources (the Sources-page gate pattern). */
root.addEventListener("bor:view-refresh", () => loadSources());
+ /* Phase 191 (task 03): the label catalogue rides the same re-show
+ refresh. A SECOND listener (rather than a call inside loadSources)
+ keeps the phase-77 listener above verbatim AND keeps the two lists
+ independent — the ignore-paths / hidden-folders saves reload the
+ source table without ever wiping a half-typed name draft. */
+ root.addEventListener("bor:view-refresh", () => loadLabels());
+ await loadLabels(); // phase 191: the names list (admin branch only)
await loadSources();
// Phase 64 (task 05): re-attach a running run (a reload mid-run
// resumes the bare Processing state) or re-render a terminal
diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css
index f45efab..ac9e0de 100644
--- a/frontend/assets/styles.css
+++ b/frontend/assets/styles.css
@@ -4349,9 +4349,16 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
.git-source-ignore-count idiom (TEXT + background, never
color alone — WCAG 1.4.1: --ink on --bg 16.7:1). Phase 135 (A6):
the "branch: " state tag joins the same rule — stored git
- rows with a branch only, the text IS the signal. */
+ rows with a branch only, the text IS the signal.
+ Phase 191 (task 03): the TWO new tags of the name/label/tags row join
+ the SAME rule — .source-label-state ("named", beside the mono identity)
+ and .source-label-tag (one chip of the Tags token input). That is the
+ reuse the phase asks for: the chips are the house tag, not a new
+ widget, so no tag-input styling is added anywhere below. */
.git-source-hidden-count,
-.git-source-branch-count {
+.git-source-branch-count,
+.source-label-state,
+.source-label-tag {
display: inline-block;
margin-left: 0.55rem;
padding: 0.08rem 0.5rem;
@@ -4376,8 +4383,11 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
}
/* Empty state: the tuning page's quiet centered line at full table
- width — no stored rows AND no env fallback to show. */
-.git-sources-empty {
+ width — no stored rows AND no env fallback to show. Phase 191 (task
+ 03): the name/label/tags list shares the line verbatim (its own
+ no-sources case, the same quiet card). */
+.git-sources-empty,
+.source-labels-empty {
margin: 0;
padding: 1.4rem 1rem;
text-align: center;
@@ -4389,6 +4399,251 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
box-shadow: var(--shadow);
}
+/* ---------- Source names, labels and tags (phase 191, task 03) ----------
+ The Sources page's SECOND list: every source the knowledge base knows
+ (git, local, upload — the union the phase-191 catalogue GET returns),
+ each with the optional name / label / tags the model is told about
+ (D1: an ALIAS — the mono identity cell is read-only text, nothing here
+ renames a document, a citation or a locator).
+ Nothing new is invented: the card is the phase-156 .table-wrap (bounded
+ panel + sticky header, inherited by the class alone), the identity cell
+ is the .git-source-url-cell mono, the state tag and the tag CHIPS are
+ the phase-89 tag rule above, the field boxes are the .ignore-editor-
+ token / #ignore-editor-branch language at the 44px floor, Save is the
+ page's brand pill, Reset and the row's toggle are the neutral
+ .git-source-ignore language, and the dirty mark is the phase-168
+ the phase-168 dirty-row stripe. PALETTE VARIABLES ONLY — no new hue
+ (every pair is a pair this page already measures: ink 15.3:1 and
+ ink-soft 8.2:1 on --surface, brand-ink on brand-soft 12.4:1, the err
+ pair 9.3:1, --bg ink on --brand 5.2:1); :focus-visible rides the global
+ 3px outline rule; no CDN, no blur. The editor expands UNDER its own row
+ (one row open at a time keeps a 27-source list scannable) and its three
+ fields stack in a single column below 60rem instead of pushing the card
+ into a horizontal scroll at 375px. */
+.source-labels {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+.source-labels-title {
+ margin: 0;
+ font-size: 1.25rem;
+ line-height: 1.3;
+ color: var(--ink);
+}
+/* The alias note (the contract in the owner's words) — the page-sub
+ language, ink-soft on the page canvas (8.2:1 on --surface). */
+.source-labels-sub {
+ margin: 0;
+ max-width: 60rem;
+ color: var(--ink-soft);
+ font-size: 0.88rem;
+}
+/* The list's own load failure (role=alert) — the .git-source-error
+ pair (err-ink on err-bg 9.3:1). */
+.source-labels-error {
+ margin: 0;
+ padding: 0.45rem 0.8rem;
+ background: var(--err-bg);
+ color: var(--err-ink);
+ border: 1px solid var(--err-line);
+ border-radius: var(--radius-sm);
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+/* No min-width on purpose: the editor column has to SHRINK (the fields
+ stack), so this table never scrolls the card sideways at 375px — the
+ panel still bounds vertically (.table-wrap). */
+.source-labels-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.93rem;
+}
+.source-labels-table th, .source-labels-table td {
+ text-align: left;
+ vertical-align: top;
+ padding: 0.7rem 1rem;
+ border-bottom: 1px solid var(--line);
+}
+.source-labels-table th {
+ color: var(--brand-ink);
+ font-size: 0.82rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.source-labels-table tbody tr:last-child td { border-bottom: 0; }
+/* The identity cell: the derived name, mono like the sources table's
+ location cell, wrapping (overflow-wrap) instead of widening the card —
+ it is text, never an input (D1/A1). */
+.source-labels-table td.source-label-id-cell {
+ font-family: var(--mono);
+ font-size: 0.82rem;
+ overflow-wrap: anywhere;
+}
+.source-labels-table td.source-label-id-cell code { font-family: inherit; }
+/* The dirty row mark — the phase-168 dirty-row stripe (the brand edge is
+ beside the row's own "unsaved changes" TEXT, so the state is never color
+ alone — WCAG 1.4.1). The literal is NOT written in this comment: the
+ phase-168 pin (test_settings_frontend_view.py) regex-matches that
+ selector to its own rule and a comment mention would steal the match. */
+.source-label-row.is-dirty > td:first-child { box-shadow: inset 3px 0 0 var(--brand); }
+/* The collapsed row's read-out of the stored label (name · label · tags).
+ textContent-only (a label is owner text — the XSS contract). */
+.source-label-summary {
+ margin: 0 0 0.45rem;
+ color: var(--ink-soft);
+ font-size: 0.85rem;
+ overflow-wrap: anywhere;
+}
+/* The row's expand trigger — the .git-source-ignore language (neutral
+ secondary fill, >=44px, hovers to the brand pair 12.4:1). */
+.source-label-toggle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ max-width: 100%;
+ min-height: 44px;
+ min-width: 44px;
+ padding: 0.35rem 0.7rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--ink-soft);
+ font: inherit;
+ font-weight: 600;
+ font-size: 0.82rem;
+ white-space: nowrap;
+ cursor: pointer;
+}
+.source-label-toggle:hover:not(:disabled) {
+ background: var(--brand-soft);
+ color: var(--brand-ink);
+}
+/* The expanded editor panel — the row's own form card. The fields go in
+ one column narrow, three across from 60rem (the house grid). */
+.source-label-editor {
+ display: grid;
+ gap: 0.9rem;
+ margin-top: 0.75rem;
+ padding: 0.9rem;
+ background: var(--bg);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+}
+@media (min-width: 60rem) {
+ .source-label-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+.source-label-fields {
+ display: grid;
+ gap: 0.75rem;
+}
+.source-label-field {
+ display: block;
+ min-width: 0;
+}
+/* The field label — the .ignore-editor-label language (ink-soft 8.2:1 on
+ the panel, 600 weight, the hint span the .field-hint secondary read). */
+.source-label-field-label {
+ display: block;
+ margin-bottom: 0.35rem;
+ color: var(--ink-soft);
+ font-size: 0.88rem;
+ font-weight: 600;
+}
+/* The field box — the .ignore-editor-token / #ignore-editor-branch box
+ language VERBATIM (full width, --bg… panel fill, the line border, mono)
+ at the 44px touch floor; the global [hidden] rule and the global
+ :focus-visible outline cover the rest. */
+.source-label-input {
+ display: block;
+ width: 100%;
+ min-height: 44px;
+ padding: 0.55rem 0.65rem;
+ font-family: var(--mono);
+ font-size: 0.85rem;
+ color: var(--ink);
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+}
+/* The Tags token input's chip list — the LAYOUT only (the chips
+ themselves are the shared tag rule above, so the widget adds no tag
+ styling). list-style is dropped, so role="list" stays in the markup
+ (the VoiceOver list-semantics idiom the house uses). */
+.source-label-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ margin: 0 0 0.45rem;
+ padding: 0;
+ list-style: none;
+}
+.source-label-tags .source-label-tag { margin-left: 0; }
+.source-label-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.6rem;
+}
+/* Save — the page's brand pill at the row scale (--bg ink on --brand,
+ 5.2:1); disabled until the row is dirty, and again while the PUT is
+ out (§7.4 — the row's own in-flight state). */
+.source-label-save {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 44px;
+ padding: 0.4rem 1.1rem;
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: var(--brand);
+ color: var(--bg);
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+}
+.source-label-save:hover:not(:disabled) { background: var(--brand-hover); }
+.source-label-save:disabled { opacity: 0.6; cursor: default; }
+/* Reset — the neutral secondary button (the .git-source-ignore language):
+ it clears a STORED label, it is not the destructive row Remove. */
+.source-label-reset {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 44px;
+ padding: 0.35rem 0.9rem;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--ink-soft);
+ font: inherit;
+ font-weight: 600;
+ font-size: 0.82rem;
+ cursor: pointer;
+}
+.source-label-reset:hover:not(:disabled) {
+ background: var(--brand-soft);
+ color: var(--brand-ink);
+}
+.source-label-reset:disabled { opacity: 0.5; cursor: wait; }
+/* The §7.4 save-state line (role=status): "Saving…" → "saved" / the
+ one-line error note. The hint (unsaved) is the muted italic; the error
+ takes the err pair (9.3:1) so the note reads as a failure, not as a
+ label — TEXT carries it either way, never color alone. */
+.source-label-status {
+ color: var(--ink-soft);
+ font-size: 0.82rem;
+}
+.source-label-status.is-hint { font-style: italic; }
+.source-label-status.is-error {
+ padding: 0.35rem 0.6rem;
+ color: var(--err-ink);
+ background: var(--err-bg);
+ border: 1px solid var(--err-line);
+ border-radius: var(--radius-sm);
+ font-weight: 600;
+}
+
/* ---------- History refresh button (phase 77 task 03, TODO.md L3) ----------
The #view-history page-head is a flex row: the title block left, the
actions slot right (the row wraps below 640px). Scoped to
diff --git a/frontend/index.html b/frontend/index.html
index 8882911..42143e7 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -840,6 +840,124 @@
pruned on that run).
+
+
+ Names, labels and tags
+
+ A name is what you call a source (“the git server”)
+ and a tag is a focus token — the model is told both, so a
+ question that uses the name finds that source’s documents.
+ Nothing here renames a source: the mono name below stays the
+ identity of every document, citation and link.
+
+
+
+
+
+ Every source the knowledge base knows — git repositories, local directories and uploaded archives — each with the optional name, label and tags the model reads. The mono name in the first column is the source’s identity and never changes.
+
+
+ Source
+ Name, label and tags
+
+
+
+
+
+
+ No sources to name yet.
+
+
+
+
+
+
+
+ named
+
+
+
+ Name this source
+
+
+
+ Name optional — what you call it
+
+
+
+ Label optional — one line about it
+
+
+
+
Tags optional — Enter or comma adds one
+
+
+
+
+
+ Save
+ Reset
+
+
+
+
+
+
+
+