From 32a3ec4f5ef1892bef8d0638f2bb46ce09e66cb3 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Thu, 1 Oct 2026 11:13:43 -0400 Subject: [PATCH] phase: 145_git_source_links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Phase 145 final verification pass — all green, no code changes needed** - **Verified (no defects found):** resolver `origin_for_source` + `_url_basename` (scp/`.git`/trailing-slash, sanitize, ambiguity WARNING); `origin:`/`branch:` lines in all four read shapes via shared `_origin_lines`; HIGH `` origin rule (verbatim + only-byte-change pins); `DocContent.origin_url/origin_branch` (absent-never-null serializer); `renderDocument` "Original" anchor (copyable verbatim URL, `_blank`/`noopener`, branch in title); mock scripted origin-link flow (disjoint trigger — grep-verified vs all other E2E files) - **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → 3402 passed, `app/` coverage **99%** (>90% gate); `uv run pytest tests/e2e/test_git_source_links.py -v --no-cov` → **4 passed** in isolation; `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings - **Completion criteria:** (1) git read results carry origin/branch, local byte-identical — ✅ (unit pins + E2E control); (2) HIGH `` rule pinned, LOW byte-identical — ✅; (3) modal AND /document.html show Original link, branch in label when pinned, nothing for local — ✅; (4) full suite green + coverage + E2E + lint/types — ✅; (5) no completed-phase behavior change (read header line 1, mock prefix, done frame/SourceRef untouched, LOW sha) — ✅ - **Extra regression check:** re-ran shared-mock E2E suites `test_read_line_ranges.py` (2 passed) and `test_document_viewer.py` (8 passed) — no cross-suite regressions from the `mock_llm.py` addition - **Documentation:** README.md reviewed in full (env table ⊇ `.env.example`, pages/routes, config defaults, scripts, 17-color palette, 300 s guard, 2 s sync poll, phase-145 provenance section matches implementation) and AGENTS.md (72rem container, no-CDN test, smoke test, gitignore, gates) — **verified consistent, no changes needed** - **Notable:** phase-116 screenshot PNG was regenerated during this phase's E2E runs (byte re-encode only, visually identical, correct no-anchor local-source meta row) — left in tree for the harness commit - **Next pending phase:** `146_search_tool_scope` (then 147, 148, 149) --- .../145_git_source_links/00_phase.md | 0 .../01_origin_resolution.md | 0 .../02_prompt_teaching.md | 0 .../03_content_payload_render_link.md | 0 .../04_e2e_source_link.md | 0 .../145_git_source_links__00_phase.a1.err | 0 .../145_git_source_links__00_phase.a1.md | 9 + ...145_git_source_links__00_phase.a1.validate | 119 ++++ ..._source_links__01_origin_resolution.a1.err | 0 ...t_source_links__01_origin_resolution.a1.md | 17 + ...ce_links__01_origin_resolution.a1.validate | 118 ++++ ...it_source_links__02_prompt_teaching.a1.err | 0 ...git_source_links__02_prompt_teaching.a1.md | 21 + ...urce_links__02_prompt_teaching.a1.validate | 119 ++++ ...nks__03_content_payload_render_link.a1.err | 0 ...inks__03_content_payload_render_link.a1.md | 19 + ...03_content_payload_render_link.a1.validate | 119 ++++ ...it_source_links__04_e2e_source_link.a1.err | 0 ...git_source_links__04_e2e_source_link.a1.md | 17 + ...urce_links__04_e2e_source_link.a1.validate | 119 ++++ .../screenshots/doc_modal_scrollbar_1280.png | Bin 42473 -> 41225 bytes README.md | 15 + app/api/docs.py | 18 + app/rag/agent.py | 47 +- app/rag/git_sources.py | 77 +++ app/rag/prompts.py | 46 +- app/schemas.py | 18 + frontend/assets/document.js | 52 ++ frontend/assets/styles.css | 23 + tests/e2e/mock_llm.py | 242 ++++++++ tests/e2e/test_git_source_links.py | 585 ++++++++++++++++++ tests/integration/test_document_content.py | 136 +++- tests/unit/test_agent.py | 332 +++++++++- tests/unit/test_document_viewer.py | 370 ++++++++++- tests/unit/test_git_sources.py | 121 ++++ tests/unit/test_prompt_lock.py | 81 ++- tests/unit/test_prompts.py | 38 ++ 37 files changed, 2856 insertions(+), 22 deletions(-) rename .agents/phases/{todo => complete}/145_git_source_links/00_phase.md (100%) rename .agents/phases/{todo => complete}/145_git_source_links/01_origin_resolution.md (100%) rename .agents/phases/{todo => complete}/145_git_source_links/02_prompt_teaching.md (100%) rename .agents/phases/{todo => complete}/145_git_source_links/03_content_payload_render_link.md (100%) rename .agents/phases/{todo => complete}/145_git_source_links/04_e2e_source_link.md (100%) create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__00_phase.a1.err create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__00_phase.a1.md create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__00_phase.a1.validate create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__01_origin_resolution.a1.err create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__01_origin_resolution.a1.md create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__01_origin_resolution.a1.validate create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__02_prompt_teaching.a1.err create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__02_prompt_teaching.a1.md create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__02_prompt_teaching.a1.validate create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__03_content_payload_render_link.a1.err create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__03_content_payload_render_link.a1.md create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__03_content_payload_render_link.a1.validate create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__04_e2e_source_link.a1.err create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__04_e2e_source_link.a1.md create mode 100644 .agents/reports/145_git_source_links/145_git_source_links__04_e2e_source_link.a1.validate create mode 100644 tests/e2e/test_git_source_links.py diff --git a/.agents/phases/todo/145_git_source_links/00_phase.md b/.agents/phases/complete/145_git_source_links/00_phase.md similarity index 100% rename from .agents/phases/todo/145_git_source_links/00_phase.md rename to .agents/phases/complete/145_git_source_links/00_phase.md diff --git a/.agents/phases/todo/145_git_source_links/01_origin_resolution.md b/.agents/phases/complete/145_git_source_links/01_origin_resolution.md similarity index 100% rename from .agents/phases/todo/145_git_source_links/01_origin_resolution.md rename to .agents/phases/complete/145_git_source_links/01_origin_resolution.md diff --git a/.agents/phases/todo/145_git_source_links/02_prompt_teaching.md b/.agents/phases/complete/145_git_source_links/02_prompt_teaching.md similarity index 100% rename from .agents/phases/todo/145_git_source_links/02_prompt_teaching.md rename to .agents/phases/complete/145_git_source_links/02_prompt_teaching.md diff --git a/.agents/phases/todo/145_git_source_links/03_content_payload_render_link.md b/.agents/phases/complete/145_git_source_links/03_content_payload_render_link.md similarity index 100% rename from .agents/phases/todo/145_git_source_links/03_content_payload_render_link.md rename to .agents/phases/complete/145_git_source_links/03_content_payload_render_link.md diff --git a/.agents/phases/todo/145_git_source_links/04_e2e_source_link.md b/.agents/phases/complete/145_git_source_links/04_e2e_source_link.md similarity index 100% rename from .agents/phases/todo/145_git_source_links/04_e2e_source_link.md rename to .agents/phases/complete/145_git_source_links/04_e2e_source_link.md diff --git a/.agents/reports/145_git_source_links/145_git_source_links__00_phase.a1.err b/.agents/reports/145_git_source_links/145_git_source_links__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/145_git_source_links/145_git_source_links__00_phase.a1.md b/.agents/reports/145_git_source_links/145_git_source_links__00_phase.a1.md new file mode 100644 index 0000000..bcc195b --- /dev/null +++ b/.agents/reports/145_git_source_links/145_git_source_links__00_phase.a1.md @@ -0,0 +1,9 @@ +**Phase 145 final verification pass — all green, no code changes needed** + +- **Verified (no defects found):** resolver `origin_for_source` + `_url_basename` (scp/`.git`/trailing-slash, sanitize, ambiguity WARNING); `origin:`/`branch:` lines in all four read shapes via shared `_origin_lines`; HIGH `` origin rule (verbatim + only-byte-change pins); `DocContent.origin_url/origin_branch` (absent-never-null serializer); `renderDocument` "Original" anchor (copyable verbatim URL, `_blank`/`noopener`, branch in title); mock scripted origin-link flow (disjoint trigger — grep-verified vs all other E2E files) +- **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → 3402 passed, `app/` coverage **99%** (>90% gate); `uv run pytest tests/e2e/test_git_source_links.py -v --no-cov` → **4 passed** in isolation; `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings +- **Completion criteria:** (1) git read results carry origin/branch, local byte-identical — ✅ (unit pins + E2E control); (2) HIGH `` rule pinned, LOW byte-identical — ✅; (3) modal AND /document.html show Original link, branch in label when pinned, nothing for local — ✅; (4) full suite green + coverage + E2E + lint/types — ✅; (5) no completed-phase behavior change (read header line 1, mock prefix, done frame/SourceRef untouched, LOW sha) — ✅ +- **Extra regression check:** re-ran shared-mock E2E suites `test_read_line_ranges.py` (2 passed) and `test_document_viewer.py` (8 passed) — no cross-suite regressions from the `mock_llm.py` addition +- **Documentation:** README.md reviewed in full (env table ⊇ `.env.example`, pages/routes, config defaults, scripts, 17-color palette, 300 s guard, 2 s sync poll, phase-145 provenance section matches implementation) and AGENTS.md (72rem container, no-CDN test, smoke test, gitignore, gates) — **verified consistent, no changes needed** +- **Notable:** phase-116 screenshot PNG was regenerated during this phase's E2E runs (byte re-encode only, visually identical, correct no-anchor local-source meta row) — left in tree for the harness commit +- **Next pending phase:** `146_search_tool_scope` (then 147, 148, 149) diff --git a/.agents/reports/145_git_source_links/145_git_source_links__00_phase.a1.validate b/.agents/reports/145_git_source_links/145_git_source_links__00_phase.a1.validate new file mode 100644 index 0000000..58d878c --- /dev/null +++ b/.agents/reports/145_git_source_links/145_git_source_links__00_phase.a1.validate @@ -0,0 +1,119 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 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/auth.py 52 0 100% +app/api/chat.py 268 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 181 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 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 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 705 9 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 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/schemas.py 430 0 100% +-------------------------------------------------- +TOTAL 5537 22 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/145_git_source_links/145_git_source_links__01_origin_resolution.a1.err b/.agents/reports/145_git_source_links/145_git_source_links__01_origin_resolution.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/145_git_source_links/145_git_source_links__01_origin_resolution.a1.md b/.agents/reports/145_git_source_links/145_git_source_links__01_origin_resolution.a1.md new file mode 100644 index 0000000..af425b7 --- /dev/null +++ b/.agents/reports/145_git_source_links/145_git_source_links__01_origin_resolution.a1.md @@ -0,0 +1,17 @@ +Task complete — all green. + +**Implemented** +- `app/rag/git_sources.py`: `_url_basename` (path component after `://` or scp-style `:`, trailing `/` + single `.git` stripped, last segment) and `origin_for_source(db, source_name)` beside `sanitize_url` — `kind='git'` rows only, exact-match → `(sanitize_url(url), branch)`, zero/two-or-more → `None` + WARNING (sanitized candidates), pure-DB (no git subprocess). +- `app/rag/agent.py`: shared `_origin_lines` helper; origin computed ONCE per successful read before the ranged/truncated branching; all four result builders insert `origin:`/`branch:` immediately after `date:` (before the image marker / `lines:` header); first line and all non-git results byte-identical. +- Tests: 4-shape × 4-origin read-result matrix in `tests/unit/test_agent.py` (exact strings, first-line `_READ_RESULT_PREFIX` contract, phase-122 marker pin, caplog ambiguity WARNING, real resolver driven via a git-only mock session) + resolver units in `tests/unit/test_git_sources.py` (basename mapping incl. scp-style/path-`@`, legacy `user:tok@` masked, zero/ambiguous `None`+WARNING, branch passthrough, local rows never origins). + +**Results** +- `uv run pytest --cov=app --cov-report=term-missing`: **3382 passed**, `app/` coverage **99%** (`git_sources.py` 100%, `agent.py` 99%) +- `uv run ruff check .` clean; `uv run pyright`: 0 errors +- Bonus: `uv run pytest tests/e2e/test_smoke.py -v --no-cov`: 3 passed (mock parse + SSE unaffected) + +**Docs**: no doc impact — model-internal read-result lines only; user-visible "Original" link arrives in task 03. + +**Decisions**: zero-match case also logs a WARNING (per task spec's "zero matches … → None + WARNING"); `origin:` sits above the `lines:` header in range shapes (strictly "immediately after the `date:` line"). + +**Next pending task**: `02_prompt_teaching.md` (the `` original-source rule + prompt pins). diff --git a/.agents/reports/145_git_source_links/145_git_source_links__01_origin_resolution.a1.validate b/.agents/reports/145_git_source_links/145_git_source_links__01_origin_resolution.a1.validate new file mode 100644 index 0000000..d3026d2 --- /dev/null +++ b/.agents/reports/145_git_source_links/145_git_source_links__01_origin_resolution.a1.validate @@ -0,0 +1,118 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 97%] +...................................................................... [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/auth.py 52 0 100% +app/api/chat.py 268 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 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 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 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 705 9 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 154 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 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/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5528 22 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/145_git_source_links/145_git_source_links__02_prompt_teaching.a1.err b/.agents/reports/145_git_source_links/145_git_source_links__02_prompt_teaching.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/145_git_source_links/145_git_source_links__02_prompt_teaching.a1.md b/.agents/reports/145_git_source_links/145_git_source_links__02_prompt_teaching.a1.md new file mode 100644 index 0000000..69ac298 --- /dev/null +++ b/.agents/reports/145_git_source_links/145_git_source_links__02_prompt_teaching.a1.md @@ -0,0 +1,21 @@ +Task 02 complete. All gates 100% green. + +## Report + +**Implemented** +- `app/rag/prompts.py`: new canonical `ORIGIN_RULE` constant; inserted the locked verbatim original-source rule into `TOOLS_SECTION` at the `read` clause's end (after the truncation workflow sentence, before the `grep` clause). +- `app/rag/agent.py`: `AGENT_TOOLS` `read` description gains the one-clause pointer ("the result also carries an `origin` line (and `branch` when pinned) — include that original source in your answer (see your tools instructions)"). +- Pins: re-cut `TOOLS_SECTION` sha/len + kept pre-phase-145 anchors for a diff-style pin (rule is the *only* byte change); added verbatim+position, HIGH-only/LOW-absent, read-description-clause, and citation-discipline-unchanged tests. +- `README.md`: concise git-source-provenance note (origin/branch line + the answer link). + +**Results** +- `uv run pytest` → **3385 passed, 0 failed** +- `uv run pytest --cov=app` → **app/ 99%** (>90%); `prompts.py` 100%, `agent.py` 99% +- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors/warnings +- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` → 3 passed (mock regression check) + +**Docs**: README updated; AGENTS.md no impact (no command/config/convention change). + +**Decisions**: `ORIGIN_RULE` made the single source of truth, cross-pinned to the `TOOLS_SECTION` literal (removing it reproduces pre-phase bytes byte-for-byte); kept the LOW prompt and the mock's `` marker keying untouched; README describes only the prompt teaching + origin line (the UI "Original" anchor is task 03, so not over-claimed). + +**Next pending task**: `03_content_payload_render_link.md`. diff --git a/.agents/reports/145_git_source_links/145_git_source_links__02_prompt_teaching.a1.validate b/.agents/reports/145_git_source_links/145_git_source_links__02_prompt_teaching.a1.validate new file mode 100644 index 0000000..fdfdb73 --- /dev/null +++ b/.agents/reports/145_git_source_links/145_git_source_links__02_prompt_teaching.a1.validate @@ -0,0 +1,119 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 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/auth.py 52 0 100% +app/api/chat.py 268 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 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 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 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 705 9 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 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/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5529 22 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/145_git_source_links/145_git_source_links__03_content_payload_render_link.a1.err b/.agents/reports/145_git_source_links/145_git_source_links__03_content_payload_render_link.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/145_git_source_links/145_git_source_links__03_content_payload_render_link.a1.md b/.agents/reports/145_git_source_links/145_git_source_links__03_content_payload_render_link.a1.md new file mode 100644 index 0000000..2f160e8 --- /dev/null +++ b/.agents/reports/145_git_source_links/145_git_source_links__03_content_payload_render_link.a1.md @@ -0,0 +1,19 @@ +**Task 03 complete — report** + +**Implemented** +- `app/schemas.py`: `DocContent` gains `origin_url`/`origin_branch` (optional metadata, `image_url` style); serializer drops both when `None` — never `null` (non-git payload byte-identical) +- `app/api/docs.py`: `get_document_content` calls task-01's `origin_for_source(db, doc.source)` (same resolver as the read branch) and sets the two fields +- `frontend/assets/document.js`: new `originAnchor()` helper (`createElement("a")`, full URL verbatim as href+text, `target="_blank" rel="noopener"`, title = URL + ` — branch: ` when pinned); `renderDocument` inserts it after the path `code`, before the Created badge — one shared site for modal AND /document.html; absent → pre-phase meta row +- `frontend/assets/styles.css`: `.doc-origin` (`.doc-path` ellipsis idiom + `.doc-link` brand-ink affordance) + ≤640px 16rem squeeze +- Tests: 10 unit (model omission rule, endpoint stubbed git/pinned/sanitized/local/ambiguous, anchor source pins + node behavioral branch/no-branch variants, position pin, XSS contract, CSS pin) + 4 integration (seeded git row, pinned row, legacy credential sanitized end-to-end, plain local source → keys ABSENT) + +**Results** +- `uv run pytest --cov=app --cov-report=term-missing`: **3399 passed**, `app/` coverage **99%** (>90%) +- `uv run ruff check .`: clean; `uv run pyright`: **0 errors** +- E2E spot-runs in isolation: `tests/e2e/test_document_viewer.py` 8 passed, `tests/e2e/test_git_source_dates.py` 4 passed (screenshot visually identical — no anchor on the local fixture) + +**Docs**: README phase-145 section extended with the viewer's "Original" link; AGENTS.md — no doc impact (no command/config/convention change) + +**Notable decisions**: meta-row children built with a conditional spread (`...originItems`) so all pre-phase badge pins (sources_dates/date_editor/image_documents) pass untouched; existing E2E meta pins are selector-based and unaffected. Dev DB confirmed an empty test sandbox (integration truncation is the established pattern). + +**Next pending task**: `.agents/phases/todo/145_git_source_links/04_e2e_source_link.md` diff --git a/.agents/reports/145_git_source_links/145_git_source_links__03_content_payload_render_link.a1.validate b/.agents/reports/145_git_source_links/145_git_source_links__03_content_payload_render_link.a1.validate new file mode 100644 index 0000000..58d878c --- /dev/null +++ b/.agents/reports/145_git_source_links/145_git_source_links__03_content_payload_render_link.a1.validate @@ -0,0 +1,119 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 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/auth.py 52 0 100% +app/api/chat.py 268 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 181 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 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 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 705 9 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 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/schemas.py 430 0 100% +-------------------------------------------------- +TOTAL 5537 22 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/145_git_source_links/145_git_source_links__04_e2e_source_link.a1.err b/.agents/reports/145_git_source_links/145_git_source_links__04_e2e_source_link.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/145_git_source_links/145_git_source_links__04_e2e_source_link.a1.md b/.agents/reports/145_git_source_links/145_git_source_links__04_e2e_source_link.a1.md new file mode 100644 index 0000000..691a9d0 --- /dev/null +++ b/.agents/reports/145_git_source_links/145_git_source_links__04_e2e_source_link.a1.md @@ -0,0 +1,17 @@ +Task 04 complete — all gates 100% green. + +**Implemented** +- `tests/e2e/mock_llm.py`: new scripted `ORIGIN_LINK_TRIGGER` flow ("original source link", house style — docstring entry + constants + `_origin_link_url` + `_origin_link_flow` + dispatch before the plain tools flow). Request 1: scripted `read` (question-carried call); request 2: deterministic final answer — repo-URL sentence parsed by the mock FROM the read result's `origin:` line (the server-side half — the mock cannot fabricate it) + the read result VERBATIM (the rendered lens on the `origin:`/`branch:` lines); no-origin sentence for local docs; `Premise broke:` echo for refusals +- `tests/e2e/test_git_source_links.py`: dedicated Playwright suite, mock-only, isolated — 2-source KB (`skills` local dir whose name matches the seeded git row's URL basename `https://example.com/reese/skills` + `control` byte-identity dir), importer-in-thread, git row seeded per-test in the suite's own DB session (never at import time, so no clone); 4 tests: origin line in tool output + repo URL in done answer / modal `.doc-origin` anchor (href, `_blank`, `noopener`, verbatim URL text, bare title) / pinned-branch `title` (`… — branch: main`, text stays bare) + `branch: main` in the result / local control with NO `origin:` line and NO anchor + +**Results** +- `uv run pytest tests/e2e/test_git_source_links.py -v --no-cov` → **4 passed** (in isolation) +- Regression, in isolation: `tests/e2e/test_read_line_ranges.py` → 2 passed; `tests/e2e/test_document_viewer.py` → 8 passed +- `uv run pytest --cov=app --cov-report=term-missing` → **3399 passed**, `app/` coverage **99%** (>90%) +- `uv run ruff check .` → clean; `uv run pyright` → **0 errors** + +**Docs**: no doc impact (test-only task; README's phase-145 section was already accurate from tasks 02/03) + +**Decisions**: the phase-117 compact tool surface renders the *call* (not result text), so the origin-line assertion rides the mock's verbatim echo in the answer — the house scripted-turn lens; mock URL sentinel is `""` (homogeneous `tuple[str, …]`, pyright) + +**Next pending task**: none — 04 is phase 145's last task (harness performs the phase's final pass). diff --git a/.agents/reports/145_git_source_links/145_git_source_links__04_e2e_source_link.a1.validate b/.agents/reports/145_git_source_links/145_git_source_links__04_e2e_source_link.a1.validate new file mode 100644 index 0000000..58d878c --- /dev/null +++ b/.agents/reports/145_git_source_links/145_git_source_links__04_e2e_source_link.a1.validate @@ -0,0 +1,119 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 16%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 86%] +........................................................................ [ 88%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 95%] +........................................................................ [ 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/auth.py 52 0 100% +app/api/chat.py 268 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 181 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 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 431 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 705 9 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 155 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 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/schemas.py 430 0 100% +-------------------------------------------------- +TOTAL 5537 22 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/screenshots/doc_modal_scrollbar_1280.png b/.agents/screenshots/doc_modal_scrollbar_1280.png index 88b7d35e952903ace0f548f597627d4f9c94536e..54fc60ae7a21a285748695a48dc5f88a7838e240 100644 GIT binary patch literal 41225 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 tuple[Document | None, str, str return find_document(db, source, path), source, path +def _origin_lines(origin: tuple[str, str | None] | None) -> str: + """Phase 145 (task 01) — the read result's origin lines, shared by + the four result builders (the lines are identical in every shape). + + ``None`` (the document's source has no unambiguous ``kind='git'`` + row — a local source, zero matches, or an ambiguous basename) → + ``""``: the result stays byte-identical to pre-phase. Otherwise + one ``origin: {url}`` line (the sanitized repository URL — the + phase-121 mask, so a legacy embedded-credential row's secret never + reaches the prompt) and, when a branch is pinned (non-NULL), one + ``branch: {branch}`` line. The builders insert the block + IMMEDIATELY AFTER the ``date:`` line and BEFORE the image marker + (the marker keeps its phase-122 position relative to the content, + the origin lines ride above it); the FIRST line + (``Document {source}/{path}:``) is untouched — the E2E mock's + ``_READ_RESULT_PREFIX`` header contract. + """ + if origin is None: + return "" + url, branch = origin + lines = f"origin: {url}\n" + if branch is not None: + lines += f"branch: {branch}\n" + return lines + + def all_documents(db: Session) -> list[Document]: """Every indexed document (full rows), ordered by ``(source, path)`` — catalog order. @@ -1728,6 +1757,14 @@ def _execute_tool( if (doc.source, doc.path) not in known: holder.read_docs.append(doc) holder.tool_calls += 1 + # Phase 145 (task 01): the git-sourced document's repository + # origin — computed ONCE per successful read, BEFORE the + # ranged/truncated branching (the lines are identical in all + # four result shapes, which each render the shared block via + # :func:`_origin_lines`). ``None`` (local source, no git row, + # ambiguous basename) → no lines: the result stays + # byte-identical to pre-phase. + origin = origin_for_source(db, doc.source) # Phase 122 (task 05): an image document's content IS the vision # description — the marker line (a third line between the # byte-identical header/date lines and the text) tells the model @@ -1775,6 +1812,7 @@ def _execute_tool( return ( f"Document {doc.source}/{doc.path}:\n" f"date: {doc.created_at:%Y-%m-%d}\n" + f"{_origin_lines(origin)}" f"{marker}" f"{doc.content[:cap]}\n" f"{TRUNCATION_MARKER}\n" @@ -1792,6 +1830,7 @@ def _execute_tool( return ( f"Document {doc.source}/{doc.path}:\n" f"date: {doc.created_at:%Y-%m-%d}\n" + f"{_origin_lines(origin)}" f"{marker}" f"{doc.content}" ) @@ -1829,6 +1868,7 @@ def _execute_tool( return ( f"Document {doc.source}/{doc.path}:\n" f"date: {doc.created_at:%Y-%m-%d}\n" + f"{_origin_lines(origin)}" f"lines: {first}-{end} of {total_lines}\n" f"{marker}{slice_text[:cap]}\n" f"{TRUNCATION_MARKER}\n" @@ -1842,6 +1882,7 @@ def _execute_tool( return ( f"Document {doc.source}/{doc.path}:\n" f"date: {doc.created_at:%Y-%m-%d}\n" + f"{_origin_lines(origin)}" f"lines: {first}-{end} of {total_lines}\n" f"{marker}{slice_text}" ) diff --git a/app/rag/git_sources.py b/app/rag/git_sources.py index db4229e..4668078 100644 --- a/app/rag/git_sources.py +++ b/app/rag/git_sources.py @@ -86,6 +86,83 @@ def sanitize_url(url: str) -> str: return _USERINFO_RE.sub(r"\1", url, count=1) +def _url_basename(url: str) -> str: + """Phase 145 (task 01) — the repository directory a git row's URL + imports into. + + The URL's path component — after the ``://``, or after the ``:`` + for scp-style ``git@host:owner/repo`` (the port-carrying + ``ssh://user@host:22/…`` form already fell into the ``://`` + branch) — with trailing ``/`` stripped, a single trailing ``.git`` + suffix stripped, and the last segment taken. The importer clones + into (and names the source after) exactly this basename + (``source = root.name``), so it is the join key between a + document's ``source`` name and the ``git_sources`` row whose clone + produced it. An ``@`` inside the *path* is not userinfo and is + never treated as one (no split, no strip). + """ + rest = url + if "://" in rest: + rest = rest.partition("://")[2] + elif ":" in rest: + # scp-style ``git@host:owner/repo`` — the path component starts + # after the colon (``host:port`` has no other form here: the + # only colon-carrying scheme without ``//`` is the scp one). + rest = rest.partition(":")[2] + rest = rest.rstrip("/") + if rest.endswith(".git"): + rest = rest[: -len(".git")] + return rest.rsplit("/", 1)[-1] + + +def origin_for_source(db: Session, source_name: str) -> tuple[str, str | None] | None: + """Phase 145 (task 01) — the origin a read result of *source_name* + documents carries: the sanitized repository URL (and the pinned + branch, when one exists), or ``None``. + + The candidate rows are the ``kind='git'`` rows (local rows are + never origins — nothing to link to from the user's browser); a row + matches when its URL basename (:func:`_url_basename` — the clone + directory the importer names the source after) equals + ``source_name``. + + * EXACTLY ONE match → ``(sanitize_url(row.url), row.branch)`` — + the phase-121 output mask keeps a legacy embedded-credential + row's secret out of the prompt (the stored value is never + modified — the :func:`clone_url_for` lesson), and ``branch`` is + the pinned branch or ``None`` (NULL = the default branch); + * zero matches, or TWO-OR-MORE (two repos sharing a basename) → + ``None`` + a WARNING log (the ambiguity names the sanitized + candidates) — never guess: the read result would otherwise + carry a wrong origin into every answer about the document, and + a ``None`` result is byte-identical to pre-phase. + + Pure-DB helper — one query, no git subprocess + (``scripts/git_sync.py`` stays the only git-invocation site, PLAN + §13), no caching (the table is a handful of rows; a read's extra + query is trivial). + """ + rows = db.scalars(select(GitSource).where(GitSource.kind == "git")).all() + candidates = [row for row in rows if _url_basename(row.url) == source_name] + if len(candidates) == 1: + row = candidates[0] + return sanitize_url(row.url), row.branch + if not candidates: + logger.warning( + "no git origin for source %r: no kind='git' row's URL basename matches", + source_name, + ) + return None + logger.warning( + "ambiguous git origin for source %r: %d rows share the URL basename (%s) — " + "no origin line is added to its read results", + source_name, + len(candidates), + ", ".join(sanitize_url(row.url) for row in candidates), + ) + return None + + def clone_url_for(row: GitSource) -> str: """Phase 121, LOCKED A2 — the URL git actually clones, with the row's credential injected ONLY here. diff --git a/app/rag/prompts.py b/app/rag/prompts.py index ad37581..06ef067 100644 --- a/app/rag/prompts.py +++ b/app/rag/prompts.py @@ -231,6 +231,40 @@ SUGGEST_INTRO = ( #: summaries are the starting points, not the content; ``grep`` with #: only ``pattern`` searches the whole knowledge base, and a source #: name is not a document. +#: Phase 145 (task 02) — the original-source rule the ``read`` teaching +#: carries (LOCKED verbatim — pinned in :mod:`tests.unit.test_prompt_lock` +#: and :mod:`tests.unit.test_prompts`). :data:`TOOLS_SECTION` embeds this +#: exact sentence-group at the ``read`` clause's end (the truncation +#: workflow sentence) and BEFORE the ``grep`` clause begins; the +#: cross-pins (``TOOLS_SECTION`` minus this rule reproduces the +#: pre-phase-145 bytes; ``count == 1`` + the boundary substring) keep +#: the embedded copy and this constant byte-identical. A read result for +#: a git-sourced document carries an ``origin:`` line (and a ``branch:`` +#: line when a branch is pinned) — the read contract's fact — so when +#: the answer uses such a document it includes the original source: a +#: link to the repository (the ``origin`` line) with the document's path +#: (the ``Document …`` header) named and the pinned branch when one is +#: shown, as the repository's file-view URL where the model can tell it +#: (``/blob//``), or the repository URL plus the +#: path as plain text otherwise — the user opens the complete original +#: from the answer in one step, not a second question. The rule is +#: CONDITIONAL on the ``origin`` line's presence — a local-sourced +#: document's read result carries none, so the rule adds nothing to +#: those answers (the model has no URL to cite; it must not invent one — +#: the sentence's conditioning does that job). +ORIGIN_RULE: str = ( + "A read result for a document from a git source carries an origin line " + "(and a branch line when a branch is pinned): when your answer uses " + "such a document, include the original source in the answer — a link " + "to the repository from the origin line, with the document's path " + "(from the Document header) named and the pinned branch when one is " + "shown — as the repository's file-view URL where you can tell it " + "(for example /blob//), or the repository " + "URL plus the path as plain text otherwise. The user should be able " + "to open the complete original from your answer in one step, not ask " + "again for the source." +) + TOOLS_SECTION: str = ( "\n" "You may extend your context with three tools. `ls` lists the " @@ -260,7 +294,17 @@ TOOLS_SECTION: str = ( "are capped: a " "cut read returns the first part plus a TRUNCATED notice — the " "document did not end where it stopped; use `grep` (pattern) to " - "find the rest, it searches the whole document. `grep` locates an exact string " + "find the rest, it searches the whole document. A read result for a " + "document from a git source carries an origin line (and a branch line " + "when a branch is pinned): when your answer uses such a document, " + "include the original source in the answer — a link to the " + "repository from the origin line, with the document's path (from the " + "Document header) named and the pinned branch when one is shown — as " + "the repository's file-view URL where you can tell it (for example " + "/blob//), or the repository URL plus the " + "path as plain text otherwise. The user should be able to open the " + "complete original from your answer in one step, not ask again for " + "the source. `grep` locates an exact string " "(case-insensitive) in the indexed documents and returns up to 20 " "matching `source/path:line: text` lines — a locator, not a " "context-adder: read the winner with `read`. A grep pattern is a " diff --git a/app/schemas.py b/app/schemas.py index 6a43e37..aa33943 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -561,12 +561,30 @@ class DocContent(BaseModel): #: omission precedent); also absent for an image row whose #: ``image_path`` is NULL (the viewer's onerror fallback covers it). image_url: str | None = None + #: Phase 145 (task 03) — the document's git origin: the sanitized + #: repository URL (the task-01 resolver's output — the phase-121 + #: credential-free mask; the document's ``source`` is the directory + #: the clone landed in, so no unambiguous git row means no origin). + #: ABSENT from the wire for non-git documents (``None`` → dropped by + #: the serializer — never ``null``, the :func:`_drop_absent_share_url` + #: omission precedent), so a non-git document's payload is + #: byte-identical to pre-phase. + origin_url: str | None = None + #: Phase 145 (task 03) — the row's PINNED branch (phase 135). + #: ABSENT from the wire when the row pins none (NULL = the + #: repository's default branch — nothing to show); also dropped by + #: the serializer (never ``null``). + origin_branch: str | None = None @model_serializer(mode="wrap") def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any: data = handler(self) if self.image_url is None: data.pop("image_url", None) + if self.origin_url is None: + data.pop("origin_url", None) + if self.origin_branch is None: + data.pop("origin_branch", None) return data diff --git a/frontend/assets/document.js b/frontend/assets/document.js index 9ee5a1e..d9fb3e0 100644 --- a/frontend/assets/document.js +++ b/frontend/assets/document.js @@ -118,6 +118,22 @@ * the row exists but the copy was lost) swaps the block for a small * "Image unavailable" note (role=status): the page still shows the * description. ONE shared core — page + modal both get it. + * + * Phase 145 (task 03): the meta row's "Original" anchor — when the + * content payload carries origin_url (a git-sourced document — the + * task-01 resolver's sanitized repository URL; ABSENT on the wire + * for non-git documents, whose meta row stays byte-identical to + * pre-phase), an joins the .doc-meta row + * AFTER the path code and BEFORE the Created badge: href + visible + * text = the full URL VERBATIM (the copyable-link contract — no + * decorative label inside the anchor text; the .doc-origin ellipsis + * clips the display), target=_blank + rel=noopener (the modal's + * "Full page" escape-hatch idiom), and the title carries the full + * URL plus ` — branch: ` when the payload pins a branch (the + * pathCode ellipsis-precision idiom). ONE shared core — the modal + * AND /document.html render it (no per-surface copy); createElement + * + properties/textContent only (the never-build-HTML rule — no + * innerHTML). */ import { bindSharedHeaderControls, fetchIsAdmin, initSharedHeader } from "./header.js"; @@ -158,6 +174,32 @@ function metaBadge(cls, text, title) { return el; } +/* Phase 145 (task 03): the meta row's "Original" anchor — the git + * origin as a copyable external link (called only when the payload + * carries origin_url — a non-git document never gets one, so its + * meta row stays the pre-phase shape). The FULL URL is the anchor's + * visible text (the copyable-link contract — no decorative label + * inside the anchor text: a " (branch)" suffix would break + * copy-paste; the .doc-origin CSS ellipsis clips the display + * instead), target=_blank + rel=noopener (the modal's "Full page" + * escape-hatch idiom), and the title carries the full URL — plus + * ` — branch: ` when the payload pins a branch (the pathCode + * ellipsis-precision idiom). Properties + textContent only — never + * innerHTML (the XSS contract). */ +function originAnchor(doc) { + const a = document.createElement("a"); + a.className = "doc-origin"; + a.href = doc.origin_url; + a.target = "_blank"; + a.rel = "noopener"; + a.textContent = doc.origin_url; + a.setAttribute( + "title", + doc.origin_branch ? doc.origin_url + " — branch: " + doc.origin_branch : doc.origin_url, + ); + return a; +} + /* ---------- shared renderer (phase 26, task 02) ---------- * Populates the three elements every render surface provides: a title, * a .doc-meta badge row (source · format · mono path · created · @@ -178,10 +220,20 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) { const pathCode = document.createElement("code"); pathCode.className = "doc-path"; pathCode.textContent = doc.path; + // Phase 145 (task 03): the "Original" anchor — the git origin as a + // copyable external link, present ONLY when the content payload + // carries origin_url (a git-sourced document's sanitized repository + // URL — the task-01 resolver's output; ABSENT on the wire for + // non-git documents, so their meta row stays byte-identical to + // pre-phase). It lands AFTER the path code and BEFORE the Created + // badge — one more inline item in the row (the modal's wrap rule + // and the page's clip rule both keep it on their shapes). + const originItems = doc.origin_url ? [originAnchor(doc)] : []; metaEl.replaceChildren( metaBadge("doc-source-badge", doc.source), metaBadge("format-badge", doc.format), pathCode, + ...originItems, // Phase 106 (task 08, D8): the Created badge BEFORE the Indexed // one (the owner's verbatim position — the date at the top of a // clicked document). textContent = the locale date (fmtDate — the diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 00fa8e1..398bf77 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -4400,6 +4400,28 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und keeps a visible box on narrow screens while the trailing badges clip. */ min-width: 6rem; } +/* Phase 145 (task 03): the "Original" anchor — the git origin as a + copyable external link in the meta row (rendered by the ONE + renderDocument core — the modal AND /document.html; a non-git + document never gets one). The .doc-path code family (mono, 0.75rem, + the SAME 26rem ellipsis budget + 6rem floor — the row clips, the + title carries the full URL) with the .doc-link affordance (brand-ink + 8.7:1 on --surface — the recorded .doc-link pair; underline on + hover/focus, the .doc-link idiom). The anchor's visible text IS the + full URL (the copyable-link contract — the ellipsis clips the + display). */ +.doc-origin { + font-family: var(--mono); + font-size: 0.75rem; + max-width: 26rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 6rem; + color: var(--brand-ink); /* 8.7:1 on --surface (the .doc-link pair) */ + text-decoration: none; +} +.doc-origin:hover, .doc-origin:focus-visible { text-decoration: underline; } .doc-chunks { font-family: var(--mono); } /* Phase 106 (task 08, D8): the Created badge — the SAME family as the Indexed one (no own look: the meta row's text, var(--ink-soft) on @@ -5447,6 +5469,7 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und .doc-titlebar .container { gap: 0.5rem; } /* phase 34: row 2 squeezes like row 1 at mobile widths */ #doc-title { font-size: 1.1rem; } .doc-path { max-width: 16rem; } + .doc-origin { max-width: 16rem; } /* phase 145 (task 03): the Original anchor squeezes like the path */ .doc-md { padding: 1.1rem 1rem; } .doc-summary { padding: 0.75rem 0.9rem; } .doc-raw { padding: 1rem; font-size: 0.8rem; } diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index a68913f..92c6f9f 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -714,6 +714,55 @@ Implements just enough of the aipi surface: deflected turns never hit it); verified 2026-10-01: no existing E2E question or fixture file contains the phrase, so every other suite is unaffected. + - user message containing ``original source link`` + (``ORIGIN_LINK_TRIGGER``, phase 145 task 04 — the git-source- + links' dedicated story suite + ``tests/e2e/test_git_source_links.py``) **and** the system + prompt carries the ```` section -> the deterministic + SCRIPTED ORIGIN-LINK flow (the owner's scenario, TODO.md L7: + the answer grounded in a git-sourced document must carry the + way to the complete original in ONE step): the question + carries its own tool call after the colon — ``original source + link: read source/path`` — the question-carried-call + convention (``_READ_CAP_CALL_RE``), then discriminated + statelessly from the tool results (streaming only): + * request 1 (``tools`` offered, no tool results yet): a + ``tool_calls`` delta — ``read`` on the parsed target + (synthetic id ``call_0``); + * a ``tool``-role READ result is in the messages (content + starting with the agent's ``"Document "`` prefix — + ``_READ_RESULT_PREFIX``; the phase-145 ``origin:`` / + ``branch:`` lines sit BELOW the header, so the first-line + parsing is untouched): the deterministic FINAL answer — + the repository URL VERBATIM, parsed from the read + result's ``origin:`` line BY THE MOCK ITSELF (the mock + cannot fabricate it — the real server's resolver put the + line in the result, so the URL in the answer is the + server-side half's assertion): ``The full source lives at + — see the repo for the complete original.`` + (``ORIGIN_LINK_ANSWER``) followed by the house + scripted-turn echo — the read result VERBATIM under + ``Here's what the read returned:`` (the rendered output + is the E2E's lens on the ``origin:``/``branch:`` lines' + presence); + * a tool result is in the messages that carries NO + ``origin:`` line (a local-sourced document — the byte- + identity control): the deterministic NO-ORIGIN answer — + ``No origin line in the read result — the document is not + from a git source.`` (``ORIGIN_LINK_NO_ORIGIN``) + the + same verbatim echo (the suite asserts the line's ABSENCE + through it); + * a tool result is in the messages that is NOT a read + result (a refusal — the premise broke): the + deterministic broken-premise echo — the offending result + VERBATIM under ``Premise broke:`` (the two-ranges + convention — the suite fails loudly on it). + Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint + trigger phrases — the phase-71/72/94/118/131/139/141 ordering + convention; the trigger needs the ```` section, so + deflected turns never hit it); verified 2026-10-01: no + existing E2E question or fixture file contains the phrase, so + every other suite is unaffected. Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic dead-endpoint behavior for the retry E2E suite (``tests/e2e/ @@ -2667,6 +2716,148 @@ def _two_ranges_flow(body: dict[str, Any]) -> tuple[str, ...] | None: return ("read1", target) +# --------------------------------------------------------------------------- +# Phase 145 (task 04): the git-source-links' dedicated story suite — the +# owner's "the answer points at the original git source" scenario. See +# the module docstring. +# --------------------------------------------------------------------------- + +#: A user message containing this substring (case-insensitive) — +#: combined with the ```` section in the system prompt — +#: drives the deterministic SCRIPTED ORIGIN-LINK flow (the +#: git-source-links' story suite, +#: ``tests/e2e/test_git_source_links.py`` — the owner's scenario, +#: TODO.md L7: the answer grounded in a git-sourced document must +#: carry the way to the complete original in ONE step). Checked +#: BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger +#: phrases — the phase-71/72/94/118/131/139/141 ordering +#: convention; the trigger needs the ```` section, so +#: deflected turns never hit it); verified 2026-10-01: no +#: existing E2E question or fixture file contains the phrase, so +#: every other suite is unaffected. +ORIGIN_LINK_TRIGGER = "original source link" + +#: The scripted call in the origin-link question (case-insensitive +#: — the suite's questions capitalize the trigger's first +#: letter): the verb (``read``) plus the target — a combined +#: ``source/path``, parsed from the RAW user message so the target +#: keeps its case. The target is a ``[a-z0-9_./-]`` run (case- +#: insensitively), so the suite's `` — `` flavor separator (em +#: dash) can never bleed into it (the read-cap / drill-down +#: convention, ``_READ_CAP_CALL_RE`` / ``_DRILL_CALL_RE``). +_ORIGIN_LINK_CALL_RE = re.compile( + r"original source link:\s*read\s+(?P[a-z0-9_./-]+)", + re.I, +) + +#: The flow's FINAL answer for a read result that carries the +#: phase-145 ``origin:`` line — the DETERMINISTIC string carrying +#: the repository URL VERBATIM (the model-side contract, owner- +#: confirmed 2026-09-30: the answer includes the original source). +#: ``{url}`` is parsed from the read result's ``origin:`` line BY +#: THE MOCK ITSELF — the mock cannot fabricate it, so the URL in +#: the answer is the server-side half's assertion (the real +#: server's resolver put the line in the result the model saw). +ORIGIN_LINK_ANSWER = ( + "The full source lives at {url} — see the repo for the complete original." +) + +#: The flow's FINAL answer for a read result that carries NO +#: ``origin:`` line (a local-sourced document — the byte-identity +#: control): the deterministic no-origin sentence (its wording +#: carries NO ``origin:`` substring — the suite's absence +#: assertion keys off the line's exact prefix). +ORIGIN_LINK_NO_ORIGIN = ( + "No origin line in the read result — the document is not from a git source." +) + +#: The house scripted-turn echo header (the ``READ_CAP_TRIGGER`` +#: flow's convention): the read result rides VERBATIM into the +#: answer, so the rendered output is the E2E's lens on the +#: ``origin:``/``branch:`` lines' presence (and their absence in +#: the local-source control). +_ORIGIN_LINK_ECHO_HEADER = "Here's what the read returned:" + + +def _origin_link_url(result: str) -> str | None: + """The read result's ``origin:`` line's URL (the phase-145 + task-01 line — the sanitized repository URL the real server's + resolver put in the result) — or ``None`` when the result + carries no origin line (a local-sourced document, no git row, + or an ambiguous basename). + + The search is position-independent (any line with the exact + ``origin: `` prefix): the phase-145 lines sit between the + ``date:`` line and the content, but the parse never drifts if + the result's header shape changes. A document BODY line that + merely starts with ``origin: `` would be a fixture defect (the + suite's fixtures are pinned content — no such line), never a + mock defect. + """ + for line in result.splitlines(): + if line.startswith("origin: "): + return line[len("origin: "):] + return None + + +def _origin_link_flow(body: dict[str, Any]) -> tuple[str, ...] | None: + """Classify a phase-145 scripted origin-link request (see the + module docstring). The question carries the scripted call + (``original source link: read source/path``); the step is then + discriminated statelessly from the tool results, like the + other marker flows: + + * ``("call", target, "call_0")`` — ``tools`` are offered and + no ``tool``-role result is in the messages yet: the scripted + ``read`` on the parsed target (synthetic id ``call_0``). + * ``("answer", url, result)`` — a ``tool``-role READ result + (content starting with the agent's ``"Document "`` prefix — + ``_READ_RESULT_PREFIX``; the phase-145 ``origin:`` / + ``branch:`` lines sit BELOW the header, so the first-line + parsing is untouched) is in the messages: the deterministic + FINAL answer — ``url`` the result's ``origin:`` line's URL + (or ``""`` — the no-origin sentence, the local-source + control; the empty-string sentinel keeps the return + homogeneous ``tuple[str, …]``, the ``_read_lines_flow`` + convention) and ``result`` the read result VERBATIM (the + house scripted-turn echo — the rendered output is the lens + on the origin lines). + * ``("bad", result)`` — a tool result is in the messages that + is NOT a read result (a refusal — the premise broke): the + deterministic broken-premise echo — the offending result + VERBATIM under ``Premise broke:`` (the two-ranges convention + — the suite fails loudly on it). + * ``None`` — not the flow: the trigger is absent, the + ```` section is missing (deflected turns never carry + it), the scripted call is unparseable, or ``tools`` are not + offered and no tool results are in the messages yet (e.g. + ``agent_max_rounds=0``). + """ + user = _user(body) + if ORIGIN_LINK_TRIGGER not in user.lower(): + return None + if "" not in _system(body): + return None + match = _ORIGIN_LINK_CALL_RE.search(user) + if match is None: + return None + target = match.group("arg") + results = _tool_results(body) + if results: + last = results[-1] + if not last.startswith(_READ_RESULT_PREFIX): + return ("bad", last) + # The empty-string sentinel (never a real URL — a URL is + # always non-empty: the resolver returns the stored URL's + # sanitized form, which is a non-empty string): keeps the + # return homogeneous ``tuple[str, …]`` (pyright), the + # ``_read_lines_flow`` convention. + return ("answer", _origin_link_url(last) or "", last) + if not body.get("tools"): + return None + return ("call", target, "call_0") + + def long_answer() -> str: """~900-word deterministic walkthrough (phase 11): numbered steps plus a unique final line that must survive the stream untruncated.""" @@ -4044,6 +4235,57 @@ def chat_completions(body: dict[str, Any]) -> Any: media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) + # Phase 145 (task 04): the owner's "the answer points at the + # original git source" scenario (the git-source-links' story + # suite, tests/e2e/test_git_source_links.py — TODO.md L7): + # the scripted read, then the final answer that carries the + # repository URL parsed from the read result's ``origin:`` + # line (the server-side half — the mock cannot fabricate it) + # + the result VERBATIM (the lens on the origin lines' + # presence/absence). Checked BEFORE the plain TOOLS_TRIGGER + # flow (disjoint trigger phrases — the phase-71/72/94/118/ + # 131/139/141 ordering convention; the trigger needs the + # ```` section, so deflected turns never hit it). + origin_link = _origin_link_flow(body) + if origin_link is not None: + if origin_link[0] == "call": + stream = _tool_call_stream( + "read", {"path": origin_link[1]}, origin_link[2] + ) + elif origin_link[0] == "bad": + # The premise broke (a refusal — the scripted read + # was refused): echo the offending result verbatim + # so a suite fails loudly on it (the two-ranges + # convention). + stream = _sse_stream( + _apply_max_tokens( + f"Premise broke:\n{origin_link[1]}", + body.get("max_tokens"), + ), + 0.0, + ) + else: # "answer" — the final answer (tests 1–3's model- + # side contract) + the verbatim echo (the lens on + # the origin lines' presence/absence). ``url`` is + # the ``origin:`` line's URL or the "" sentinel + # (the no-origin sentence — the local-source + # control). + url = origin_link[1] + sentence = ( + ORIGIN_LINK_ANSWER.format(url=url) if url else ORIGIN_LINK_NO_ORIGIN + ) + stream = _sse_stream( + _apply_max_tokens( + f"{sentence}\n{_ORIGIN_LINK_ECHO_HEADER}\n{origin_link[2]}", + body.get("max_tokens"), + ), + 0.0, + ) + return StreamingResponse( + stream, + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) # Phase 124 (task 07, image chips and answers, TODO L3): the # scripted SHOW_IMAGE flow — the mock shows the seeded image # document (the ```` block with the image- diff --git a/tests/e2e/test_git_source_links.py b/tests/e2e/test_git_source_links.py new file mode 100644 index 0000000..09b32a3 --- /dev/null +++ b/tests/e2e/test_git_source_links.py @@ -0,0 +1,585 @@ +"""Phase 145 task 04 E2E (Playwright, mock-only): the git-source-links' +dedicated story suite — the owner's scenario, end to end. + +The browser-visible proof (TODO.md L7: "there always needs to be a +link to the original git source if possible … point to the skills +repo in the answer so I can go download the skills"): a question +about a git-sourced document produces (1) an answer that contains +the repository link (the model-side teaching, through the real read +result that carries the ``origin:`` line) and (2) a document modal +whose meta row carries the "Original" external anchor. A +local-sourced document produces neither (the byte-identity guard). + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_git_source_links.py -v --no-cov + +MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the real +``turbo`` does whatever it does with the tools, while this story's +gate is the deterministic SCRIPTED flow in ``tests/e2e/mock_llm.py`` +(the mock is the only E2E lens on the LLM's context): + +* ``ORIGIN_LINK_TRIGGER`` — a user message containing ``original + source link`` **and** the system prompt carrying the HIGH + prompt's ```` section. The question carries its own + scripted call after the colon — ``original source link: read + source/path`` (the question-carried-call convention, + ``_READ_CAP_CALL_RE``). Request 1 is the scripted ``read``; + request 2 (the read result present) is the deterministic FINAL + answer — the repository URL VERBATIM, parsed from the read + result's ``origin:`` line BY THE MOCK (it cannot fabricate it — + the real server's resolver put the line in the result, so the + URL in the answer is the server-side half's assertion) — + followed by the house scripted-turn echo (the read result + VERBATIM — the rendered output is the lens on the + ``origin:``/``branch:`` lines' presence; the local-source + control's read result carries none, and the mock answers with + its deterministic no-origin sentence instead). + +KB fixture — TWO local directories (the house per-module import — +the ``test_document_viewer.py`` importer-in-a-thread pattern), each +holding ONE readable markdown document: + +* ``skills/phased-execution.md`` — the owner's scenario document + (the phased execution process for pi.dev). The source's name is + ``skills`` — it MATCHES the seeded git row's URL basename + (``https://example.com/reese/skills`` → ``skills``: the path + component, trailing ``/`` stripped, no ``.git`` suffix to strip, + last segment — the resolver's name mapping). The fixture document + is locally imported, but the mapping is name-based — which is the + point of the seam; +* ``control/notes.md`` — a SECOND plain local source with NO git + row: the byte-identity control (its read result and its modal + meta row must be exactly the pre-phase shapes). + +The git row is seeded in the suite's OWN DB session (a direct +``SessionLocal`` write, per test — ``branch=None`` for tests 1–2, +``branch="main"`` for the pinned-branch test, and ABSENT for the +control test) — never through the API, and never during the import +(a ``kind='git'`` row present at import time would be cloned — +``example.com`` is not a git host; the resolver reads the table +LIVE at read/content time, so seeding after the import is the +whole trick). + +The app under test is the conftest session app (mock LLM + the +mock-calibrated gate: threshold 0.30 + lexical floor 0.15): both +questions share the subject words with their own fixture document +(the mock's token-overlap distribution), so every scripted turn +stays GROUNDED (the ```` section is present) and the read +document is the turn's cited chip (phase 119 A1 — the done frame's +``sources`` is the READ docs). + +Test → observable mapping (Playwright Mapping Rule; the story is +the owner's TODO item — one Playwright file per story, A16): +1. ``test_answer_carries_origin_link`` — the ``original source + link`` question on the git-matched source's document → the + rendered tool output for the ``read`` (the mock's verbatim echo + in the answer — the house scripted-turn lens) carries + ``origin: https://example.com/reese/skills`` (the server put + the line in the result the model saw) and the final ``done`` + answer contains the repository URL (the scripted echo — the + model-side contract, deterministic). +2. ``test_modal_shows_original_anchor`` — same setup → click the + source chip → ``#doc-modal`` rendered → the meta row contains + the ``.doc-origin`` anchor with ``href`` = the URL, + ``target="_blank"``, ``rel="noopener"``, ``textContent`` = the + URL verbatim (and NO branch in the text — ``branch=None``: the + ``title`` is the bare URL). +3. ``test_pinned_branch_in_title`` — the ``branch="main"`` row → + the read result's surface also carries ``branch: main``, the + done answer still carries the repository URL, and the same + anchor's ``title`` is + ``https://example.com/reese/skills — branch: main`` (the text + stays the bare URL — the copyable-link contract). +4. ``test_local_source_has_no_origin`` — the control source's + document (no git rows at all — the pre-phase world) → the + read's rendered tool output carries NO ``origin:`` line (the + byte-identical result shape — the mock's no-origin sentence + answers) and the modal's meta row has NO ``.doc-origin`` + anchor (the pre-phase badge row). +""" +from __future__ import annotations + +import asyncio +import json +import time +from collections.abc import Iterator +from pathlib import Path +from threading import Thread +from typing import Any + +import pytest +from playwright.sync_api import Page, expect +from sqlalchemy import delete, func, select, text + +from app.config import Settings +from app.db import SessionLocal +from app.models import Document, GitSource +from app.rag.importer import import_sources +from app.rag.llm import LLMClient +from e2e.auth_helpers import login +from e2e.mock_llm import ORIGIN_LINK_ANSWER, ORIGIN_LINK_NO_ORIGIN + +REPO = Path(__file__).resolve().parents[2] + +SOURCE = "skills" # the local directory's basename = the source name +SKILLS_REL = "phased-execution.md" +SKILLS_SP = f"{SOURCE}/{SKILLS_REL}" # the combined read identity +CONTROL = "control" # the plain local control source (NO git row) +CONTROL_REL = "notes.md" +CONTROL_SP = f"{CONTROL}/{CONTROL_REL}" + +#: The seeded git row's public URL (no credential — the public form; +#: the phase-121 sanitizer is a byte-identical no-op for it). Its +#: URL basename — ``skills`` — MATCHES the fixture source's name +#: (the resolver's name mapping — the seam). +ORIGIN_URL = "https://example.com/reese/skills" +PINNED_BRANCH = "main" + +SKILLS_DOC = """# Phased execution for pi.dev + +How does the phased execution process work for pi.dev? It runs one +task at a time: each task file executes in a separate pi subprocess, +and the harness manages phase and task files, retries, and final +validation. + +## Task files + +Every task file is a unit of work. The pipeline runs the todo phases +in numeric order, and a completed phase moves to the complete +directory with its own test suite green. + +## Validation + +A phase is complete only when the unit and integration tests pass, +the coverage gate is green, and the Playwright story suite passes +in isolation. The harness then moves the task file and commits the +phase atomically. +""" + +CONTROL_DOC = """# Control notes + +This is a plain local directory source. It is walked directly by +the importer and never cloned from a repository. The control note +exists so the byte-identity guard has a document: local documents +read, render, and cite exactly as before, with nothing new in the +read result or the meta row. +""" + +# The suite's own content pins — the mock's echo assertion keys off +# the read result's ``origin:``/``branch:`` lines, so neither +# fixture may contain a line with either prefix (a fixture defect, +# not a mock defect — see the mock's ``_origin_link_url``). +assert "origin:" not in SKILLS_DOC and "branch:" not in SKILLS_DOC +assert "origin:" not in CONTROL_DOC and "branch:" not in CONTROL_DOC + +# The scripted questions — the mock's ``ORIGIN_LINK_TRIGGER`` + the +# question-carried call (``_ORIGIN_LINK_CALL_RE``) + the subject +# words (the mock-calibrated gate: both questions stay GROUNDED on +# their own fixture document). +Q_SKILLS = ( + f"Original source link: read {SKILLS_SP} — " + "how does the phased execution process work for pi.dev?" +) +Q_CONTROL = ( + f"Original source link: read {CONTROL_SP} — " + "what does the control note say about plain local sources?" +) + +#: The final answer the mock scripts for the git-matched document +#: (the ``ORIGIN_LINK_ANSWER`` constant with the seeded URL — the +#: model-side contract, imported so the mock can never drift). +ANSWER_SENTENCE = ORIGIN_LINK_ANSWER.format(url=ORIGIN_URL) + + +# -------------------------------------------------------------------------- +# Fixtures +# -------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def kb_dirs(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]: + """The story's two local-directory sources (host temp dirs — the + app server runs on the same host, so the paths are visible to + it): the ``skills`` source (its basename MATCHES the seeded git + row's URL basename — the resolver's name mapping) and the + ``control`` source (no git row — the byte-identity control).""" + skills = tmp_path_factory.mktemp("bor_origin_skills") / SOURCE + skills.mkdir() + (skills / SKILLS_REL).write_text(SKILLS_DOC, encoding="utf-8") + control = tmp_path_factory.mktemp("bor_origin_control") / CONTROL + control.mkdir() + (control / CONTROL_REL).write_text(CONTROL_DOC, encoding="utf-8") + return skills, control + + +def _run_in_thread(coro: Any) -> Any: + """Run a coroutine on a worker thread (the + ``test_document_viewer`` pattern — Playwright's sync API keeps + an asyncio loop on the test thread, so ``asyncio.run`` cannot + run directly in a test body).""" + box: dict[str, Any] = {} + + def runner() -> None: + try: + box["value"] = asyncio.run(coro) + except BaseException as e: # noqa: BLE001 — re-raised in the test + box["error"] = e + + t = Thread(target=runner) + t.start() + t.join() + if "error" in box: + raise box["error"] + return box["value"] + + +async def _import_sources(roots: list[Path], mock_port: int) -> None: + """The house per-module import (the ``test_document_viewer`` + pattern): the REAL importer against the fixture roots under the + deterministic mock embeddings — the mock LLM supplies the + embeds, and the owner's local ``.env`` is NOT read (the + ``_env_file=None`` isolation).""" + kwargs: dict[str, Any] = { + "_env_file": None, + "llm_base_url": f"http://127.0.0.1:{mock_port}/v1", + } + settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + await import_sources(roots, LLMClient(settings)) + + +def _truncate_all() -> None: + """Fresh registry + KB (the E2E isolation pattern): the E2E + suites share one Postgres, so a leftover git_sources row or + document would pollute the resolver and the retrieval the + scripted turns run against (the two-source KB's gate margins + are pinned against EXACTLY these two documents).""" + with SessionLocal() as db: + db.execute( + text( + "TRUNCATE chunks, documents, query_log, steering_notes, " + "kb_overview, git_sources, folder_summaries" + ) + ) + db.commit() + + +@pytest.fixture(scope="module") +def seeded_kb(mock_llm: int, kb_dirs: tuple[Path, Path]) -> None: + """The story's precondition: the two-source KB (one readable + markdown doc each) imported under the deterministic mock, the + stored content byte-pinned. The git row is NOT part of the + import (a git row present at import time would be cloned — the + row is seeded in the suite's own DB session per test, AFTER + the import; the resolver reads the table live at + read/content time).""" + _truncate_all() + _run_in_thread(_import_sources(list(kb_dirs), mock_llm)) + with SessionLocal() as db: + docs = { + (d.source, d.path): d for d in db.scalars(select(Document)).all() + } + assert set(docs) == {(SOURCE, SKILLS_REL), (CONTROL, CONTROL_REL)}, ( + docs.keys() + ) + assert docs[(SOURCE, SKILLS_REL)].content == SKILLS_DOC + assert docs[(CONTROL, CONTROL_REL)].content == CONTROL_DOC + # The import ran with NO git rows (the local-only KB): the + # seeded row lands after, per test. + assert db.scalar(select(func.count()).select_from(GitSource)) == 0 + + +def _seed_git_row(branch: str | None, exist: bool = True) -> None: + """The git row in the suite's OWN DB session (the task's seam): + delete every row, then — when *exist* — insert the one + ``kind='git'`` row whose URL basename matches the ``skills`` + fixture source (public URL, no credential; *branch* pinned or + NULL). The resolver reads the table LIVE — the row never + touches the importer.""" + with SessionLocal() as db: + db.execute(delete(GitSource)) + if exist: + db.add(GitSource(url=ORIGIN_URL, kind="git", branch=branch)) + db.commit() + + +@pytest.fixture(autouse=True) +def _clean(db_ready: None) -> Iterator[None]: + """Per-test query_log isolation (the KB itself is module-scoped + — the scripted turns never change it, so the documents persist + across the tests of this module).""" + with SessionLocal() as db: + db.execute(text("TRUNCATE query_log")) + db.commit() + yield + with SessionLocal() as db: + db.execute(text("TRUNCATE query_log")) + db.commit() + + +# -------------------------------------------------------------------------- +# Page helpers (the test_read_line_ranges / test_document_viewer +# house patterns) +# -------------------------------------------------------------------------- + +#: Captures the raw SSE ``data:`` payloads of the /api/chat stream +#: (a response clone read in the background) — wire-level +#: assertions for the ``tool`` / ``done`` frames, independent of +#: the UI rendering. +SSE_HOOK = """ +() => { + if (window.__sseInstalled) return; + window.__sseInstalled = true; + window.__sseFrames = []; + const origFetch = window.fetch; + window.fetch = async function (...args) { + const res = await origFetch.apply(this, args); + try { + const url = typeof args[0] === 'string' ? args[0] : args[0].url; + if (url.includes('/api/chat')) { + res.clone().text().then((bodyText) => { + for (const block of bodyText.split('\\n\\n')) { + const line = block.trim(); + if (line.startsWith('data: ')) { + window.__sseFrames.push(line.slice(6)); + } + } + }); + } + } catch (e) { /* non-clonable responses: ignored */ } + return res; + }; +} +""" + + +def _install_page_hooks(page: Page) -> None: + page.evaluate(SSE_HOOK) + + +def _frames(page: Page, deadline_s: float = 120.0) -> list[dict]: + """The SSE frames captured since the last submit (``_submit`` + clears the buffer), once the hook's background read settles.""" + deadline = time.monotonic() + deadline_s + while True: + raw = page.evaluate("() => window.__sseFrames || []") + parsed = [json.loads(line) for line in raw if line] + if any(f.get("type") == "done" for f in parsed): + return parsed + if time.monotonic() > deadline: + raise AssertionError( + f"SSE hook captured no `done` frame (frames so far: " + f"{len(parsed)}) — hook install failed?" + ) + time.sleep(0.05) + + +def _tool_frames(frames: list[dict]) -> list[dict]: + return [f for f in frames if f.get("type") == "tool"] + + +def _submit(page: Page, question: str) -> None: + page.evaluate("window.__sseFrames = []") + page.fill("#message-input", question) + page.click("#send-btn") + # The user bubble lands synchronously with the submit handler. + expect(page.locator(".msg.user .bubble").last).to_contain_text(question) + + +def _wait_settled(page: Page, timeout_ms: int = 120_000) -> None: + """The turn is complete: answer text in the bubble, button + recovered (the phase-48 settle wait).""" + expect(page.locator(".msg.brain .bubble").last).not_to_have_text( + "", timeout=timeout_ms + ) + expect(page.locator("#send-btn")).to_be_enabled(timeout=timeout_ms) + expect(page.locator("#send-label")).to_have_text("Send", timeout=timeout_ms) + + +def _bubble_text(page: Page) -> str: + """The turn's rendered answer (the brain bubble's text content + — the mock's scripted echo lands here: the house scripted-turn + lens on the read result's ``origin:``/``branch:`` lines).""" + return page.locator(".msg.brain .bubble").last.text_content() or "" + + +def _open_modal_for_chip(page: Page, chip_text: str) -> None: + """Drive the story's modal surface: the turn's cited chip + (phase 119 A1 — done.sources is the READ docs, so the read + document is the turn's ONLY chip) opens the same-page document + modal (the ``test_document_viewer`` contract).""" + chip = page.locator(".msg.brain .source-chip", has_text=chip_text) + expect(chip).to_have_count(1, timeout=30_000) + chip.first.click() + expect(page.locator("#doc-modal")).not_to_have_attribute("hidden") + expect(page.locator("#doc-modal")).to_be_visible() + + +# -------------------------------------------------------------------------- +# 1. The answer carries the repository link (the model-side +# contract, through the real read result's ``origin:`` line) +# -------------------------------------------------------------------------- + + +def test_answer_carries_origin_link( + page: Page, app_url: str, seeded_kb: None, db_ready: None +) -> None: + _seed_git_row(branch=None) + page.set_default_timeout(60_000) + login(page, app_url, next="/") + _install_page_hooks(page) + + _submit(page, Q_SKILLS) + _wait_settled(page) + + frames = _frames(page) + # The scripted read on the wire — the turn's only tool call. + assert _tool_frames(frames) == [ + {"type": "tool", "name": "read", "argument": SKILLS_SP} + ], _tool_frames(frames) + done = next(f for f in frames if f.get("type") == "done") + assert done["deflected"] is False, done + # The read document is the turn's cited source (the chip the + # modal tests click — phase 119 A1). + assert any( + s["source"] == SOURCE and s["path"] == SKILLS_REL for s in done["sources"] + ), done["sources"] + + # The read result's surface (the mock's verbatim echo in the + # answer — the house scripted-turn lens): the sanitized + # ``origin:`` line the SERVER put in the result the model saw + # (the task-01 resolver's output — the git row's URL, + # credential-free). + text = _bubble_text(page) + assert f"origin: {ORIGIN_URL}" in text, text + # The done answer carries the repository URL (the scripted + # echo — the model-side contract, deterministic): the mock + # parsed the URL from the origin line — it cannot appear in + # the answer unless the server sent it. + assert ANSWER_SENTENCE in text, text + + +# -------------------------------------------------------------------------- +# 2. The modal's meta row shows the "Original" external anchor +# -------------------------------------------------------------------------- + + +def test_modal_shows_original_anchor( + page: Page, app_url: str, seeded_kb: None, db_ready: None +) -> None: + _seed_git_row(branch=None) + page.set_default_timeout(60_000) + login(page, app_url, next="/") + _install_page_hooks(page) + + _submit(page, Q_SKILLS) + _wait_settled(page) + _open_modal_for_chip(page, SKILLS_REL) + expect(page.locator("#doc-modal-title")).to_have_text( + "Phased execution for pi.dev" + ) + + # The meta row's "Original" anchor — the task-03 contract + # (ONE shared renderDocument site: the modal AND + # /document.html show it). + anchor = page.locator("#doc-modal-meta .doc-origin") + expect(anchor).to_have_count(1) + expect(anchor).to_have_attribute("href", ORIGIN_URL) + expect(anchor).to_have_attribute("target", "_blank") + expect(anchor).to_have_attribute("rel", "noopener") + # The copyable-link contract: the full URL VERBATIM as the + # visible text — NO branch in the text (branch=None), and the + # title is the bare URL (the pathCode precision idiom, no + # branch suffix). + expect(anchor).to_have_text(ORIGIN_URL) + expect(anchor).to_have_attribute("title", ORIGIN_URL) + + +# -------------------------------------------------------------------------- +# 3. The pinned branch rides the read result and the anchor's title +# -------------------------------------------------------------------------- + + +def test_pinned_branch_in_title( + page: Page, app_url: str, seeded_kb: None, db_ready: None +) -> None: + _seed_git_row(branch=PINNED_BRANCH) + page.set_default_timeout(60_000) + login(page, app_url, next="/") + _install_page_hooks(page) + + _submit(page, Q_SKILLS) + _wait_settled(page) + + # The read result's surface (the mock's verbatim echo): the + # origin line AND the pinned branch line (the task-01 second + # line, only when the row pins a branch). + text = _bubble_text(page) + assert f"origin: {ORIGIN_URL}" in text, text + assert f"branch: {PINNED_BRANCH}" in text, text + # The done answer still carries the repository URL (tests + # 1–3's model-side contract — the branch never joins the + # URL). + assert ANSWER_SENTENCE in text, text + + _open_modal_for_chip(page, SKILLS_REL) + anchor = page.locator("#doc-modal-meta .doc-origin") + expect(anchor).to_have_count(1) + expect(anchor).to_have_attribute("href", ORIGIN_URL) + # The text stays the bare URL (copyable — the branch never + # joins the anchor text)… + expect(anchor).to_have_text(ORIGIN_URL) + # …the branch rides the title (the task-03 label contract). + expect(anchor).to_have_attribute( + "title", f"{ORIGIN_URL} — branch: {PINNED_BRANCH}" + ) + + +# -------------------------------------------------------------------------- +# 4. The local-source control is byte-identical (no ``origin:`` +# line in the read result, no anchor in the meta row) +# -------------------------------------------------------------------------- + + +def test_local_source_has_no_origin( + page: Page, app_url: str, seeded_kb: None, db_ready: None +) -> None: + _seed_git_row(branch=None, exist=False) # the pre-phase world: NO git rows at all + page.set_default_timeout(60_000) + login(page, app_url, next="/") + _install_page_hooks(page) + + _submit(page, Q_CONTROL) + _wait_settled(page) + + frames = _frames(page) + assert _tool_frames(frames) == [ + {"type": "tool", "name": "read", "argument": CONTROL_SP} + ], _tool_frames(frames) + done = next(f for f in frames if f.get("type") == "done") + assert done["deflected"] is False, done + + # The byte-identity guard on the read result's surface (the + # mock's verbatim echo): the result carried NO origin line — + # the local source's result is byte-identical to pre-phase, + # so the mock's deterministic no-origin sentence answers. + text = _bubble_text(page) + assert ORIGIN_LINK_NO_ORIGIN in text, text + assert "origin:" not in text, text + assert "branch:" not in text, text + + # …and the modal's meta row is the pre-phase badge row: NO + # .doc-origin anchor (the content payload's origin fields are + # absent on the wire — never null). + _open_modal_for_chip(page, CONTROL_REL) + expect(page.locator("#doc-modal-title")).to_have_text("Control notes") + expect(page.locator("#doc-modal-meta .doc-origin")).to_have_count(0) + # The pre-phase badges are untouched (the row still shows + # source · format · path · created · indexed · chunks). + expect(page.locator("#doc-modal-meta .doc-source-badge")).to_have_text( + CONTROL + ) + expect(page.locator("#doc-modal-meta .doc-path")).to_have_text( + CONTROL_REL + ) + expect(page.locator("#doc-modal-meta .doc-created")).not_to_be_empty() diff --git a/tests/integration/test_document_content.py b/tests/integration/test_document_content.py index 14a2a85..07685b0 100644 --- a/tests/integration/test_document_content.py +++ b/tests/integration/test_document_content.py @@ -10,7 +10,13 @@ Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient: tests and the anonymous 403/401 pins build their own client; * GET: 404 for an unknown (source, path) pair; * GET: 404 for traversal-style ``path`` values (no filesystem access → no - leak). + leak); +* GET: phase 145 (task 03) — the payload's git origin fields: a seeded + ``kind='git'`` row (URL basename = the fixture source) adds + ``origin_url`` (+ ``origin_branch`` for a pinned row); a legacy + embedded-credential row is served sanitized (end-to-end); a plain + local source keeps the pre-phase key set (both keys ABSENT — never + ``null``). PATCH /api/documents/summary (phase 57, task 01) — the admin summary editor, on the same DB-backed fixtures: @@ -27,6 +33,7 @@ editor, on the same DB-backed fixtures: """ from __future__ import annotations +from collections.abc import Iterator from datetime import UTC, datetime import pytest @@ -37,7 +44,7 @@ from sqlalchemy.orm import Session import app.api.docs as docs_api from app.config import get_settings from app.main import app as fastapi_app -from app.models import Chunk, Document +from app.models import Chunk, Document, GitSource from app.rag.llm import EmbeddingError from tests.conftest import ADMIN_PASSWORD from tests.fakes import FakeEmbedder @@ -571,6 +578,131 @@ def test_content_format_from_suffix(client, db) -> None: db.commit() +# --------------------------------------------------------------------------- +# Phase 145 (task 03) — the content payload's git origin fields +# (the task-01 resolver's output, end-to-end over the real registry) +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def origin_registry(db) -> Iterator[None]: + """A FRESH ``git_sources`` table around the origin tests: the + resolver reads EVERY ``kind='git'`` row, so a controlled registry + is required (truncated before and after — the + ``test_agent_tools.py`` registry pattern).""" + db.execute(text("TRUNCATE git_sources")) + db.commit() + yield + db.execute(text("TRUNCATE git_sources")) + db.commit() + + +def test_content_carries_origin_url_for_a_seeded_git_row( + client, db, origin_registry +) -> None: + """A ``kind='git'`` row whose URL basename matches the document's + source → the payload carries ``origin_url`` (the sanitized clone + URL). The row pins no branch (NULL = the default branch) → + ``origin_branch`` is ABSENT from the JSON (not ``null`` — the + omission pinned on the wire); every pre-phase key stands.""" + _seed_doc(db) + db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git")) + db.commit() + try: + r = client.get( + "/api/documents/content", + params={"source": "Homelab", "path": "kubernetes.md"}, + ) + assert r.status_code == 200 + body = r.json() + assert set(body) == { + "source", "path", "title", "format", "summary", "created_at", + "content", "indexed_at", "chunks", "is_image", "origin_url", + } + assert body["origin_url"] == "https://github.com/reese/Homelab.git" + assert "origin_branch" not in body # absent — never null + finally: + db.execute(text("TRUNCATE chunks, documents")) + db.commit() + + +def test_content_carries_origin_branch_for_a_pinned_row( + client, db, origin_registry +) -> None: + """A pinned row (phase 135 ``branch``) also carries + ``origin_branch`` — the value the viewer's title attribute shows.""" + _seed_doc(db) + db.add( + GitSource( + url="https://github.com/reese/Homelab.git", kind="git", branch="docs" + ) + ) + db.commit() + try: + r = client.get( + "/api/documents/content", + params={"source": "Homelab", "path": "kubernetes.md"}, + ) + body = r.json() + assert body["origin_url"] == "https://github.com/reese/Homelab.git" + assert body["origin_branch"] == "docs" + finally: + db.execute(text("TRUNCATE chunks, documents")) + db.commit() + + +def test_content_origin_is_sanitized_end_to_end(client, db, origin_registry) -> None: + """A legacy row whose credential is still embedded in the stored + URL never leaks it: the payload carries the phase-121 sanitized + URL (the resolver's guarantee, end-to-end — the stored row value + is untouched, the output mask does the work).""" + _seed_doc(db) + db.add( + GitSource(url="https://user:tok@example.com/reese/Homelab.git", kind="git") + ) + db.commit() + try: + r = client.get( + "/api/documents/content", + params={"source": "Homelab", "path": "kubernetes.md"}, + ) + body = r.json() + assert body["origin_url"] == "https://example.com/reese/Homelab.git" + assert "user:tok" not in r.text # the credential stays out of the wire + finally: + db.execute(text("TRUNCATE chunks, documents")) + db.commit() + + +def test_content_origin_absent_for_a_plain_local_source( + client, db, origin_registry +) -> None: + """No unambiguous git row (a local row is never an origin, and a + non-matching git row does not count) → BOTH origin keys ABSENT + from the JSON (not ``null`` — the omission pinned on the wire; the + payload is byte-identical to pre-phase).""" + _seed_doc(db) + db.add(GitSource(url="/srv/Homelab", kind="local", path="/srv/Homelab")) + db.add(GitSource(url="https://github.com/reese/OtherRepo.git", kind="git")) + db.commit() + try: + r = client.get( + "/api/documents/content", + params={"source": "Homelab", "path": "kubernetes.md"}, + ) + assert r.status_code == 200 + body = r.json() + assert set(body) == { + "source", "path", "title", "format", "summary", "created_at", + "content", "indexed_at", "chunks", "is_image", + } + assert "origin_url" not in body + assert "origin_branch" not in body + finally: + db.execute(text("TRUNCATE chunks, documents")) + db.commit() + + def test_content_format_name_token_extensionless( client, db, monkeypatch ) -> None: diff --git a/tests/unit/test_agent.py b/tests/unit/test_agent.py index 9e872ee..872e08b 100644 --- a/tests/unit/test_agent.py +++ b/tests/unit/test_agent.py @@ -269,8 +269,11 @@ def test_agent_tools_names_and_parameters() -> None: "and `limit` (number of lines) to read exactly that " "part. Do not re-read a document you have already read " "in full — its full text is already in your prompt. " - "Call one tool at a time — wait for this result before " - "your next call." + "For a document from a git source, the result also " + "carries an `origin` line (and `branch` when pinned) — " + "include that original source in your answer (see your " + "tools instructions). Call one tool at a time — wait " + "for this result before your next call." ) # The byte-preserved contracts inside the rewrite, pinned as # substrings: the combined source/path identity, the @@ -293,6 +296,17 @@ def test_agent_tools_names_and_parameters() -> None: "Call one tool at a time — wait for this result before " "your next call." ) in read["description"] + # Phase 145 (task 02): the origin clause mirrors the TOOLS_SECTION + # rule (the description points, the section teaches — the + # phase-124 show_image "see the instructions" shape): a git- + # sourced document's read result carries an `origin` line (and + # `branch` when pinned); include that original source in the + # answer. + assert ( + "For a document from a git source, the result also carries " + "an `origin` line (and `branch` when pinned) — include that " + "original source in your answer (see your tools instructions)." + ) in read["description"] read_params = read["parameters"] assert read_params["type"] == "object" # Phase 131 (task 01, LOCKED A2): offset/limit are OPTIONAL — @@ -3649,6 +3663,320 @@ def test_unknown_tool_name_refused(monkeypatch: pytest.MonkeyPatch) -> None: assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered +# ---------- phase 145 (task 01): the origin/branch lines in the read result ---------- + +#: The fixture git row's URL (basename ``skills`` = the fixture source +#: name) and the four origin outcomes the read-branch matrix runs: +#: git row matched (branch NULL), git row + pinned branch, local source +#: (no git row), two rows sharing the basename (ambiguous — the source +#: name is ``repo`` for the last one). +_GIT_URL = "https://github.com/owner/skills" +_GIT_ROWS = [GitSource(url=_GIT_URL, kind="git")] +_GIT_PINNED_ROWS = [GitSource(url=_GIT_URL, kind="git", branch="release/1.2")] +_LOCAL_ROWS = [GitSource(url="/srv/skills", kind="local", path="/srv/skills")] +_AMBIGUOUS_ROWS = [ + GitSource(url="https://a.example/org/repo.git", kind="git"), + GitSource(url="git@b.example:other/repo.git", kind="git"), +] + + +def _git_rows_factory(rows: list[GitSource]) -> Callable[[], Session]: + """A db factory whose mock session answers the resolver's query with + the git rows of *rows* (like the real ``kind = 'git'`` WHERE clause + — the seeded local rows are filtered out, proving they are never + candidates): the REAL ``origin_for_source`` runs in the read branch, + no database.""" + git_rows = [row for row in rows if row.kind == "git"] + + def _factory() -> Session: + mock = cast("Session", MagicMock()) + mock.__enter__ = MagicMock(return_value=mock) + mock.__exit__ = MagicMock(return_value=False) + mock.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=git_rows))) + return mock + + return _factory + + +def _one_read( + monkeypatch: pytest.MonkeyPatch, + doc: Document, + rows: list[GitSource], + arguments: dict[str, Any], + read_max_chars: int | None = None, +) -> str: + """One scripted read turn against the REAL resolver; returns the + exact tool-result string (the successful-read bookkeeping is + asserted — the matrix asserts the bytes).""" + monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc) + holder = AgentHolder() + llm = ScriptedLLM( + [ToolCallPiece(id="c1", name="read", arguments=arguments)], + [StreamPiece("content", "ans")], + ) + kwargs: dict[str, Any] = {"read_max_chars": read_max_chars} if read_max_chars else {} + asyncio.run(_run(llm, holder, _settings(**kwargs), db_factory=_git_rows_factory(rows))) + assert holder.read_docs == [doc] and holder.tool_calls == 1 + return llm.requests[1][0][3]["content"] + + +def test_origin_lines_plain_read_under_cap( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Shape 1 (plain read at or under the cap): the origin block rides + IMMEDIATELY AFTER the ``date:`` line — ``Document …: / date: / + origin: / branch: / content`` — in all four origin outcomes; the + first line (the mock's ``_READ_RESULT_PREFIX`` contract) is + untouched, and the local/ambiguous results are byte-identical to + pre-phase (the ambiguity still warns).""" + doc = _doc("skills", "docs/a.md", "A", "A-CONTENT") + plain = {"path": "skills/docs/a.md"} + + result = _one_read(monkeypatch, doc, _GIT_ROWS, plain) + assert result == ( + "Document skills/docs/a.md:\n" + "date: 2024-06-15\n" + f"origin: {_GIT_URL}\n" + "A-CONTENT" + ) + # The E2E mock's header contract: first line, byte-identical. + assert result.startswith("Document ") + assert result.splitlines()[0] == "Document skills/docs/a.md:" + + result = _one_read(monkeypatch, doc, _GIT_PINNED_ROWS, plain) + assert result == ( + "Document skills/docs/a.md:\n" + "date: 2024-06-15\n" + f"origin: {_GIT_URL}\n" + "branch: release/1.2\n" + "A-CONTENT" + ) + + result = _one_read(monkeypatch, doc, _LOCAL_ROWS, plain) + assert result == "Document skills/docs/a.md:\ndate: 2024-06-15\nA-CONTENT" + + repo_doc = _doc("repo", "docs/a.md", "A", "A-CONTENT") + caplog.clear() + result = _one_read(monkeypatch, repo_doc, _AMBIGUOUS_ROWS, {"path": "repo/docs/a.md"}) + assert result == "Document repo/docs/a.md:\ndate: 2024-06-15\nA-CONTENT" + # The ambiguity is logged at WARNING (the agent's INFO per-turn log + # lines are captured too — filter to the resolver's records). + warnings = [ + r for r in caplog.records if r.levelname == "WARNING" and r.name == "app.rag.git_sources" + ] + assert len(warnings) == 1 + assert "repo" in warnings[0].message + + +def test_origin_lines_truncated_plain_read( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Shape 2 (plain read cut at the cap): the origin block sits at the + same position (after ``date:``, before the cut content), the + truncation marker + the pinned notice unchanged, the holder + truncation tuple unchanged (the ``read_truncations`` signal is + untouched).""" + cap = 20 + content = "x" * (cap + 1) + doc = _doc("skills", "big.md", "Big", content) + plain = {"path": "skills/big.md"} + + result = _one_read(monkeypatch, doc, _GIT_ROWS, plain, read_max_chars=cap) + assert result == ( + "Document skills/big.md:\n" + "date: 2024-06-15\n" + f"origin: {_GIT_URL}\n" + + content[:cap] + + "\n" + + TRUNCATION_MARKER + + "\n" + + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content)) + ) + + result = _one_read(monkeypatch, doc, _GIT_PINNED_ROWS, plain, read_max_chars=cap) + assert result == ( + "Document skills/big.md:\n" + "date: 2024-06-15\n" + f"origin: {_GIT_URL}\n" + "branch: release/1.2\n" + + content[:cap] + + "\n" + + TRUNCATION_MARKER + + "\n" + + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content)) + ) + + result = _one_read(monkeypatch, doc, _LOCAL_ROWS, plain, read_max_chars=cap) + assert result == ( + "Document skills/big.md:\n" + "date: 2024-06-15\n" + + content[:cap] + + "\n" + + TRUNCATION_MARKER + + "\n" + + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content)) + ) + + repo_doc = _doc("repo", "big.md", "Big", content) + result = _one_read( + monkeypatch, repo_doc, _AMBIGUOUS_ROWS, {"path": "repo/big.md"}, read_max_chars=cap + ) + assert result == ( + "Document repo/big.md:\n" + "date: 2024-06-15\n" + + content[:cap] + + "\n" + + TRUNCATION_MARKER + + "\n" + + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content)) + ) + + +def test_origin_lines_range_read_under_cap( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Shape 3 (range read at or under the cap): the origin block rides + between the ``date:`` line and the ``lines:`` header (the same + position as in the plain shapes); the local/ambiguous results stay + byte-identical to pre-phase.""" + doc = _doc("skills", "b.md", "B", "one\ntwo\nthree\nfour\nfive") + ranged = {"path": "skills/b.md", "offset": 2, "limit": 2} + + result = _one_read(monkeypatch, doc, _GIT_ROWS, ranged) + assert result == ( + "Document skills/b.md:\n" + "date: 2024-06-15\n" + f"origin: {_GIT_URL}\n" + "lines: 2-3 of 5\n" + "two\nthree" + ) + + result = _one_read(monkeypatch, doc, _GIT_PINNED_ROWS, ranged) + assert result == ( + "Document skills/b.md:\n" + "date: 2024-06-15\n" + f"origin: {_GIT_URL}\n" + "branch: release/1.2\n" + "lines: 2-3 of 5\n" + "two\nthree" + ) + + result = _one_read(monkeypatch, doc, _LOCAL_ROWS, ranged) + assert result == "Document skills/b.md:\ndate: 2024-06-15\nlines: 2-3 of 5\ntwo\nthree" + + repo_doc = _doc("repo", "b.md", "B", "one\ntwo\nthree\nfour\nfive") + caplog.clear() + result = _one_read( + monkeypatch, repo_doc, _AMBIGUOUS_ROWS, {"path": "repo/b.md", "offset": 2, "limit": 2} + ) + assert result == "Document repo/b.md:\ndate: 2024-06-15\nlines: 2-3 of 5\ntwo\nthree" + # The ambiguity is logged at WARNING (the agent's INFO per-turn log + # lines are captured too — filter to the resolver's records). + warnings = [ + r for r in caplog.records if r.levelname == "WARNING" and r.name == "app.rag.git_sources" + ] + assert len(warnings) == 1 + assert "repo" in warnings[0].message + + +def test_origin_lines_truncated_range_read( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Shape 4 (range read whose SLICE exceeds the cap): the origin block + at the same position; the cap on the slice + the shared marker + the + pinned notice (``{total}`` = the SLICE's length) all unchanged.""" + cap = 40 + doc = _doc("skills", "c.md", "C", "a" * 30 + "\n" + "b" * 30 + "\n" + "c" * 30) + slice_text = "a" * 30 + "\n" + "b" * 30 # the requested lines 1-2 (61 chars) + ranged = {"path": "skills/c.md", "offset": 1, "limit": 2} + + result = _one_read(monkeypatch, doc, _GIT_ROWS, ranged, read_max_chars=cap) + assert result == ( + "Document skills/c.md:\n" + "date: 2024-06-15\n" + f"origin: {_GIT_URL}\n" + "lines: 1-2 of 3\n" + + slice_text[:cap] + + "\n" + + TRUNCATION_MARKER + + "\n" + + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text)) + ) + + result = _one_read(monkeypatch, doc, _GIT_PINNED_ROWS, ranged, read_max_chars=cap) + assert result == ( + "Document skills/c.md:\n" + "date: 2024-06-15\n" + f"origin: {_GIT_URL}\n" + "branch: release/1.2\n" + "lines: 1-2 of 3\n" + + slice_text[:cap] + + "\n" + + TRUNCATION_MARKER + + "\n" + + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text)) + ) + + result = _one_read(monkeypatch, doc, _LOCAL_ROWS, ranged, read_max_chars=cap) + assert result == ( + "Document skills/c.md:\n" + "date: 2024-06-15\n" + "lines: 1-2 of 3\n" + + slice_text[:cap] + + "\n" + + TRUNCATION_MARKER + + "\n" + + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text)) + ) + + repo_doc = _doc("repo", "c.md", "C", "a" * 30 + "\n" + "b" * 30 + "\n" + "c" * 30) + result = _one_read( + monkeypatch, repo_doc, _AMBIGUOUS_ROWS, {"path": "repo/c.md", "offset": 1, "limit": 2}, + read_max_chars=cap, + ) + assert result == ( + "Document repo/c.md:\n" + "date: 2024-06-15\n" + "lines: 1-2 of 3\n" + + slice_text[:cap] + + "\n" + + TRUNCATION_MARKER + + "\n" + + READ_TRUNCATION_NOTICE.format(shown=cap, total=len(slice_text)) + ) + + +def test_origin_lines_image_doc_keeps_marker_position( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The image-doc shape keeps its phase-122 pin: the marker stays + IMMEDIATELY ABOVE the content, and the origin lines ride ABOVE the + marker (``Document …: / date: / origin: / branch: / marker / + content``); a local image doc is byte-identical to pre-phase.""" + doc = _doc("skills", "img.png", "Img", "IMG-DESC") + doc.is_image = True + plain = {"path": "skills/img.png"} + + result = _one_read(monkeypatch, doc, _GIT_PINNED_ROWS, plain) + assert result == ( + "Document skills/img.png:\n" + "date: 2024-06-15\n" + f"origin: {_GIT_URL}\n" + "branch: release/1.2\n" + f"{agent.IMAGE_DOC_MARKER}\n" + "IMG-DESC" + ) + + doc = _doc("skills", "img.png", "Img", "IMG-DESC") + doc.is_image = True + result = _one_read(monkeypatch, doc, _LOCAL_ROWS, plain) + assert result == ( + "Document skills/img.png:\ndate: 2024-06-15\n" + f"{agent.IMAGE_DOC_MARKER}\n" + "IMG-DESC" + ) + + # ---------- grep (the phase-68 A5 contract under the new name) ---------- diff --git a/tests/unit/test_document_viewer.py b/tests/unit/test_document_viewer.py index d9eabf4..3585f2d 100644 --- a/tests/unit/test_document_viewer.py +++ b/tests/unit/test_document_viewer.py @@ -20,9 +20,29 @@ Frontend side: * phase 26 — the shared ``renderDocument`` export in ``document.js``, the import-safe page guard, the modal skeleton on chat + Sources, and the modal module's close/focus/URL contract. + +Phase 145 (task 03) — the content payload's git origin fields + the +``renderDocument`` "Original" anchor (modal AND /document.html, one +site): +* the ``DocContent`` omission rule — both origin keys ABSENT on the + wire when ``None`` (never ``null``; non-git payload byte-identical); +* the content endpoint (stubbed session) — a matching ``kind='git'`` + row adds ``origin_url`` (+ ``origin_branch`` for a pinned row), a + legacy embedded-credential row is served sanitized, and local / + ambiguous sources keep the pre-phase key set; +* the anchor itself (source pins + a node behavioral check over a + minimal fake DOM) — ``createElement("a")``, the full URL verbatim as + ``href`` AND visible text (the copyable-link contract), + ``target="_blank" rel="noopener"``, the ``title`` = full URL (+ + `` — branch: `` when pinned), inserted AFTER the path code and + BEFORE the Created badge (absent → the pre-phase six children), no + innerHTML/outerHTML (the module's ONE innerHTML survives), and the + ``.doc-origin`` CSS mirroring the ``.doc-path`` ellipsis idiom with + the ``.doc-link`` affordance (+ the ≤640px squeeze). """ from __future__ import annotations +import json import re import shutil import subprocess @@ -35,7 +55,8 @@ from fastapi.testclient import TestClient from app.api.docs import doc_format from app.db import get_db from app.main import create_app -from app.models import Document +from app.models import Document, GitSource +from app.schemas import DocContent from tests.conftest import ADMIN_PASSWORD FRONTEND = Path(__file__).resolve().parents[2] / "frontend" @@ -126,22 +147,39 @@ class _FakeResult: return self._row +class _FakeScalars: + """The resolver's ``db.scalars(...).all()`` — only the git rows + ever reach the caller (the ``kind = 'git'`` WHERE clause is + emulated, the task-01 ``_GitOnlySession`` pattern).""" + + def __init__(self, rows: tuple[GitSource, ...]) -> None: + self._rows = [row for row in rows if row.kind == "git"] + + def all(self) -> list[GitSource]: + return self._rows + + class _FakeSession: - def __init__(self, row: object) -> None: + def __init__(self, row: object, git_rows: tuple[GitSource, ...] = ()) -> None: self._row = row + self._git_rows = git_rows def execute(self, _stmt: object) -> _FakeResult: return _FakeResult(self._row) + def scalars(self, _stmt: object) -> _FakeScalars: + return _FakeScalars(self._git_rows) -def _client_with_row(row: object) -> TestClient: + +def _client_with_row(row: object, git_rows: tuple[GitSource, ...] = ()) -> TestClient: """Fresh app whose ``get_db`` dependency is a stub returning ``row`` - (``None`` → no matching document row). Phase 79: the endpoint is - user-gated, so the client signs in as the admin first — these tests - pin the CONTENT mapping (200/404/422), not the auth contract (which - ``test_auth_api.py`` pins).""" + (``None`` → no matching document row) and, for the phase-145 + origin lookup, *git_rows* (default: no rows — the non-git corner). + Phase 79: the endpoint is user-gated, so the client signs in as + the admin first — these tests pin the CONTENT mapping (200/404/ + 422), not the auth contract (which ``test_auth_api.py`` pins).""" app = create_app() - app.dependency_overrides[get_db] = lambda: _FakeSession(row) + app.dependency_overrides[get_db] = lambda: _FakeSession(row, git_rows) client = TestClient(app) r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}" @@ -217,6 +255,322 @@ def test_content_requires_both_params() -> None: assert client.get("/api/documents/content", params={"source": "Homelab"}).status_code == 422 +# --------------------------------------------------------------------------- +# Phase 145 (task 03) — the content payload's git origin fields + the +# renderDocument "Original" anchor (modal AND /document.html, one site) +# --------------------------------------------------------------------------- + + +def _text_doc() -> Document: + """The content-endpoint stub's document row (a markdown doc — no + summary, the pre-phase shape).""" + doc = Document( + source="Homelab", + path="notes/deep mark.md", + full_path="/tmp/deep mark.md", + title="Deep Mark", + content="# Deep Mark\n\nbody", + content_hash="f" * 64, + is_image=False, # phase 122: the stub session never applies defaults + ) + doc.indexed_at = datetime(2026, 8, 22, 1, 2, 3, tzinfo=UTC) + doc.created_at = datetime(2026, 8, 20, 9, 0, 0, tzinfo=UTC) + return doc + + +def _text_doc_content( + origin_url: str | None = None, origin_branch: str | None = None +) -> DocContent: + """One text doc's content payload (the pre-phase shape) with the + phase-145 origin fields optionally set.""" + return DocContent( + source="S", + path="a.md", + title="A", + format="md", + created_at="2026-01-01T00:00:00+00:00", + content="body", + indexed_at="2026-01-01T00:00:00+00:00", + chunks=1, + origin_url=origin_url, + origin_branch=origin_branch, + ) + + +def test_doc_content_origin_fields_absent_on_the_wire_when_unset() -> None: + """The model-level omission rule (the :func:`_drop_absent_share_url` + precedent, the ``image_url`` shape): a non-git document (no + ``origin_url``) serializes with BOTH origin keys ABSENT — never + ``null`` — so the payload is byte-identical to pre-phase. A + sanitized-URL-only payload (an unpinned row) drops + ``origin_branch``; a pinned row keeps both keys.""" + dumped = _text_doc_content().model_dump() + assert "origin_url" not in dumped # absent — never null + assert "origin_branch" not in dumped # absent — never null + dumped = _text_doc_content(origin_url="https://host/x.git").model_dump() + assert dumped["origin_url"] == "https://host/x.git" + assert "origin_branch" not in dumped # NULL = the default branch — nothing to show + dumped = _text_doc_content( + origin_url="https://host/x.git", origin_branch="docs" + ).model_dump() + assert dumped["origin_branch"] == "docs" + + +def test_content_origin_url_carried_for_a_matching_git_row() -> None: + """The endpoint (stubbed session, no database): a ``kind='git'`` + row whose URL basename matches the document's source adds + ``origin_url`` — the sanitized clone URL — to the payload, and + every pre-phase key stands untouched. The row pins no branch → + ``origin_branch`` is ABSENT (never ``null``).""" + row = GitSource(url="https://github.com/reese/Homelab.git", kind="git") + with _client_with_row((_text_doc(), 3), git_rows=(row,)) as client: + r = client.get( + "/api/documents/content", + params={"source": "Homelab", "path": "notes/deep mark.md"}, + ) + assert r.status_code == 200 + body = r.json() + assert set(body) == { + "source", "path", "title", "format", "summary", "created_at", + "content", "indexed_at", "chunks", "is_image", "origin_url", + } + assert body["origin_url"] == "https://github.com/reese/Homelab.git" + assert "origin_branch" not in body # absent — never null (unpinned row) + + +def test_content_origin_branch_carried_for_a_pinned_row() -> None: + """A pinned row (phase 135 ``branch``) also carries + ``origin_branch`` — the branch the title attribute will show.""" + row = GitSource( + url="https://github.com/reese/Homelab.git", kind="git", branch="docs" + ) + with _client_with_row((_text_doc(), 3), git_rows=(row,)) as client: + r = client.get( + "/api/documents/content", + params={"source": "Homelab", "path": "notes/deep mark.md"}, + ) + body = r.json() + assert body["origin_url"] == "https://github.com/reese/Homelab.git" + assert body["origin_branch"] == "docs" + + +def test_content_origin_url_sanitizes_a_legacy_embedded_credential_row() -> None: + """End-to-end (stubbed): a legacy row whose credential is still + embedded in the stored URL never leaks it — the payload carries + the phase-121 sanitized URL (the resolver's guarantee, one mapping + for the whole app).""" + row = GitSource(url="https://user:tok@example.com/reese/Homelab.git", kind="git") + with _client_with_row((_text_doc(), 3), git_rows=(row,)) as client: + r = client.get( + "/api/documents/content", + params={"source": "Homelab", "path": "notes/deep mark.md"}, + ) + body = r.json() + assert body["origin_url"] == "https://example.com/reese/Homelab.git" + assert "user:tok" not in r.text # the credential stays out of the wire + + +def test_content_origin_fields_absent_for_local_and_ambiguous_sources() -> None: + """No git row (a plain local source) OR two rows sharing the + basename (ambiguous — never guess) → both origin keys ABSENT from + the JSON (not ``null`` — the omission pinned on the wire; the + payload is byte-identical to pre-phase, the ``test_content_known_ + pair_maps_to_doc_content`` key-set pin).""" + local_only = (_text_doc(), 3) + with _client_with_row(local_only) as client: # no git rows at all + r = client.get( + "/api/documents/content", + params={"source": "Homelab", "path": "notes/deep mark.md"}, + ) + assert r.status_code == 200 + assert "origin_url" not in r.json() + assert "origin_branch" not in r.json() + + ambiguous = ( + GitSource(url="https://a.example/org/Homelab.git", kind="git"), + GitSource(url="git@b.example:other/Homelab.git", kind="git"), + ) + with _client_with_row(local_only, git_rows=ambiguous) as client: + r = client.get( + "/api/documents/content", + params={"source": "Homelab", "path": "notes/deep mark.md"}, + ) + assert r.status_code == 200 + assert "origin_url" not in r.json() + assert "origin_branch" not in r.json() + + +# ---------- the renderDocument "Original" anchor (the shared core) ---------- + + +def _fn(js: str, name: str) -> str: + """The source of a top-level function via balanced-brace counting + (the ``test_date_editor.py`` pattern).""" + start = js.find(f"function {name}(") + assert start != -1, f"{name}() must exist in document.js" + i = js.find("(", start) + depth = 0 + close = i + while i < len(js): + if js[i] == "(": + depth += 1 + elif js[i] == ")": + depth -= 1 + if depth == 0: + close = i + break + i += 1 + brace = js.find("{", close) + depth = 0 + for j in range(brace, len(js)): + if js[j] == "{": + depth += 1 + elif js[j] == "}": + depth -= 1 + if depth == 0: + return js[start : j + 1] + raise AssertionError(f"unbalanced braces in {name}()") + + +def test_origin_anchor_is_a_copyable_external_link() -> None: + """The anchor (house-style source contract): ``createElement("a")`` + (the never-build-HTML rule), the ``doc-origin`` class, ``href = + doc.origin_url``, ``target="_blank" rel="noopener"`` (the modal's + "Full page" escape-hatch idiom), the full URL VERBATIM as the + visible text (the copyable-link contract — no decorative label), + and the ``title`` = the full URL, plus `` — branch: `` when + the payload pins a branch (the pathCode precision idiom).""" + body = _fn(_read(DOCUMENT_JS), "originAnchor") + assert 'document.createElement("a")' in body + assert 'a.className = "doc-origin"' in body + assert "a.href = doc.origin_url" in body + assert 'a.target = "_blank"' in body + assert 'a.rel = "noopener"' in body + assert "a.textContent = doc.origin_url" in body, ( + "the visible text is the full URL verbatim (copy-pasteable)" + ) + branch_title = 'doc.origin_url + " — branch: " + doc.origin_branch' + assert 'doc.origin_branch ? ' + branch_title + " : doc.origin_url" in body, ( + "the title carries the full URL (+ the pinned branch when present)" + ) + # The never-build-HTML shape: no innerHTML/outerHTML in the new code. + code = re.sub(r"//.*?$|/\*.*?\*/", "", body, flags=re.S | re.M) + assert "innerHTML" not in code and "outerHTML" not in code + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_origin_anchor_behaves_for_branch_and_no_branch_variants() -> None: + """Behavioral check (node, minimal fake DOM) of the real anchor + builder: the exact ``href``/``target``/``rel``/``textContent``/ + ``title`` for a git doc, branch and no-branch variants.""" + fn = _fn(_read(DOCUMENT_JS), "originAnchor") + fake_dom = ( + "globalThis.document = { createElement: (tag) => {" + ' const el = { tag, className: "", href: "", target: "", rel: "",' + ' textContent: "", setAttribute(k, v) { this[k] = v; } };' + " return el; } };" + ) + out = _run_node( + fake_dom + + "\n" + fn + + '\nconst g1 = originAnchor({ origin_url: "https://github.com/reese/skills.git",' + ' origin_branch: "main" });' + "\n" + + '\nconst g2 = originAnchor({ origin_url: "https://github.com/reese/skills.git" });' + "\n" + + "console.log(JSON.stringify(g1));\nconsole.log(JSON.stringify(g2));" + ) + j1, j2 = (json.loads(line) for line in out.splitlines()) + for j in (j1, j2): + assert j["tag"] == "a" + assert j["className"] == "doc-origin" + assert j["href"] == "https://github.com/reese/skills.git" + assert j["target"] == "_blank" + assert j["rel"] == "noopener" + # The full URL VERBATIM as the visible text (copy-pasteable). + assert j["textContent"] == "https://github.com/reese/skills.git" + # The title: the full URL, + the pinned branch when present. + assert j1["title"] == "https://github.com/reese/skills.git — branch: main" + assert j2["title"] == "https://github.com/reese/skills.git" + + +def test_origin_anchor_sits_after_the_path_code_before_the_created_badge() -> None: + """renderDocument (the ONE shared core — modal AND /document.html): + the anchor joins the meta row AFTER the path ``code`` and BEFORE + the Created badge — and ONLY when the payload carries + ``origin_url``. Absent (a non-git document — the field simply + missing on the wire) → the gate builds the empty array, the + spread adds nothing, and the meta row's children are EXACTLY the + pre-phase six (the existing badge-order pins pass unchanged).""" + render = _fn(_read(DOCUMENT_JS), "renderDocument") + gate_i = render.find("const originItems = doc.origin_url ? [originAnchor(doc)] : [];") + call_i = render.find("metaEl.replaceChildren(") + path_i = render.find("pathCode,", call_i) + spread_i = render.find("...originItems,", call_i) + created_i = render.find('metaBadge("doc-created"', call_i) + assert -1 < gate_i < call_i < path_i < spread_i < created_i, ( + "the anchor gate precedes the row build and lands AFTER the path code, " + "BEFORE the Created badge" + ) + # The gate: present only on a truthy origin_url — an ABSENT field + # (undefined) is falsy → [] → the pre-phase children stand. + assert "doc.origin_url ? [originAnchor(doc)] : []" in render + # The spread is the meta row's ONLY conditional child (the six + # pre-phase children are the fixed ones). + assert render[call_i:].count("...") == 1 + + +def test_origin_anchor_keeps_the_module_xss_contract() -> None: + """The module's ONE innerHTML (the escape-first markdown render) + survives — the new anchor adds no HTML-building of its own + (createElement + properties/textContent only).""" + code = re.sub(r"//.*", "", re.sub(r"/\*.*?\*/", "", _read(DOCUMENT_JS), flags=re.S)) + assert code.count("innerHTML") == 1 + assert "outerHTML" not in code + + +def test_doc_origin_css_mirrors_the_doc_path_ellipsis_idiom() -> None: + """styles.css: the ``.doc-origin`` rule sits next to the + ``.doc-path`` family (mono, 0.75rem, the same 26rem ellipsis + budget + 6rem floor) with the ``.doc-link`` affordance + (brand-ink, underline on hover/focus — the existing link + tokens), provenance-cited, and the ≤640px block squeezes it like + the path (16rem — the row's own wrap/clip rules are unchanged: + the anchor is one more inline item).""" + css = _read(FRONTEND / "assets" / "styles.css") + rule_i = css.find(".doc-origin {") + assert rule_i != -1, "styles.css must style .doc-origin" + # Provenance comment directly above the rule (house style). + comment_start = css.rfind("/*", 0, rule_i) + assert -1 < comment_start < rule_i and "Phase 145 (task 03)" in css[comment_start:rule_i] + # Next to the .doc-path family (before .doc-chunks). + assert css.find(".doc-path {") < rule_i < css.find(".doc-chunks {") + rule = css[rule_i : css.find("}", rule_i)] + for decl in ( + "font-family: var(--mono)", + "font-size: 0.75rem", + "max-width: 26rem", + "white-space: nowrap", + "overflow: hidden", + "text-overflow: ellipsis", + "min-width: 6rem", + "color: var(--brand-ink)", + "text-decoration: none", + ): + assert decl in rule, f".doc-origin must carry {decl!r}" + # The .doc-link hover/focus affordance (the existing link idiom). + assert ".doc-origin:hover, .doc-origin:focus-visible { text-decoration: underline; }" in css + # The ≤640px squeeze mirrors .doc-path's 16rem (same media block). + mobile = css.find("@media (max-width: 640px)") + path_sq = css.find(".doc-path { max-width: 16rem; }", mobile) + origin_sq = css.find(".doc-origin { max-width: 16rem; }", mobile) + close_640 = css.find("@media", mobile + 1) + assert -1 < path_sq < origin_sq < (close_640 if close_640 != -1 else len(css)), ( + "the mobile block squeezes the anchor like the path" + ) + assert "url(http" not in css and "@import url(" not in css, ( + "no CDN (AGENTS.md rule 6)" + ) + + # --------------------------------------------------------------------------- # Viewer URL builder — encoded query (spaces/slashes in real paths) # --------------------------------------------------------------------------- diff --git a/tests/unit/test_git_sources.py b/tests/unit/test_git_sources.py index 6188c6a..0818a74 100644 --- a/tests/unit/test_git_sources.py +++ b/tests/unit/test_git_sources.py @@ -179,3 +179,124 @@ def test_alias_env_fallback_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: assert urls == ["https://env.example/one.git"] assert origin == "env" + + +# --- phase 145 (task 01): origin_for_source (the read result's origin) --- + + +class _GitOnlySession: + """The origin resolver's session (phase 145, task 01): emulates the + resolver's ``kind = 'git'`` WHERE clause — only the git rows ever + reach the caller, exactly like the real query; the seeded local + rows prove they are never candidates.""" + + def __init__(self, rows: list[GitSource]) -> None: + self._rows = [row for row in rows if row.kind == "git"] + self.statements: list[Any] = [] + + def scalars(self, stmt: Any) -> _FakeScalars: + self.statements.append(stmt) + return _FakeScalars(self._rows) + + +# --- the basename mapping -------------------------------------------------- + + +def test_origin_basename_mapping() -> None: + """The URL's path component — after the ``://``, or after the ``:`` + for scp-style ``git@host:owner/repo`` — trailing ``/`` stripped, + a single trailing ``.git`` suffix stripped, last segment taken. An + ``@`` inside the PATH is not userinfo and stays (no split, no + strip).""" + cases = { + "https://github.com/owner/myrepo.git": "myrepo", # the .git suffix + "https://github.com/owner/myrepo": "myrepo", # no suffix, same dir + "https://github.com/owner/myrepo/": "myrepo", # trailing slash + "https://github.com/owner/myrepo.git/": "myrepo", # both + "git@github.com:owner/myrepo.git": "myrepo", # scp-style + suffix + "git@github.com:owner/myrepo": "myrepo", # scp-style, no suffix + "ssh://git@host/owner/myrepo.git": "myrepo", # ssh scheme, @ in the host part + "https://host/a@b/myrepo.git": "myrepo", # @ inside the PATH — not userinfo + } + for url, expected in cases.items(): + assert resolver._url_basename(url) == expected, url + + +# --- the match: sanitize + branch ------------------------------------------ + + +def test_origin_exact_match_is_sanitized() -> None: + """EXACTLY ONE match → the phase-121 masked URL + the branch — a + legacy embedded-credential row never leaks its secret (the stored + value is untouched; the OUTPUT mask does the work).""" + session = _GitOnlySession( + [GitSource(url="https://user:tok@host/x.git", kind="git", branch=None)] + ) + + assert resolver.origin_for_source(session, "x") == ("https://host/x.git", None) # pyright: ignore[reportArgumentType] + + +def test_origin_branch_passthrough() -> None: + """The branch rides through as-is: pinned → the name (the read + result's ``branch:`` line), NULL → ``None`` (the default branch — + no ``branch:`` line).""" + session = _GitOnlySession( + [GitSource(url="https://host/x.git", kind="git", branch="release/1.2")] + ) + + assert resolver.origin_for_source(session, "x") == ("https://host/x.git", "release/1.2") # pyright: ignore[reportArgumentType] + + +def test_origin_local_rows_never_origins() -> None: + """Local rows are never origins (nothing to link to from the user's + browser): a source that exists ONLY as a local row gets no origin + (zero git candidates); a local + git pair resolves to the git row.""" + local = GitSource(url="/srv/repo", kind="local", path="/srv/repo") + session = _GitOnlySession([local]) # the WHERE clause drops it + assert resolver.origin_for_source(session, "repo") is None # pyright: ignore[reportArgumentType] + + session = _GitOnlySession( + [local, GitSource(url="https://host/repo.git", kind="git", branch=None)] + ) + assert resolver.origin_for_source(session, "repo") == ("https://host/repo.git", None) # pyright: ignore[reportArgumentType] + + +# --- the no-guess rows: None + WARNING -------------------------------------- + + +def test_origin_zero_match_is_none_with_warning( +caplog: pytest.LogCaptureFixture, +) -> None: + """No git row's basename matches (a plain local source) → ``None`` + + a WARNING naming the source — the read result stays + byte-identical to pre-phase.""" + session = _GitOnlySession([GitSource(url="https://host/other.git", kind="git")]) + + assert resolver.origin_for_source(session, "repo") is None # pyright: ignore[reportArgumentType] + + warnings = [record for record in caplog.records if record.levelname == "WARNING"] + assert len(warnings) == 1 + assert "repo" in warnings[0].message + + +def test_origin_ambiguous_is_none_with_warning( +caplog: pytest.LogCaptureFixture, +) -> None: + """TWO-OR-MORE rows share the basename → ``None`` + a WARNING naming + the sanitized candidates (never guess — a wrong origin would ride + into every answer about the document). The candidates are logged + SANITIZED: a legacy embedded credential stays out of the log too.""" + session = _GitOnlySession( + [ + GitSource(url="https://user:tok@a.example/org/repo.git", kind="git"), + GitSource(url="git@b.example:other/repo.git", kind="git"), + ] + ) + + assert resolver.origin_for_source(session, "repo") is None # pyright: ignore[reportArgumentType] + + warnings = [record for record in caplog.records if record.levelname == "WARNING"] + assert len(warnings) == 1 + assert "https://a.example/org/repo.git" in warnings[0].message + assert "git@b.example:other/repo.git" in warnings[0].message + assert "tok" not in warnings[0].message diff --git a/tests/unit/test_prompt_lock.py b/tests/unit/test_prompt_lock.py index 6baac77..0fe6186 100644 --- a/tests/unit/test_prompt_lock.py +++ b/tests/unit/test_prompt_lock.py @@ -15,6 +15,7 @@ from __future__ import annotations import hashlib from app.rag.prompts import ( + ORIGIN_RULE, PERSONA, SUGGEST_INTRO, TOOLS_SECTION, @@ -77,12 +78,18 @@ def test_high_and_low_bases_byte_locked() -> None: #: form — do not re-read a document read IN FULL (answer from it); #: a PARTIAL document (a range read, or a truncated read) is not #: complete in the context until read all — read the other parts with -#: ``offset``/``limit``. Only that sentence moved — the prefix (the -#: ``ls``-clause opening) and the suffix (the discipline-rules -#: ending) survived byte-identical, so they are the same anchors as -#: pre-phase-141. -TOOLS_SECTION_SHA256 = "3671680bbf4e04efe1186690c18d24869e4974ceb29485cedc3e34a9286bf3b2" -TOOLS_SECTION_LEN = 2656 +#: ``offset``/``limit``. RE-CUT again for phase 145 (task 02): the +#: ``read`` clause gains the original-source rule — :data:`ORIGIN_RULE`, +#: the one sentence-group appended at the ``read`` clause's end (the +#: truncation workflow sentence) and BEFORE the ``grep`` clause begins +#: (the ``origin:`` line is a read-result fact; the rule binds the +#: ANSWER that uses what the read added). Only that sentence-group was +#: inserted — the prefix (the ``ls``-clause opening) and the suffix +#: (the discipline-rules ending) survived byte-identical, so they are +#: the same anchors as pre-phase-145; the pre-phase-145 sha/length are +#: kept below for the diff-style pin. +TOOLS_SECTION_SHA256 = "41a84885be98d4e15b95205694d59d364296a30e27758d39ae737881d5bacaa7" +TOOLS_SECTION_LEN = 3279 TOOLS_SECTION_PREFIX = ( "\n" "You may extend your context with three tools. `ls` lists the " @@ -94,6 +101,33 @@ TOOLS_SECTION_SUFFIX = ( "you need.\n" ) +#: The pre-phase-145 anchors — the section BEFORE :data:`ORIGIN_RULE` was +#: inserted. Kept for the diff-style pin: the ONLY byte change is the +#: rule's insertion, so removing the rule reproduces these exact bytes. +TOOLS_SECTION_PRE_PHASE_145_SHA256 = ( + "3671680bbf4e04efe1186690c18d24869e4974ceb29485cedc3e34a9286bf3b2" +) +TOOLS_SECTION_PRE_PHASE_145_LEN = 2656 + + +#: Phase 145 (task 02) — the original-source rule the ``read`` teaching +#: carries is :data:`app.rag.prompts.ORIGIN_RULE` (imported above, LOCKED +#: verbatim). :data:`TOOLS_SECTION` embeds that exact sentence-group at +#: the ``read`` clause's end (the truncation workflow sentence) and +#: BEFORE the ``grep`` clause begins. A read result for a git-sourced +#: document carries an ``origin:`` line (and a ``branch:`` line when a +#: branch is pinned) — the read contract's fact — so when the answer uses +#: such a document it includes the original source in the answer: a link +#: to the repository (the ``origin`` line) with the document's path (the +#: ``Document …`` header) named and the pinned branch when one is shown, +#: as the repository's file-view URL where the model can tell it +#: (``/blob//``), or the repository URL plus the +#: path as plain text otherwise. The rule is CONDITIONAL on the ``origin`` +#: line's presence — a local-sourced document's read result carries none, +#: so the rule adds nothing to those answers (the model has no URL to +#: cite; it must not invent one — the sentence's conditioning does that +#: job). + def test_tools_section_byte_locked() -> None: """The ```` teaching is LOCKED verbatim too (the E2E mock keys @@ -105,6 +139,41 @@ def test_tools_section_byte_locked() -> None: assert TOOLS_SECTION.endswith(TOOLS_SECTION_SUFFIX) +def test_tools_section_origin_rule_verbatim_at_its_position() -> None: + """Phase 145 (task 02): the original-source rule is LOCKED verbatim + and sits at its EXACT position — the ``read`` clause's end (the + truncation workflow sentence) and BEFORE the ``grep`` clause begins. + The sentence-group is pinned byte-for-byte, and the surrounding + boundary (the truncation sentence's tail → the rule → the ``grep`` + clause's opening) is pinned as one contiguous substring, so a move + or re-spelling of ANY byte (the rule's or the boundary's) fails.""" + # Verbatim presence. + assert ORIGIN_RULE in TOOLS_SECTION + assert TOOLS_SECTION.count(ORIGIN_RULE) == 1 # exactly once + # Exact position: after the truncation sentence, before the grep + # clause — one contiguous span, no drift at either seam. + assert ( + "it searches the whole document. " + + ORIGIN_RULE + + " `grep` locates an exact string" + ) in TOOLS_SECTION + + +def test_tools_section_origin_rule_is_the_only_byte_change() -> None: + """Phase 145 (task 02) diff-style pin: the section's OTHER bytes are + byte-identical to pre-phase — the pre-phase section with ONLY the new + sentence-group inserted. Removing the rule (and the single space + that now separates it from the ``grep`` clause) reproduces the + pre-phase-145 section byte-for-byte (sha + length), and the length + delta is exactly the rule's length plus that one inserted space.""" + assert len(TOOLS_SECTION) == ( + TOOLS_SECTION_PRE_PHASE_145_LEN + len(ORIGIN_RULE) + 1 + ) + pre_phase = TOOLS_SECTION.replace(ORIGIN_RULE + " ", "") + assert len(pre_phase) == TOOLS_SECTION_PRE_PHASE_145_LEN + assert _sha256(pre_phase) == TOOLS_SECTION_PRE_PHASE_145_SHA256 + + # ---------- the LOW prompt's locked DEFLECT_MODE body ---------- #: The ``DEFLECT_MODE`` body exactly as it was pre-phase-112 (the diff --git a/tests/unit/test_prompts.py b/tests/unit/test_prompts.py index b372b54..0a2f2f2 100644 --- a/tests/unit/test_prompts.py +++ b/tests/unit/test_prompts.py @@ -48,6 +48,7 @@ import pytest from app.config import Settings from app.models import Document from app.rag.prompts import ( + ORIGIN_RULE, PERSONA, SHOW_IMAGE_CLAUSE, SUGGEST_INTRO, @@ -463,6 +464,43 @@ def test_tools_section_phase72_clauses_in_high_prompt_not_low() -> None: assert "including the source name" not in low +def test_tools_section_phase145_origin_rule() -> None: + """Phase 145 (task 02): the ```` ``read`` clause carries the + original-source rule (LOCKED verbatim in :data:`ORIGIN_RULE`) — when + the answer uses a document whose read result carries an ``origin:`` + line, it includes the original source (a link to the repository + from the origin line, with the document's path and the pinned branch + where shown) so the user opens the complete original in one step. + The rule rides the HIGH prompt (```` is HIGH-only) and never + the LOW/deflection prompt. The citation discipline is UNCHANGED — + the rule extends the ANSWERS of cited documents; it does not widen + the cite set (the "cite only what you read" sentence stays + byte-identical — pinned in ``test_prompt_lock``).""" + # The rule is present verbatim in the constant, exactly once, and at + # its exact position (the read clause's end, before the grep clause). + assert TOOLS_SECTION.count(ORIGIN_RULE) == 1 + assert ( + "it searches the whole document. " + + ORIGIN_RULE + + " `grep` locates an exact string" + ) in TOOLS_SECTION + # It rides the built HIGH prompt with the rest of the section. + doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes") + high = build_high_prompt([doc]) + assert ORIGIN_RULE in high + # ...and never the LOW/deflection prompt (```` is HIGH-only). + for low in (build_deflect_prompt(["T1"]), build_deflect_prompt([])): + assert ORIGIN_RULE not in low + # The citation discipline is UNCHANGED: the rule does not widen the + # cite set (the "cite only what you read" sentence is untouched). + assert ( + "Cite only the document(s) you read — or, if you answered from a " + "suggested summary without reading it, cite that suggested " + "document — never a document you neither read nor used." + ) in high + assert "Cite only the document(s) you read" in SUGGEST_INTRO + + def test_documents_section_leads_with_the_suggest_intro() -> None: """Phase 118, task 03: the ```` section leads with the start-here :data:`SUGGEST_INTRO` line BEFORE the first block (the