phase: 106_document_dates
Everything is verified green. Final report: **Phase 106 — Document dates (final verification pass; all 10 tasks already complete)** - Verified all phase artifacts: alembic `0020` (dev DB at `0020`), `doc_dates.py`, git `file_commit_dates`, importer `doc_dates_by_root`/`dates_updated`, both entry-point wirings, date APIs + tree `created_at`/`updated_at`, LLM surfaces (prompt block, `read` line 2, appended `ls` field), `apply_recency_boost` in `retrieve()`, UI columns/badge, admin editor, mock-LLM regex — all present and correct; no defects found, no fixes needed. - `uv run pytest --cov=app --cov-report=term-missing` → **2299 passed, TOTAL 99%** (>90% ✓) - `uv run pytest tests/e2e/test_document_dates.py -v --no-cov` → **6/6 passed** in isolation (DB up) - 12 regression E2E suites (retrieval_quality, whole_document_context, agent_document_tools, ls_tree_drilldown, read_truncation_cap, kb_tree, kb_tree_nav, document_viewer, edit_summaries, import_documents, sync_button, hidden_folders_toggle, smoke) → **all green in isolation** - `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings** **Completion criteria:** 1) non-null `created_at` + 0020 upgrade/downgrade on dev DB ✓ (real-Alembic integration tests) 2) sync refresh/older/manual-persists/content-reset/no sources_meta bump ✓ 3) zip/tar mtime + future→today ✓ 4) LLM date surfaces + cross-check ✓ 5) UI Created/Updated/badge positions ✓ 6) admin editor set+revert round-trip ✓ 7) old-correct-beats-new-similar (defaults & boost-off) + near-tie + `BOR_RECENCY_BOOST=0` byte-identical ✓ 8) full gate ✓ 9) commit/phase-move — left to harness per instructions. - **Notable:** recency default tuned 0.001 → **0.0007** (task 07 step 5 explicitly permits; measured margins recorded in `test_recency_boost.py` docstring). - **Next pending phase:** none — `todo/` holds only this phase.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
Everything is verified green. Final report:
|
||||
|
||||
**Phase 106 — Document dates (final verification pass; all 10 tasks already complete)**
|
||||
|
||||
- Verified all phase artifacts: alembic `0020` (dev DB at `0020`), `doc_dates.py`, git `file_commit_dates`, importer `doc_dates_by_root`/`dates_updated`, both entry-point wirings, date APIs + tree `created_at`/`updated_at`, LLM surfaces (prompt block, `read` line 2, appended `ls` field), `apply_recency_boost` in `retrieve()`, UI columns/badge, admin editor, mock-LLM regex — all present and correct; no defects found, no fixes needed.
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **2299 passed, TOTAL 99%** (>90% ✓)
|
||||
- `uv run pytest tests/e2e/test_document_dates.py -v --no-cov` → **6/6 passed** in isolation (DB up)
|
||||
- 12 regression E2E suites (retrieval_quality, whole_document_context, agent_document_tools, ls_tree_drilldown, read_truncation_cap, kb_tree, kb_tree_nav, document_viewer, edit_summaries, import_documents, sync_button, hidden_folders_toggle, smoke) → **all green in isolation**
|
||||
- `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings**
|
||||
|
||||
**Completion criteria:** 1) non-null `created_at` + 0020 upgrade/downgrade on dev DB ✓ (real-Alembic integration tests) 2) sync refresh/older/manual-persists/content-reset/no sources_meta bump ✓ 3) zip/tar mtime + future→today ✓ 4) LLM date surfaces + cross-check ✓ 5) UI Created/Updated/badge positions ✓ 6) admin editor set+revert round-trip ✓ 7) old-correct-beats-new-similar (defaults & boost-off) + near-tie + `BOR_RECENCY_BOOST=0` byte-identical ✓ 8) full gate ✓ 9) commit/phase-move — left to harness per instructions.
|
||||
|
||||
- **Notable:** recency default tuned 0.001 → **0.0007** (task 07 step 5 explicitly permits; measured margins recorded in `test_recency_boost.py` docstring).
|
||||
- **Next pending phase:** none — `todo/` holds only this phase.
|
||||
@@ -0,0 +1,100 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 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 181 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 154 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 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 316 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 243 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 172 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 327 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4018 15 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
|
||||
@@ -0,0 +1,16 @@
|
||||
All gates green. Task complete — report:
|
||||
|
||||
**Implemented (task 01 — `documents.created_at` / `created_at_manual`)**
|
||||
- `app/models.py::Document` — `created_at` (DateTime tz, NOT NULL, `server_default=func.now()`) + `created_at_manual` (Boolean, NOT NULL, default `False`, server `false`), placed after `indexed_at` with D1–D4 provenance comments (module-header inventory untouched — it doesn't name `indexed_at`, so the conditional didn't apply)
|
||||
- `alembic/versions/0020_documents_created_at.py` — additive, reversible (A13): adds both columns (`now()` / `false` server defaults), downgrade drops both; phase-106 provenance docstring
|
||||
- `tests/integration/test_migration_0020.py` — real-Alembic house pattern: 0019→0020 contract (types/NOT NULL/defaults), pre-0019 row backfill `created_at ≈ upgrade moment` (±5 s window) + flag false, omitted-column server defaults, explicit-flag round-trip, ORM fresh-row defaults + explicit `created_at`/manual `True` round-trip, downgrade→0019 (columns gone, row/content/`indexed_at` survive), upgrade round-trip
|
||||
|
||||
**Results**
|
||||
- `uv run pytest tests/integration/test_migration_0020.py -v` → 4 passed
|
||||
- `uv run pytest` → 2184 passed, 0 failed
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90% gate)
|
||||
- `uv run ruff check . && uv run pyright` → clean (0 errors)
|
||||
- Dev/test DB at `0020` (head); pre-existing rows read `created_at ≈ now()`, `created_at_manual = false`
|
||||
- No deviations; no commit made (harness owns the commit)
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/106_document_dates/02_date_normalization.md`
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 98%]
|
||||
........................ [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 181 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 134 1 99%
|
||||
app/api/git_sources.py 232 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 137 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 140 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 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 316 1 99%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 200 3 98%
|
||||
app/rag/llm.py 243 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 313 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3905 15 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
|
||||
@@ -0,0 +1,18 @@
|
||||
All indexes functionally correct. Everything is complete and verified.
|
||||
|
||||
## Report — Task 02 `02_date_normalization`
|
||||
|
||||
**Implemented**
|
||||
- `app/rag/doc_dates.py` (new, pure stdlib): `FUTURE_SKEW_TOLERANCE` (1 day), `normalize_doc_date` (None→now, naive→UTC-attached, aware→UTC, future-beyond-tolerance→now, full precision kept), `file_mtime_datetime` (stat→aware UTC)
|
||||
- `tests/unit/test_doc_dates.py` (new): 15-test boundary matrix — None→exactly now, naive-is-UTC, aware conversion, 23h/25h future, strict-greater 1-day boundary (±1s), microsecond precision, default/naive `now`, mtime ±1s, future-mtime fidelity, stdlib-only source pin
|
||||
- **Defect fix (pre-existing, found while testing):** dev DB bricked at PG's 1600-attribute cap — the house migration-test round-trips leak dropped-column placeholders (~95/full run; VACUUM can't reclaim; a concurrent pytest run raced it over the cap). Repaired `documents`/`chunks` (LIKE rebuild, FK `ON DELETE CASCADE` restored, alembic back to 0020 head) and added `tests/integration/conftest.py`: session self-heal that rewrites any `public` table with >200 dropped attrs (data-preserving, FK rewire both directions, generated-column-aware, PK renamed back). Verified end-to-end incl. repeated heals
|
||||
|
||||
**Gates (all green)**
|
||||
- `uv run pytest tests/unit/test_doc_dates.py -q` → 15 passed
|
||||
- `uv run pytest` → 2199 passed, exit 0
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 2199 passed, TOTAL **99%** (>90%), `doc_dates.py` 100%
|
||||
- `uv run ruff check . && uv run pyright` → clean, 0 errors
|
||||
|
||||
**Decisions:** self-heal threshold 200 (a tenth of headroom); non-PK index/constraint names after a heal keep PG auto names (nothing references them — DDL is alembic-only). No E2E (that file is task 10's). No commits (harness commits per phase).
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/106_document_dates/03_source_date_extraction.md`
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 98%]
|
||||
....................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 181 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 134 1 99%
|
||||
app/api/git_sources.py 232 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 137 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 140 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 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 316 1 99%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 200 3 98%
|
||||
app/rag/llm.py 243 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 313 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3923 15 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
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
Task 03 is complete and fully verified. Final report:
|
||||
|
||||
## Task 03 — Source date extraction: complete
|
||||
|
||||
**Implemented**
|
||||
- `app/rag/archive_upload.py`: mtime-preserving unpack (D2) — `_unpack_zip` restores each regular file's DOS `date_time`, `_unpack_tar` restores the epoch `mtime` (via `os.utime(ns=…)` after `_write_capped`); dirs/symlinks/hardlinks untouched; all safety checks + zip-bomb cap unchanged; module docstring guarantees list updated.
|
||||
- `scripts/git_sync.py`: new public `file_commit_dates(dest) -> dict[str, datetime]` — ONE `git log --name-only --format=@@%cI` through `run_git` (A11 site), pure `_parse_commit_dates` (first-sighting-wins, POSIX path normalization), fail-soft to `{}` + logged warning on missing dir / `GitSyncError` / parse anomaly; module docstring pins the verified local-full-history vs shallow-tip-date behavior (D10).
|
||||
- Tests: `tests/unit/test_archive_upload_dates.py` (4) + `tests/integration/test_git_file_dates.py` (11, DB-free, git-skip guard): old zip/tar member mtimes restored (±1 s), dir members NOT utime'd; local `clone_or_pull` → true per-file dates (2020/2024 split, subdirectory); `file://` shallow clone → tip date for every file; no-`.git`/missing-dir/empty-repo/git-error/malformed-output → `{}`; parser pins (first-sighting, normalization, ValueError paths).
|
||||
|
||||
**Test / lint / coverage**
|
||||
- `uv run pytest tests/unit/test_archive_upload_dates.py tests/integration/test_git_file_dates.py` → 15 passed
|
||||
- `uv run pytest tests/unit/test_archive_upload.py tests/unit/test_git_sync.py` → 68 passed (safety/git regressions)
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **2214 passed**, TOTAL **99%** (>90% gate; `archive_upload.py` 100%)
|
||||
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
|
||||
|
||||
**Notable decisions**
|
||||
- Deviation from the task's code note: `os.utime(ns=…)` requires **integer nanoseconds** on Python 3.13 (float seconds raises `TypeError`) — seconds are converted explicitly (`int(seconds * 1_000_000_000)`); behavior is otherwise exactly as specified.
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/106_document_dates/04_importer_dates.md`
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 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 181 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 134 1 99%
|
||||
app/api/git_sources.py 232 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 137 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 140 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 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 316 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 200 3 98%
|
||||
app/rag/llm.py 243 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 313 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3929 15 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
|
||||
@@ -0,0 +1,17 @@
|
||||
All gates green. Final report:
|
||||
|
||||
**Implemented (task 04 — importer dates, D2/D4):**
|
||||
- `app/rag/importer.py`: `ImportSummary.dates_updated` + `dates_updated=%d` log term (after `summary_errors`, before `formats`); `import_sources(doc_dates_by_root=…)` (str(root)-keyed, git-only, `None` = mtime fallback, pre-walk untouched); `_index_file(raw_date=…)` — added stores `normalize_doc_date(source)`, updated resets date + `created_at_manual=False`, unchanged refreshes date (may go older, never counts added/updated/pruned), manual rows skipped; module docstring date rule.
|
||||
- `app/api/sync.py`: git rows feed `file_commit_dates(root)` after clone into the map → `import_sources`; success `detail` gains `dates_updated`; docstring step-4 clause.
|
||||
- `scripts/import_docs.py`: `_resolve_sources` returns the date map as 4th element (git rows only; manual/fallback → `{}`); `main` passes it through; docstrings updated. `load_test_kb.py` untouched.
|
||||
- New suites: `tests/unit/test_importer_dates.py` (7-test matrix: added mtime, map-beats-mtime, future→today, unchanged refresh + counter, no-op, manual lock, content-change reset) and `tests/integration/test_importer_dates.py` (real Postgres: backfill-correction 2019 refresh with `sources_meta` version pinned at 7, manual-survive, prune+manual+refresh matrix).
|
||||
- Pins updated in place where they broke: `test_importer.py` log line, `test_sync_api.py` detail + 3 fake signatures, `test_sync_folder_summaries.py` key sets (×2), `test_import_docs_git.py` 4-tuple unpacks + fake (also fixed `_GatedImport`/`failing_import` TypeErrors found in `test_sync_api.py`).
|
||||
|
||||
**Results:**
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 2224 passed, TOTAL 99% (>90%)
|
||||
- `uv run pytest tests/unit/test_importer*.py tests/integration/test_importer*.py tests/integration/test_sync_api.py tests/integration/test_import_docs_git.py -q` → 132 passed
|
||||
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
|
||||
|
||||
**Notes:** 3 uncovered `importer.py` lines are pre-existing defensive branches (limit-break, empty-chunks, empty-`_prune`), untouched here. No commits made (harness owns the atomic commit).
|
||||
|
||||
**Next pending task:** `106_document_dates/05_date_apis.md`
|
||||
@@ -0,0 +1,99 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 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 181 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 134 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 140 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 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 316 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 243 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 313 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3946 15 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
|
||||
@@ -0,0 +1,15 @@
|
||||
All gates verified in the final state. Final report:
|
||||
|
||||
**Task 05 (date APIs) complete — `106_document_dates`**
|
||||
|
||||
- `GET /api/docs` + `GET /api/documents/content` now serve `created_at` (ISO-8601, verbatim); `DocSummary`/`DocContent` schemas extended
|
||||
- NEW admin-only `PATCH /api/documents/date` (D7, phase-57 split): ISO date/datetime set via `normalize_doc_date` (future folds to today) + `created_at_manual=true`; null/absent clears flag only (date stands); 422 malformed (detail names field), 404 unknown pair, 403 anonymous & token user; DB-only, no-LLM (source-pinned)
|
||||
- Tree (D9): `TreeDocRow` 6-tuple; file nodes carry `created_at`; folder/source nodes carry derived subtree-max `updated_at` (pure `_subtree_max`, lexicographic ISO max documented) — `null` for 0-document sources; `list_kb_tree` select/group_by updated
|
||||
- Tests: extended `test_kb_tree_builder.py` (6-tuples, deep-beats-shallow, direct-wins, folder-only threading, None-for-empty, cross-check green with projections); NEW `test_docs_api_dates.py` (full PATCH matrix + reads + tree against real Postgres); updated 4 wire-additive exact-shape pins (`test_docs_api`, `test_document_content`, `test_document_viewer`, `test_auth_api`)
|
||||
|
||||
- `uv run pytest tests/unit/test_kb_tree_builder.py tests/integration/test_docs_api_dates.py -q` → 34 passed
|
||||
- `uv run pytest -q` (full) → all green; `--cov=app` → **TOTAL 99%** (docs.py 99%, schemas.py 100%)
|
||||
- `uv run ruff check . && uv run pyright` → clean (0 errors)
|
||||
|
||||
- Notable: caught a created_at/indexed_at swap in the `TreeDocRow` comprehension (unit test order fixed to canonical `(…, indexed_at, created_at)`); no next-task work started
|
||||
- Next pending: `06_llm_date_surfaces.md`
|
||||
@@ -0,0 +1,100 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 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 181 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 140 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 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 316 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 243 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 327 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3982 15 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
|
||||
@@ -0,0 +1,19 @@
|
||||
All green. Final report:
|
||||
|
||||
**Task 06 — LLM date surfaces (D5) complete**
|
||||
|
||||
Implemented:
|
||||
- `retriever.py`: `d.created_at` added to both raw-SQL projections + both detached `Document` reconstructions (vector path free via ORM)
|
||||
- `prompts.py`: HIGH `<document>` block now carries `date="{created_at:%Y-%m-%d}"` appended after `title` (docstring updated); deflection path untouched
|
||||
- `agent.py`: `read` result carries `date: YYYY-MM-DD` as second line (first line byte-identical — mock header contract, both plain + truncated); `_source_document_rows` → `(path, title, date)` triples; `group_folder_listing`/`render_folder_listing` → file 4-tuples with appended ` | date: …` (source/folder lines unchanged); header + `LS_MAX_FILE_LINES` docstrings updated
|
||||
- `docs.py` cross-check docstring extended; `mock_llm.py` `_DOCUMENT_BLOCK_RE` date-tolerant optional group (`_CATALOG_LINE_RE`/`_READ_RESULT_PREFIX` untouched, verified)
|
||||
- NEW `tests/unit/test_prompts_dates.py` (9) + `tests/integration/test_agent_tools_dates.py` (4); mechanical pin updates in 10 unit/integration test files + 6 E2E suites (read quotes now include the D5 date line; seeds carry fixed `created_at`)
|
||||
|
||||
Gates (all green):
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 2253 passed, TOTAL **99%** (>90%)
|
||||
- `uv run ruff check . && uv run pyright` → clean
|
||||
- E2E in isolation: `test_whole_document_context` (the named smoke) + all 10 phase-10 regression suites + `test_oneshot_llm_retry`/`test_search_tool` → 64 passed
|
||||
|
||||
Decisions: mock's scripted read-quote carries the date line — its trailing newline renders as a markdown soft break (no text between date and content), so rendered-text pins are `date: 2024-06-15` + content[:63]; E2E seeds use explicit fixed `created_at` for byte-stability. `AGENT_TOOLS` teaching copy unchanged (phase 03 convention — task limited docstring updates to the two named sites).
|
||||
|
||||
Next pending task: `.agents/phases/todo/106_document_dates/07_recency_boost.md`
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 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 181 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 140 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 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 316 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 243 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 327 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3982 15 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
|
||||
@@ -0,0 +1,12 @@
|
||||
All green. Final report:
|
||||
|
||||
**Task 07 (recency boost, D6) — complete**
|
||||
|
||||
- `Settings.recency_boost` (default **0.0007**, 0 = byte-identical kill switch, negative fails startup) + `recency_half_life_days` (365, `<= 0` fails startup) — validators + `.env.example` documented
|
||||
- Pure `apply_recency_boost()` (settings defaults, injectable `now`, inputs unmutated, existing 4-key sort) applied **only** in `retrieve()` after `fuse()`; `eval_retrieval` prints the created date + effective score (chat API + script inherit via `retrieve()`)
|
||||
- Fine-line battery (real Postgres, axis vectors): owner scenario A(2020, answers) beats B(yesterday, similar) — pre-boost fused margin **0.002628 ≥ 3×boost (0.0021)**, and with boost OFF; near-tie flips to newer on / stays older off; decay pin (730d → boost ≈ weight·e⁻², C first again); A8 cosine input byte-identical
|
||||
- **Deviation (task step 5's explicit tuning clause):** default tuned 0.001 → **0.0007** — on the k=60 scale a 0.001 boost (+0.000997) would *flip* the pinned owner scenario (max realistic A−B gap 0.00263 < 3×0.001); 0.0007 keeps a 1.25× margin and a comfortable flip (recorded in test docstrings). Twin fixture uses cos 1.0 vs 0.9999 (identical vectors tie `ORDER BY distance` non-deterministically in Postgres — a permanent pin can't rely on that)
|
||||
|
||||
**Results:** `uv run pytest tests/unit/test_retriever_recency.py tests/integration/test_recency_boost.py -q` → 14 passed · `uv run pytest tests/integration/test_name_hit_lexical.py tests/integration/test_chat_api.py -q` → 41 passed · `uv run pytest --cov=app --cov-report=term-missing` → **2270 passed, TOTAL 99%** (>90%) · `uv run ruff check .` clean · `uv run pyright` → 0 errors
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/106_document_dates/08_ui_dates.md`
|
||||
@@ -0,0 +1,100 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 98%]
|
||||
...................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 181 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 154 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 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 316 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 243 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 172 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 327 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4018 15 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
|
||||
@@ -0,0 +1 @@
|
||||
Unit suite fully green. Now the full suite with the coverage gate:
|
||||
@@ -0,0 +1,134 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 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 181 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 154 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 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 316 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 243 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 172 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 327 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4018 15 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
SIM300 [*] Yoda condition detected
|
||||
--> tests/unit/test_sources_dates.py:192:16
|
||||
|
|
||||
190 | comment = view.rfind("<!--", 0, i)
|
||||
191 | end = view.find("-->", comment)
|
||||
192 | assert -1 < comment and "phase 106" in view[comment:end].lower(), (
|
||||
| ^^^^^^^^^^^^
|
||||
193 | f"a phase-106 comment must sit above the {col} header"
|
||||
194 | )
|
||||
|
|
||||
help: Rewrite as `comment > -1`
|
||||
|
|
||||
191 | end = view.find("-->", comment)
|
||||
- assert -1 < comment and "phase 106" in view[comment:end].lower(), (
|
||||
192 + assert comment > -1 and "phase 106" in view[comment:end].lower(), (
|
||||
193 | f"a phase-106 comment must sit above the {col} header"
|
||||
|
|
||||
|
||||
SIM300 [*] Yoda condition detected
|
||||
--> tests/unit/test_sources_dates.py:445:12
|
||||
|
|
||||
443 | comment = css.rfind("/*", 0, i)
|
||||
444 | end = css.find("*/", comment)
|
||||
445 | assert -1 < comment and "phase 106" in css[comment:end].lower(), (
|
||||
| ^^^^^^^^^^^^
|
||||
446 | "a phase-106 comment explains the Updated column + the clamp move"
|
||||
447 | )
|
||||
|
|
||||
help: Rewrite as `comment > -1`
|
||||
|
|
||||
444 | end = css.find("*/", comment)
|
||||
- assert -1 < comment and "phase 106" in css[comment:end].lower(), (
|
||||
445 + assert comment > -1 and "phase 106" in css[comment:end].lower(), (
|
||||
446 | "a phase-106 comment explains the Updated column + the clamp move"
|
||||
|
|
||||
|
||||
Found 2 errors.
|
||||
[*] 2 fixable with the `--fix` option.
|
||||
0 errors, 0 warnings, 0 informations
|
||||
validation FAILED (see output above)
|
||||
@@ -0,0 +1,14 @@
|
||||
All gates green. Task 08 complete.
|
||||
|
||||
**Implemented (task 08 — UI dates, display only):**
|
||||
- `index.html`: `Created` `<th>` between Chunks/Indexed; `Updated` `<th>` between Documents/Description (phase-106 comments)
|
||||
- `sources.js`: `makeRow` Created cell explicit (`fmtDate` text + full-ISO `title`, before Indexed); `renderLevel` row object carries `created_at`; `makeSourceRow`/`makeFolderRow` gain the `updatedTd` (`fmtDate` / `"–"` null idiom + ISO title); module docstring note
|
||||
- `document.js` (shared core, modal + page): `doc-created` badge before `doc-indexed` with full-ISO `title`; `metaBadge` optional title param
|
||||
- `styles.css`: `.doc-created` rule (doc-indexed family, 5.1:1 recorded, D8 provenance); Description clamp moved to `td:nth-child(4)`; Updated-column ink pair (13.8:1) recorded
|
||||
- `tests/unit/test_sources_dates.py` (new, 13 pins): header `<th>` order in both tables, `makeRow` order/ISO title, `updatedTd` null branch in BOTH builders, badge order + template, `.doc-created` rule + clamp move
|
||||
|
||||
**Results:** `uv run pytest --cov=app --cov-report=term-missing` → 2283 passed, TOTAL 99% (>90%); `uv run ruff check .` + `uv run pyright` → clean (fixed 2 SIM300 from last attempt); affected E2E suites green in isolation: `test_kb_tree.py` 8 passed, `test_kb_tree_nav.py`+`test_sync_summary_visibility.py` 7 passed, `test_document_viewer.py` 7 passed; plus a throwaway browser check (since deleted) verified all four surfaces render with correct ISO titles
|
||||
|
||||
**Decisions:** mechanical nth-child pin updates in 3 existing E2E suites + `test_kb_tree_ui.py` slice fixes (Description 3rd→4th, Indexed 5th→6th); no `.doc-indexed` rule existed, so `.doc-created` pins the inherited meta-row color explicitly
|
||||
|
||||
**Next pending task:** `09_date_editor.md`
|
||||
@@ -0,0 +1,100 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 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 181 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 154 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 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 316 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 243 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 172 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 327 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4018 15 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
|
||||
@@ -0,0 +1,19 @@
|
||||
Everything is green. Final verification of the complete gate set is done; here's the report.
|
||||
|
||||
**Implemented (task 09 — admin date editor in the shared viewer core)**
|
||||
- `frontend/assets/document.js`: `renderDocument` gains the admin gate right after the task-08 badge row — `void docAdminReady().then((admin) => { if (admin) wireDateEdit(metaEl, doc); })` (module-cached whoami, no extra request; non-admins keep the byte-identical badge row); new `wireDateEdit` (phase-57 idiom): `Edit date` button (`.doc-date-edit`, `aria-label="Edit creation date: <source>/<path>"`) after the Created badge, in-place swap editor (native `type=date` prefilled with the stored UTC date part, `aria-label="Document creation date"`, Save/Cancel, muted `Revert to sync` → `date: null`, `role=status` line, `role=alert` line); §7.4 lifecycle — controls lock before the `PATCH /api/documents/date`, empty input disables Save (Revert is the only clear), badge re-renders from the response's `res.created_at`, last-announce order, failure → server detail (apiDetail idiom) or canned copy into the alert, input reverts to stored value, re-enable in `finally`; modal gets it free (shared core).
|
||||
- `frontend/assets/styles.css`: eight `.doc-date-*` rules beside the summary-editor family — phase-106 D7 provenance comment with verified pairs (5.1:1 / 5.2:1 / 9.1:1 / 16.7:1), `[hidden]` override, `cursor: wait` disabled idiom, empty live lines hidden, global `:focus-visible` ring.
|
||||
- `tests/unit/test_date_editor.py` (new, 14 source-level pins: gate, single-endpoint cross-file check vs `app/api/docs.py`, response-driven render, `date: null` clear, §7.4 branches, aria strings, CSS).
|
||||
|
||||
**Results**
|
||||
- `uv run pytest tests/unit/test_date_editor.py tests/unit/test_sources_dates.py tests/unit/test_summary_edit_ui.py tests/unit/test_document_viewer.py -q` → 77 passed
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 2299 passed, TOTAL 99% (>90%)
|
||||
- `uv run ruff check . && uv run pyright` → clean (0 errors)
|
||||
- Regression E2E in isolation: `test_summary_in_viewer.py` 4 passed; `test_document_viewer.py` + `test_document_summaries.py` 9 passed
|
||||
|
||||
**Decisions**
|
||||
- Success collapses the editor after a 2 s "confirmation stays readable" beat (phase-57 precedent); `closeEditor` is idempotent to the stale beat.
|
||||
- JSON response named `res` (house `r` = fetch Response) so the badge re-render literally references `res.created_at` per the task.
|
||||
- Fixed phase-57 pin in `test_summary_edit_ui.py`: its whole-file gate `find` now hits the new date-editor gate first, so the summary gate is searched after the section mount (same intent, updated comment). No `app/` code (endpoint shipped in task 05).
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/106_document_dates/10_e2e_document_dates.md`
|
||||
@@ -0,0 +1,100 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 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 181 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 154 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 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 316 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 243 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 172 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 327 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4018 15 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
|
||||
@@ -0,0 +1,16 @@
|
||||
All gates green. Final report:
|
||||
|
||||
**Task 10 complete — phase 106 E2E + full gate**
|
||||
|
||||
- Implemented `tests/e2e/test_document_dates.py` — 6 Playwright tests, 1:1 with the contract: import date-landing (D2/D3/D9 incl. future-fold + subtree-max `updated_at`), UI columns (`Created` before `Indexed`; `Updated` between `Documents`/`Description`), modal `Created` badge DOM-before `Indexed`, the owner scenario (older-correct doc = first chip over the newer similar one, real retriever + default boost), admin date edit → re-import survival (manual flag) → Revert-to-sync → re-source, and the anonymous gate + editor a11y.
|
||||
- Fixture wording tuned and recorded in the docstring: mock md5-bucket geometry is deterministic — draft pinned at vector rank 4 / lexical rank 2 (cos 0.1443 vs 0.1875), fused margin 0.000334 with the full 0.0007 zero-age boost on the newer doc.
|
||||
|
||||
Results (exact):
|
||||
- `uv run pytest tests/e2e/test_document_dates.py -v --no-cov` → 6 passed (isolation, DB up)
|
||||
- All 10 regression E2E suites green in isolation (retrieval_quality, whole_document_context, agent_document_tools, ls_tree_drilldown, read_truncation_cap, kb_tree, kb_tree_nav, document_viewer, edit_summaries, import_documents, sync_button, hidden_folders_toggle, smoke)
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 2299 passed, TOTAL 99% (>90%)
|
||||
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
|
||||
|
||||
Decisions: (1) header assertions use `text_content()` (th's are CSS-uppercased); (2) created-cell text assertion is timezone-aware (browser renders local time; the ISO `title` is the pinned surface); (3) keyboard-traversal pinned through the editor's buttons — headless-CDP doesn't perform native focus moves off `type=date` inputs (harness artifact, documented). (4) No commit/phase-move per harness rules — left in the working tree for the harness's atomic phase commit.
|
||||
|
||||
Next pending task: none — this is the last task of phase 106 (`todo/` will be empty once the harness moves the phase to `complete/`).
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 21%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 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 181 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 156 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 154 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 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 316 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 243 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 172 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 327 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4018 15 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
|
||||
@@ -45,6 +45,8 @@ BOR_EMBED_BATCH_SIZE=16
|
||||
BOR_HYBRID_VECTOR_CANDIDATES=100 # cosine list width for the fusion
|
||||
BOR_HYBRID_LEXICAL_CANDIDATES=30 # FTS list width for the fusion
|
||||
BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant
|
||||
BOR_RECENCY_BOOST=0.0007 # max additive fused-score bonus a zero-age doc gets (phase 106); 0 = off (pre-phase ranking byte-identical)
|
||||
BOR_RECENCY_HALF_LIFE_DAYS=365 # recency decay timescale, days (phase 106); the boost decays by e^-1 (~0.37x) per timescale of age
|
||||
|
||||
# --- Agent document tools (grounded turns may extend context: ls / read / grep) ---
|
||||
# BOR_AGENT_MAX_ROUNDS=10 # hard cap on agent tool rounds per turn (0 = no tools)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""documents.created_at + created_at_manual: sourced document creation dates (phase 106)
|
||||
|
||||
Revision ID: 0020
|
||||
Revises: 0019
|
||||
Create Date: 2026-09-13
|
||||
|
||||
Phase 106 (document dates end to end: sourced at sync, displayed,
|
||||
editable, recency-weighted in retrieval): every ``documents`` row carries
|
||||
a NOT-NULL **creation date** — sourced at sync time (the git last-commit
|
||||
date for git sources, the file mtime for local dirs and unpacked uploads
|
||||
— D2) and normalized by ``app.rag.doc_dates.normalize_doc_date``
|
||||
(undetermined or future → today; stored as UTC ``timestamptz`` at full
|
||||
precision — D3). It is DISTINCT from ``indexed_at`` (the INDEX time,
|
||||
untouched — the two concepts stay separate):
|
||||
|
||||
* ``documents.created_at`` — TIMESTAMP WITH TIME ZONE NOT NULL with
|
||||
server default ``now()``: pre-phase-106 rows backfill to the migration
|
||||
moment (≈ today — the owner's instruction, D1: "the timestamp can't be
|
||||
null so just set it to today's date during the migration") and the
|
||||
next sync replaces them with the real sourced date (the importer's
|
||||
unchanged path, phase 106 task 04 — a sync may move a date OLDER, D4).
|
||||
* ``documents.created_at_manual`` — BOOLEAN NOT NULL with server default
|
||||
``false``: ``true`` only while ``created_at`` is the OWNER'S
|
||||
correction (D1 — the ``folder_summaries.manually_edited`` phase-97
|
||||
precedent). Set ONLY by ``PATCH /api/documents/date`` (phase 106 task
|
||||
05); the sync-time importer SKIPS the refresh on a manual row (the
|
||||
correction survives syncs) and a content change RESETS both the date
|
||||
and the flag (a new version = a new date — D4).
|
||||
|
||||
One additive, fully reversible migration (A13); no other schema change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0020"
|
||||
down_revision = "0019"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"documents",
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"documents",
|
||||
sa.Column(
|
||||
"created_at_manual",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("false"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Both columns are the only 0020 artefacts — dropping them leaves
|
||||
# 0019's schema byte-identical (A13, fully reversible).
|
||||
op.drop_column("documents", "created_at_manual")
|
||||
op.drop_column("documents", "created_at")
|
||||
+185
-31
@@ -10,6 +10,14 @@ update or clear ``documents.summary`` and re-embed the ``is_summary``
|
||||
chunk (embed first, mutate second — a failed LLM call leaves the row and
|
||||
chunk untouched; the content chunks are never re-embedded, D4).
|
||||
|
||||
PATCH /api/documents/date — the admin document-date editor (phase 106,
|
||||
D7): set the owner's corrected ``documents.created_at`` (normalized
|
||||
through ``app.rag.doc_dates.normalize_doc_date`` — a manually set future
|
||||
date folds to today, D3) + flag it manual, or clear the manual flag
|
||||
(null date — the stored date stands until the next sync). A pure DB
|
||||
write: NO LLM/embedding call — a date is never embedded (the
|
||||
deliberate contrast with the phase-57 ``is_summary`` re-embed).
|
||||
|
||||
GET /api/docs/tree — the admin's full recursive KB tree in one fetch
|
||||
(phase 97, task 02): the same drill-down tree the agent's ``ls``
|
||||
walks, with the file metadata the RAG view's rows and stat cards need
|
||||
@@ -40,10 +48,13 @@ from app.core.auth import require_admin, require_user
|
||||
from app.db import get_db
|
||||
from app.models import Chunk, Document, FolderSummary
|
||||
from app.rag.agent import list_source_names
|
||||
from app.rag.doc_dates import normalize_doc_date
|
||||
from app.rag.folder_summaries import MIN_DOCS_PER_FOLDER, folder_of
|
||||
from app.rag.importer import match_extension
|
||||
from app.rag.llm import EmbeddingError, LLMClient
|
||||
from app.schemas import (
|
||||
DateResult,
|
||||
DateUpdate,
|
||||
DocContent,
|
||||
DocList,
|
||||
DocSummary,
|
||||
@@ -101,10 +112,18 @@ def list_indexed_documents(
|
||||
Document.path,
|
||||
Document.title,
|
||||
func.count(Chunk.id).label("chunks"),
|
||||
Document.created_at,
|
||||
Document.indexed_at,
|
||||
)
|
||||
.outerjoin(Chunk, Chunk.document_id == Document.id)
|
||||
.group_by(Document.id, Document.source, Document.path, Document.title, Document.indexed_at)
|
||||
.group_by(
|
||||
Document.id,
|
||||
Document.source,
|
||||
Document.path,
|
||||
Document.title,
|
||||
Document.created_at,
|
||||
Document.indexed_at,
|
||||
)
|
||||
.order_by(Document.source, Document.path)
|
||||
).all()
|
||||
return DocList(
|
||||
@@ -115,6 +134,7 @@ def list_indexed_documents(
|
||||
path=row.path,
|
||||
title=row.title,
|
||||
chunks=row.chunks,
|
||||
created_at=row.created_at.isoformat(),
|
||||
indexed_at=row.indexed_at.isoformat(),
|
||||
)
|
||||
for row in rows
|
||||
@@ -157,6 +177,7 @@ def get_document_content(
|
||||
title=doc.title,
|
||||
format=doc_format(doc.path, get_settings().import_extension_set),
|
||||
summary=doc.summary,
|
||||
created_at=doc.created_at.isoformat(),
|
||||
content=doc.content,
|
||||
indexed_at=doc.indexed_at.isoformat(),
|
||||
chunks=chunks,
|
||||
@@ -231,6 +252,75 @@ async def update_document_summary(
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/documents/date", response_model=DateResult)
|
||||
def update_document_date(
|
||||
payload: DateUpdate,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
_admin: None = Depends(require_admin), # noqa: B008
|
||||
) -> DateResult:
|
||||
"""Set or clear the owner's correction of a document's creation date.
|
||||
|
||||
Admin-only (phase 106, D7 — the phase-57 split): the document
|
||||
viewer itself stays user-gated (``/documents/content`` — admin OR
|
||||
token holder); only this edit affordance is admin-gated. DB-only
|
||||
(the ``/documents/content`` row-lookup rule): the ``(source, path)``
|
||||
pair is looked up as a row — an unknown pair, including traversal
|
||||
strings such as ``../../etc/passwd``, is simply not a row (→ 404
|
||||
``document not found``), and there is no filesystem access. NO
|
||||
LLM/embedding call — a date is never embedded (no chunk, no
|
||||
retrieval role) — the deliberate contrast with the phase-57
|
||||
``is_summary`` re-embed in :func:`update_document_summary`.
|
||||
|
||||
* **Set** (``date`` present) — ``datetime.fromisoformat`` accepts a
|
||||
bare ``YYYY-MM-DD`` (midnight) and full ISO datetimes; a
|
||||
MALFORMED value 422s here (the model field is an unconstrained
|
||||
``str | None`` on purpose, so the detail can name the field). The
|
||||
parse goes through
|
||||
:func:`app.rag.doc_dates.normalize_doc_date` (D3 — the single
|
||||
choke point: naive → UTC, aware → converted, a manually set
|
||||
FUTURE date also folds to today — consistency with the sourced
|
||||
path) and stores ``created_at`` + ``created_at_manual = True``
|
||||
(the owner's correction — the sync-time importer then SKIPS the
|
||||
refresh on this row, D1/D4).
|
||||
* **Clear** (``date`` null/absent — the "revert to sync"
|
||||
operation) — ``created_at_manual = False`` ONLY: the stored date
|
||||
stands until the next sync refreshes it (the API cannot
|
||||
re-read the source, D7).
|
||||
|
||||
The response echoes the stored state — ``created_at`` (ISO-8601)
|
||||
+ ``created_at_manual`` — the viewer re-renders its Created badge
|
||||
from it (no second fetch).
|
||||
"""
|
||||
doc = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == payload.source, Document.path == payload.path
|
||||
)
|
||||
)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=404, detail="document not found")
|
||||
if payload.date:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(payload.date)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="date must be an ISO date or datetime (e.g. 2024-06-15)",
|
||||
) from None
|
||||
doc.created_at = normalize_doc_date(parsed)
|
||||
doc.created_at_manual = True
|
||||
else:
|
||||
# The CLEAR (D7): drop the manual flag only — the stored date
|
||||
# stands until the next sync refreshes it.
|
||||
doc.created_at_manual = False
|
||||
db.commit()
|
||||
return DateResult(
|
||||
source=doc.source,
|
||||
path=doc.path,
|
||||
created_at=doc.created_at.isoformat(),
|
||||
created_at_manual=doc.created_at_manual,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/folders/summary", response_model=FolderSummaryResult)
|
||||
def update_folder_summary(
|
||||
payload: FolderSummaryUpdate,
|
||||
@@ -305,16 +395,17 @@ def update_folder_summary(
|
||||
|
||||
|
||||
#: One catalogue row the tree builder consumes:
|
||||
#: ``(source, path, title, chunks, indexed_at)`` — the ``GET /api/docs``
|
||||
#: query's columns minus the document ``id`` (the tree has no document
|
||||
#: ids), in the same ``(source, path)`` order; ``indexed_at`` is the
|
||||
#: ISO-8601 string the endpoint converts (the builder stays pure over
|
||||
#: plain types — unit-testable without a DB).
|
||||
TreeDocRow = tuple[str, str, str, int, str]
|
||||
#: ``(source, path, title, chunks, indexed_at, created_at)`` — the
|
||||
#: ``GET /api/docs`` query's columns minus the document ``id`` (the
|
||||
#: tree has no document ids), in the same ``(source, path)`` order;
|
||||
#: ``indexed_at`` / ``created_at`` are the ISO-8601 strings the
|
||||
#: endpoint converts (the builder stays pure over plain types —
|
||||
#: unit-testable without a DB).
|
||||
TreeDocRow = tuple[str, str, str, int, str, str]
|
||||
|
||||
#: One of a source's file rows, already source-scoped:
|
||||
#: ``(path, title, chunks, indexed_at)``.
|
||||
TreeFileRow = tuple[str, str, int, str]
|
||||
#: ``(path, title, chunks, indexed_at, created_at)``.
|
||||
TreeFileRow = tuple[str, str, int, str, str]
|
||||
|
||||
|
||||
def _folder_counts(
|
||||
@@ -339,13 +430,13 @@ def _folder_counts(
|
||||
own descendants, or the file wearing its name, exist).
|
||||
"""
|
||||
folders: set[str] = set()
|
||||
for path, _title, _chunks, _indexed_at in rows:
|
||||
for path, _title, _chunks, _indexed_at, _created_at in rows:
|
||||
folder = folder_of(path)
|
||||
while folder:
|
||||
folders.add(folder)
|
||||
folder = folder_of(folder)
|
||||
counts: dict[str, int] = {folder: 0 for folder in folders}
|
||||
for path, _title, _chunks, _indexed_at in rows:
|
||||
for path, _title, _chunks, _indexed_at, _created_at in rows:
|
||||
if path in folders:
|
||||
counts[path] += 1
|
||||
folder = folder_of(path)
|
||||
@@ -381,27 +472,60 @@ def _level_children(
|
||||
flag: the recursive count ≥ :data:`MIN_DOCS_PER_FOLDER` AND no
|
||||
stored ``folder_summaries`` row for ``(source, sub)`` — the same
|
||||
rule the source node applies (see :func:`build_kb_tree`).
|
||||
|
||||
And, since phase 106 (D9), each folder node carries ``updated_at``
|
||||
= the subtree's MAX document ``created_at``: the max over this
|
||||
folder's direct files' dates and its subfolder children's (already
|
||||
recursive) ``updated_at`` values, via :func:`_subtree_max`.
|
||||
"""
|
||||
children: list[KbTreeFolder | KbTreeFile] = []
|
||||
for sub in sorted(g for g in folders if folder_of(g) == folder):
|
||||
sub_children = _level_children(source, sub, folders, counts, rows, summaries)
|
||||
children.append(
|
||||
KbTreeFolder(
|
||||
path=sub,
|
||||
documents=counts[sub],
|
||||
updated_at=_subtree_max(sub_children),
|
||||
summary=summaries.get((source, sub)),
|
||||
summary_pending=counts[sub] >= MIN_DOCS_PER_FOLDER
|
||||
and (source, sub) not in summaries,
|
||||
children=_level_children(source, sub, folders, counts, rows, summaries),
|
||||
children=sub_children,
|
||||
)
|
||||
)
|
||||
for path, title, chunks, indexed_at in rows:
|
||||
for path, title, chunks, indexed_at, created_at in rows:
|
||||
if folder_of(path) == folder:
|
||||
children.append(
|
||||
KbTreeFile(path=path, title=title, chunks=chunks, indexed_at=indexed_at)
|
||||
KbTreeFile(
|
||||
path=path,
|
||||
title=title,
|
||||
chunks=chunks,
|
||||
created_at=created_at,
|
||||
indexed_at=indexed_at,
|
||||
)
|
||||
)
|
||||
return children
|
||||
|
||||
|
||||
def _subtree_max(children: Sequence[KbTreeFolder | KbTreeFile]) -> str | None:
|
||||
"""A node's ``updated_at`` (phase 106, D9): the subtree's MAX
|
||||
document ``created_at``, computed from the node's direct children —
|
||||
files contribute their ``created_at``, subfolder nodes contribute
|
||||
their (already recursive) ``updated_at``.
|
||||
|
||||
All values share the same UTC ``isoformat()`` shape (the endpoint
|
||||
converts every ``created_at`` before the builder runs), so the
|
||||
LEXICOGRAPHIC max is the chronological max — ISO-8601 strings of
|
||||
one offset order by instant. ``None`` when no child carries a date
|
||||
(a node with no documents at all — the 0-document source).
|
||||
"""
|
||||
dates = [
|
||||
child.created_at if child.kind == "file" else child.updated_at
|
||||
for child in children
|
||||
]
|
||||
dates = [d for d in dates if d is not None]
|
||||
return max(dates) if dates else None
|
||||
|
||||
|
||||
def build_kb_tree(
|
||||
names: Sequence[str],
|
||||
doc_rows: Sequence[TreeDocRow],
|
||||
@@ -413,8 +537,9 @@ def build_kb_tree(
|
||||
|
||||
*names* — the registry source names in order (``app.rag.agent.
|
||||
list_source_names`` — deduped, registry order). *doc_rows* — the
|
||||
catalogue ``(source, path, title, chunks, indexed_at)`` tuples in
|
||||
the ``GET /api/docs`` query order (``source, path``). *summaries* —
|
||||
catalogue ``(source, path, title, chunks, indexed_at, created_at)``
|
||||
tuples (both stamps ISO-8601) in the ``GET /api/docs`` query order
|
||||
(``source, path``). *summaries* —
|
||||
``{(source, folder_path): summary}`` over the stored
|
||||
``folder_summaries`` rows (``folder_path = ""`` = the source root;
|
||||
rows for sources the tree does not list are simply never
|
||||
@@ -433,13 +558,21 @@ def build_kb_tree(
|
||||
source node IS the root); direct subfolders only, in path
|
||||
(sorted) order, a folder existing only under the phase-94
|
||||
existence rule; ``documents`` = the recursive subtree count;
|
||||
``summary`` = the stored row (AI OR manual — any row) or null;
|
||||
``children`` = the folder's own subfolders + direct files, same
|
||||
shape.
|
||||
``updated_at`` (phase 106, D9) = the subtree's MAX document
|
||||
``created_at`` — DERIVED as the builder recurses (the max of the
|
||||
direct files' dates and the children's ``updated_at`` values via
|
||||
:func:`_subtree_max`; the ISO-8601 strings share one
|
||||
``isoformat()`` shape, so the lexicographic max is the
|
||||
chronological one), never stored — ``null`` when the node has no
|
||||
documents at all; ``summary`` = the stored row (AI OR manual —
|
||||
any row) or null; ``children`` = the folder's own subfolders +
|
||||
direct files, same shape.
|
||||
* **File nodes** — direct files only, in input (catalog) order;
|
||||
``path`` source-relative; ``title`` / ``chunks`` / ``indexed_at``
|
||||
verbatim from the catalogue row. File nodes carry NO pending
|
||||
flag (the file table has no description column).
|
||||
``path`` source-relative; ``title`` / ``chunks`` / ``created_at``
|
||||
(phase 106) / ``indexed_at`` verbatim from the catalogue row.
|
||||
File nodes carry NO pending flag (the file table has no
|
||||
description column) and no ``updated_at`` (a file's date IS its
|
||||
``created_at``).
|
||||
* **Pending** — ``summary_pending`` on the SOURCE and every FOLDER
|
||||
node (phase 98, decision D3 — ONE concept): true iff the node's
|
||||
recursive ``documents`` count ≥
|
||||
@@ -460,13 +593,20 @@ def build_kb_tree(
|
||||
existence / count rules, so — for a single-source dataset — its
|
||||
level equals :func:`app.rag.agent.group_folder_listing`'s output
|
||||
(same subfolder ``(path, count, summary)`` triples in order, same
|
||||
file ``(path, title)`` pairs in order — the "UI shows what the
|
||||
agent sees" cross-check, unit-pinned at the root and a nested
|
||||
level).
|
||||
file ``(source, path, title, date)`` 4-tuples in order — phase
|
||||
106, D5: the agent's file lines carry the appended ``date`` field
|
||||
and the tree's file nodes carry ``created_at``; the "UI shows
|
||||
what the agent sees" cross-check, unit-pinned at the root and a
|
||||
nested level, compares the extended shapes).
|
||||
|
||||
D9 (phase 106): ``updated_at`` on every SOURCE and FOLDER node is
|
||||
the subtree's MAX document ``created_at`` — derived, never stored;
|
||||
``None`` for a node with no documents at all (the registered
|
||||
0-document source).
|
||||
"""
|
||||
by_source: dict[str, list[TreeFileRow]] = {}
|
||||
for source, path, title, chunks, indexed_at in doc_rows:
|
||||
by_source.setdefault(source, []).append((path, title, chunks, indexed_at))
|
||||
for source, path, title, chunks, indexed_at, created_at in doc_rows:
|
||||
by_source.setdefault(source, []).append((path, title, chunks, indexed_at, created_at))
|
||||
tree: list[KbTreeSource] = []
|
||||
listed: set[str] = set()
|
||||
for name in names:
|
||||
@@ -499,14 +639,20 @@ def _source_node(
|
||||
``summary_pending`` (phase 98, D3): the whole-source count ≥
|
||||
:data:`MIN_DOCS_PER_FOLDER` AND no stored ``(source, "")`` row —
|
||||
the source-root arm of the rule :func:`build_kb_tree` documents.
|
||||
|
||||
``updated_at`` (phase 106, D9): the source's subtree max —
|
||||
:func:`_subtree_max` over the root level's children; ``None`` for a
|
||||
0-document source (no children, no dates).
|
||||
"""
|
||||
folders, counts = _folder_counts(rows)
|
||||
children = _level_children(source, "", folders, counts, rows, summaries)
|
||||
return KbTreeSource(
|
||||
name=source,
|
||||
documents=len(rows),
|
||||
updated_at=_subtree_max(children),
|
||||
summary=summaries.get((source, "")),
|
||||
summary_pending=len(rows) >= MIN_DOCS_PER_FOLDER and (source, "") not in summaries,
|
||||
children=_level_children(source, "", folders, counts, rows, summaries),
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
@@ -540,15 +686,23 @@ def list_kb_tree(
|
||||
Document.path,
|
||||
Document.title,
|
||||
func.count(Chunk.id).label("chunks"),
|
||||
Document.created_at,
|
||||
Document.indexed_at,
|
||||
)
|
||||
.outerjoin(Chunk, Chunk.document_id == Document.id)
|
||||
.group_by(Document.id, Document.source, Document.path, Document.title, Document.indexed_at)
|
||||
.group_by(
|
||||
Document.id,
|
||||
Document.source,
|
||||
Document.path,
|
||||
Document.title,
|
||||
Document.created_at,
|
||||
Document.indexed_at,
|
||||
)
|
||||
.order_by(Document.source, Document.path)
|
||||
).all()
|
||||
doc_rows: list[TreeDocRow] = [
|
||||
(source, path, title, chunks, indexed_at.isoformat())
|
||||
for source, path, title, chunks, indexed_at in rows
|
||||
(source, path, title, chunks, indexed_at.isoformat(), created_at.isoformat())
|
||||
for source, path, title, chunks, created_at, indexed_at in rows
|
||||
]
|
||||
summaries: dict[tuple[str, str], str] = {
|
||||
(source, folder_path): summary
|
||||
|
||||
+18
-4
@@ -36,9 +36,13 @@ decisions):
|
||||
4. ``import_sources(..., prune=True)`` over the single combined list
|
||||
(git checkouts + local dirs), honoring each row's ``ignore_paths``
|
||||
(phase 89 — the per-root ignore map is built in the same per-row
|
||||
loop as the source list) and its ``include_hidden`` flag (phase 105
|
||||
— the per-root hidden-folders map, same per-row construction) —
|
||||
prune so files deleted upstream, out of
|
||||
loop as the source list), its ``include_hidden`` flag (phase 105 —
|
||||
the per-root hidden-folders map, same per-row construction), and
|
||||
feeding each git checkout's per-file last-commit dates (phase 106,
|
||||
D2 — the per-root date map, ``file_commit_dates`` after the clone,
|
||||
same per-row construction; local rows contribute nothing and take
|
||||
the importer's mtime fallback) — prune so files deleted upstream,
|
||||
out of
|
||||
a local dir, or newly matching an ignore pattern leave the index
|
||||
(pruning covers the union; the CLI's no-prune default is unchanged);
|
||||
5. when the import changed the KB (added + updated > 0),
|
||||
@@ -117,7 +121,7 @@ from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient, check_models
|
||||
from app.rag.overview import regenerate_overview
|
||||
from app.rag.sources_meta import bump_sources_version, current_sources_version
|
||||
from scripts.git_sync import GitSyncError, clone_or_pull
|
||||
from scripts.git_sync import GitSyncError, clone_or_pull, file_commit_dates
|
||||
from scripts.import_docs import repo_name
|
||||
|
||||
logger = logging.getLogger("app.api.sync")
|
||||
@@ -286,9 +290,17 @@ async def _run_sync() -> None:
|
||||
sources: list[Path] = []
|
||||
ignore_by_root: dict[str, list[str]] = {}
|
||||
include_hidden_by_root: dict[str, bool] = {}
|
||||
doc_dates_by_root: dict[str, dict[str, datetime]] = {}
|
||||
for row in rows:
|
||||
if row.kind == "git":
|
||||
root = clone_or_pull(row.url, sources_root / repo_name(row.url))
|
||||
# Phase 106 (D2): the checkout's per-file last-commit
|
||||
# dates, keyed by the SAME root string the importer
|
||||
# sees (local-path checkouts → true per-file dates,
|
||||
# shallow URL checkouts → the tip date, D10). Local
|
||||
# rows contribute nothing — the importer's mtime
|
||||
# fallback applies to them.
|
||||
doc_dates_by_root[str(root)] = file_commit_dates(root)
|
||||
else:
|
||||
# kind=local — the stored expanded path (phase 38 also
|
||||
# mirrors it in the NOT-NULL ``url`` location column, the
|
||||
@@ -329,6 +341,7 @@ async def _run_sync() -> None:
|
||||
summary: ImportSummary = await import_sources(
|
||||
sources, llm, prune=True, progress=_hook, ignore_by_root=ignore_by_root,
|
||||
include_hidden_by_root=include_hidden_by_root,
|
||||
doc_dates_by_root=doc_dates_by_root,
|
||||
)
|
||||
overview = False
|
||||
if summary.added + summary.updated > 0:
|
||||
@@ -431,6 +444,7 @@ async def _run_sync() -> None:
|
||||
"chunks": summary.chunks,
|
||||
"summaries": summary.summaries,
|
||||
"summary_errors": summary.summary_errors,
|
||||
"dates_updated": summary.dates_updated,
|
||||
"overview": overview,
|
||||
"sources_version": sources_version,
|
||||
}
|
||||
|
||||
@@ -185,6 +185,28 @@ class Settings(BaseSettings):
|
||||
hybrid_vector_candidates: int = 100
|
||||
hybrid_lexical_candidates: int = 30
|
||||
rrf_k: int = 60
|
||||
#: Recency boost on the RRF-fused retrieval score (phase 106, D6): the
|
||||
#: MAXIMUM additive score a zero-age document gets —
|
||||
#: ``fused + recency_boost * exp(-age_days / recency_half_life_days)``
|
||||
#: (``app.rag.retriever.apply_recency_boost``, applied in
|
||||
#: ``retrieve()`` after ``fuse()``). ``0`` = off — the pre-phase
|
||||
#: ranking is byte-identical (the kill switch); negative values fail
|
||||
#: startup loudly (the ``agent_max_rounds`` validator pattern).
|
||||
#: 0.0007 ≈ a 2-3 rank head start on a 60+ RRF scale (rank 1 vs 2
|
||||
#: in one list differs by ~0.00026, rank 1 vs 10 by ~0.0021) —
|
||||
#: enough to break near-ties toward the newer document, far below
|
||||
#: the gap between a document that answers and one that merely
|
||||
#: resembles (the phase-106 fine-line battery pins the measured
|
||||
#: margin). The design starting point was 0.001; the battery's
|
||||
#: 3×-margin requirement tuned it down here (task 07 step 5) — on
|
||||
#: the k=60 scale a 0.001 boost would flip the pinned owner
|
||||
#: scenario (older-correct vs newer-similar).
|
||||
recency_boost: float = 0.0007
|
||||
#: Age (days) over which the recency boost decays (phase 106, D6):
|
||||
#: the boost multiplies by ``e**-1`` ≈ 0.37 per ``recency_half_life_days``
|
||||
#: of document age (full weight at age 0, ``weight/e`` at one
|
||||
#: half-life). ``<= 0`` fails startup loudly (same validator family).
|
||||
recency_half_life_days: int = 365
|
||||
|
||||
# --- Admin & sign-in (phase 16; A10 revised 2026-08-22) ---
|
||||
# Single-admin auth via a signed session cookie (Starlette
|
||||
@@ -351,6 +373,25 @@ class Settings(BaseSettings):
|
||||
raise ValueError("history_max_chars must be >= 0 (chars)")
|
||||
return v
|
||||
|
||||
@field_validator("recency_boost")
|
||||
@classmethod
|
||||
def _recency_boost_non_negative(cls, v: float) -> float:
|
||||
"""``0`` is the kill switch (pre-phase ranking byte-identical) — a
|
||||
negative boost would demote fresh documents, the exact opposite
|
||||
of D6 (the ``agent_max_rounds`` pattern, phase 106)."""
|
||||
if v < 0:
|
||||
raise ValueError("recency_boost must be >= 0 (0 = off)")
|
||||
return v
|
||||
|
||||
@field_validator("recency_half_life_days")
|
||||
@classmethod
|
||||
def _recency_half_life_days_positive(cls, v: int) -> int:
|
||||
"""``0``/negative would divide the decay exponent by zero — fail
|
||||
loud at startup (the ``agent_max_rounds`` pattern, phase 106)."""
|
||||
if v <= 0:
|
||||
raise ValueError("recency_half_life_days must be > 0 (days)")
|
||||
return v
|
||||
|
||||
@field_validator("docs_branch", "docs_base_branch")
|
||||
@classmethod
|
||||
def _docs_branch_tokens(cls, v: str, info: ValidationInfo) -> str:
|
||||
|
||||
@@ -110,6 +110,27 @@ class Document(Base):
|
||||
content: Mapped[str] = mapped_column(Text) # full markdown — the RAG context
|
||||
content_hash: Mapped[str] = mapped_column(String(64), index=True) # sha256 for change detection
|
||||
indexed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
#: The document's CREATION date (phase 106, D1/D2/D3) — sourced at
|
||||
#: sync time (git last-commit date for git sources, file mtime for
|
||||
#: local dirs / unpacked uploads), normalized by
|
||||
#: :func:`app.rag.doc_dates.normalize_doc_date` (undetermined or
|
||||
#: future → today; UTC). NOT NULL: pre-phase-106 rows backfill to
|
||||
#: the migration moment (≈ today — the owner's instruction) and the
|
||||
#: next sync refreshes them (the importer's unchanged path,
|
||||
#: task 04 — a sync may move a date OLDER, D4). Distinct from
|
||||
#: ``indexed_at`` (the INDEX time, untouched).
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
#: True only while ``created_at`` is the OWNER'S correction (phase
|
||||
#: 106, D1 — the ``folder_summaries.manually_edited`` phase-97
|
||||
#: precedent): set ONLY by ``PATCH /api/documents/date``
|
||||
#: (task 05); the sync-time importer SKIPS the refresh on a manual
|
||||
#: row (the correction survives syncs, D4) and a content change
|
||||
#: RESETS both the date and the flag (a new version = a new date).
|
||||
created_at_manual: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default=text("false"), nullable=False
|
||||
)
|
||||
#: Lite-model summary, phase 30. Natural-language summary of the
|
||||
#: document (non-markdown A9 docs only, generated at import time by the
|
||||
#: aipi ``lite`` model). NULL for markdown docs, pre-phase-30 rows, and
|
||||
|
||||
+60
-36
@@ -70,9 +70,11 @@ task 04):
|
||||
(the count is the subfolder's recursive subtree — every document
|
||||
whose path equals the folder or starts with ``folder + "/"``, the
|
||||
same set the sync-time folder summary describes — and the file
|
||||
lines ``source: X | path: Y | title: Z`` (the canonical
|
||||
``read``/``grep`` identity — the phase-63 labeled format,
|
||||
unchanged) in path order (``GET /api/docs`` order), capped at
|
||||
lines ``source: X | path: Y | title: Z | date: YYYY-MM-DD`` (the
|
||||
canonical ``read``/``grep`` identity — the phase-63 labeled format —
|
||||
plus the phase-106 D5 ``date`` field APPENDED after ``title``; only
|
||||
FILE lines carry a date — source/folder lines are not documents)
|
||||
in path order (``GET /api/docs`` order), capped at
|
||||
:data:`LS_MAX_FILE_LINES` lines + one deterministic grep-pointer
|
||||
note for the rest (a 500-file folder costs 50 lines, never 500);
|
||||
a ``source/folder`` ``path``: that folder's subfolders + own file
|
||||
@@ -463,7 +465,8 @@ NO_DOCUMENT_DID_YOU_MEAN_MANY = (
|
||||
SUGGESTION_LIMIT = 3
|
||||
|
||||
#: The drill-down ``ls`` file-line cap (phase 94, task 03): a folder's
|
||||
#: own files list at most this many ``source: X | path: Y | title: Z``
|
||||
#: own files list at most this many
|
||||
#: ``source: X | path: Y | title: Z | date: YYYY-MM-DD``
|
||||
#: lines (path order), then one deterministic grep-pointer note — a
|
||||
#: 500-file folder costs the model 50 lines + the note, never 500.
|
||||
#: Pinned module constant (no env var — the phase-94 TODO asks for a
|
||||
@@ -655,14 +658,17 @@ def _source_root_summaries(db: Session) -> list[tuple[str, str]]:
|
||||
]
|
||||
|
||||
|
||||
def _source_document_rows(db: Session, source: str) -> list[tuple[str, str]]:
|
||||
"""``(path, title)`` of every document under *source*, ordered by
|
||||
``path`` — the one bounded fetch a folder drill level lists (phase
|
||||
94 task 03; one source's paths, not the whole KB)."""
|
||||
def _source_document_rows(db: Session, source: str) -> list[tuple[str, str, str]]:
|
||||
"""``(path, title, created_iso_date)`` of every document under
|
||||
*source*, ordered by ``path`` — the one bounded fetch a folder
|
||||
drill level lists (phase 94 task 03; one source's paths, not the
|
||||
whole KB). The date is the row's ``created_at`` UTC date part
|
||||
(``YYYY-MM-DD``, phase 106 D5 — the ``ls`` FILE line's appended
|
||||
`` | date: …`` field; only file lines carry a date)."""
|
||||
return [
|
||||
(path, title)
|
||||
for path, title in db.execute(
|
||||
select(Document.path, Document.title)
|
||||
(path, title, created_at.strftime("%Y-%m-%d"))
|
||||
for path, title, created_at in db.execute(
|
||||
select(Document.path, Document.title, Document.created_at)
|
||||
.where(Document.source == source)
|
||||
.order_by(Document.path)
|
||||
)
|
||||
@@ -707,15 +713,17 @@ def ls_top(db: Session) -> list[tuple[str, int, str | None]]:
|
||||
def group_folder_listing(
|
||||
source: str,
|
||||
folder: str,
|
||||
rows: Sequence[tuple[str, str]],
|
||||
rows: Sequence[tuple[str, str, str]],
|
||||
summaries: Mapping[str, str],
|
||||
) -> tuple[list[tuple[str, int, str | None]], list[tuple[str, str, str]], int]:
|
||||
) -> tuple[
|
||||
list[tuple[str, int, str | None]], list[tuple[str, str, str, str]], int
|
||||
]:
|
||||
"""One level of the drill-down tree (phase 94, task 03) — pure.
|
||||
|
||||
Given *rows* — the source's ``(path, title)`` pairs in catalog
|
||||
(path) order — and *summaries* (the source's stored
|
||||
``folder_summaries`` rows: ``folder_path → summary``), the folder
|
||||
level *folder* (source-relative; ``""`` = the source root):
|
||||
Given *rows* — the source's ``(path, title, created_iso_date)``
|
||||
triples in catalog (path) order — and *summaries* (the source's
|
||||
stored ``folder_summaries`` rows: ``folder_path → summary``), the
|
||||
folder level *folder* (source-relative; ``""`` = the source root):
|
||||
|
||||
* **(a) direct subfolders** — the folders whose parent is exactly
|
||||
*folder*, in path order, each
|
||||
@@ -732,8 +740,10 @@ def group_folder_listing(
|
||||
prefix before the last ``/`` —
|
||||
:func:`app.rag.folder_summaries.folder_of`, the shared notion) IS
|
||||
*folder*, in path order (catalog order — the same order
|
||||
``GET /api/docs`` serves), as ``(source, path, title)`` triples
|
||||
— the canonical ``read``/``grep`` identity, capped at
|
||||
``GET /api/docs`` serves), as ``(source, path, title, date)``
|
||||
4-tuples — the canonical ``read``/``grep`` identity plus the
|
||||
phase-106 D5 ``date`` field (the row's ``created_at`` UTC date
|
||||
part, APPENDED — never inserted before ``title``), capped at
|
||||
:data:`LS_MAX_FILE_LINES` (the rest fold into the renderer's
|
||||
note; a 500-file folder never costs 500 lines).
|
||||
* **(c) the TOTAL direct-file count** — pre-cap, for the note.
|
||||
@@ -745,7 +755,7 @@ def group_folder_listing(
|
||||
# indexed path (the existence rule's candidate set — a folder is
|
||||
# present iff at least one path starts with ``folder + "/"``).
|
||||
folders: set[str] = set()
|
||||
for path, _title in rows:
|
||||
for path, _title, _date in rows:
|
||||
f = folder_of(path)
|
||||
while f:
|
||||
folders.add(f)
|
||||
@@ -755,7 +765,7 @@ def group_folder_listing(
|
||||
# folder + "/"`` arm (the folder's true descendants), one pass per
|
||||
# document.
|
||||
counts: dict[str, int] = {f: 0 for f in folders}
|
||||
for path, _title in rows:
|
||||
for path, _title, _date in rows:
|
||||
if path in folders:
|
||||
counts[path] += 1
|
||||
f = folder_of(path)
|
||||
@@ -767,8 +777,8 @@ def group_folder_listing(
|
||||
for g in sorted(g for g in folders if folder_of(g) == folder)
|
||||
]
|
||||
files = [
|
||||
(source, path, title)
|
||||
for path, title in rows
|
||||
(source, path, title, date)
|
||||
for path, title, date in rows
|
||||
if folder_of(path) == folder
|
||||
]
|
||||
return subfolders, files[:LS_MAX_FILE_LINES], len(files)
|
||||
@@ -776,7 +786,9 @@ def group_folder_listing(
|
||||
|
||||
def ls_folder(
|
||||
db: Session, source: str, folder: str
|
||||
) -> tuple[list[tuple[str, int, str | None]], list[tuple[str, str, str]], int]:
|
||||
) -> tuple[
|
||||
list[tuple[str, int, str | None]], list[tuple[str, str, str, str]], int
|
||||
]:
|
||||
"""One folder level of the drill-down ``ls`` (phase 94, task 03).
|
||||
|
||||
The source's document rows (:func:`_source_document_rows`) and
|
||||
@@ -793,7 +805,7 @@ def ls_folder(
|
||||
)
|
||||
|
||||
|
||||
def _folder_exists_in(rows: Sequence[tuple[str, str]], folder: str) -> bool:
|
||||
def _folder_exists_in(rows: Sequence[tuple[str, str, str]], folder: str) -> bool:
|
||||
"""The phase-94 folder-existence rule (``00_phase.md``), pure.
|
||||
|
||||
Folder *folder* (source-relative) under a registered source
|
||||
@@ -805,11 +817,11 @@ def _folder_exists_in(rows: Sequence[tuple[str, str]], folder: str) -> bool:
|
||||
if not folder:
|
||||
return True
|
||||
prefix = folder + "/"
|
||||
return any(path.startswith(prefix) for path, _title in rows)
|
||||
return any(path.startswith(prefix) for path, _title, _date in rows)
|
||||
|
||||
|
||||
def _deepest_existing_ancestor(
|
||||
rows: Sequence[tuple[str, str]], folder: str
|
||||
rows: Sequence[tuple[str, str, str]], folder: str
|
||||
) -> str:
|
||||
"""The deepest EXISTING folder prefix of a missing *folder* (pure).
|
||||
|
||||
@@ -854,7 +866,7 @@ def render_ls_top(entries: Sequence[tuple[str, int, str | None]]) -> str:
|
||||
def render_folder_listing(
|
||||
identity: str,
|
||||
subfolders: Sequence[tuple[str, int, str | None]],
|
||||
files: Sequence[tuple[str, str, str]],
|
||||
files: Sequence[tuple[str, str, str, str]],
|
||||
total_files: int,
|
||||
) -> str:
|
||||
"""One folder level of the drill-down ``ls`` (phase 94, task 03) —
|
||||
@@ -867,9 +879,10 @@ def render_folder_listing(
|
||||
below the header — a blank line, the 2-space-indented subfolder
|
||||
lines `` {sub}/ — {m} documents`` in path order (``: {summary}``
|
||||
appended ONLY when the subfolder's summary is stored), a blank
|
||||
line, the file lines in EXACTLY the existing
|
||||
``source: X | path: Y | title: Z`` format (the canonical
|
||||
``read``/``grep`` identity — unchanged), and the cap note
|
||||
line, the file lines in EXACTLY the
|
||||
``source: X | path: Y | title: Z | date: YYYY-MM-DD`` format (the
|
||||
canonical ``read``/``grep`` identity plus the phase-106 D5
|
||||
appended ``date`` field — the only changed part), and the cap note
|
||||
``…and {hidden} more documents in this folder — use grep
|
||||
(pattern) to find a specific one.`` ONLY when the folder's own
|
||||
files outnumber :data:`LS_MAX_FILE_LINES` (*files* arrives capped;
|
||||
@@ -891,8 +904,8 @@ def render_folder_listing(
|
||||
if files or total_files > len(files):
|
||||
body.append("")
|
||||
body.extend(
|
||||
f"source: {source} | path: {path} | title: {title}"
|
||||
for source, path, title in files
|
||||
f"source: {source} | path: {path} | title: {title} | date: {date}"
|
||||
for source, path, title, date in files
|
||||
)
|
||||
hidden = total_files - len(files)
|
||||
if hidden > 0:
|
||||
@@ -1172,15 +1185,26 @@ def _execute_tool(
|
||||
holder.read_truncations.append(
|
||||
(cast("str", raw_path), cap, len(doc.content))
|
||||
)
|
||||
# Phase 106 (D5): the date rides every document the model
|
||||
# sees — the ``read`` result's SECOND line; the FIRST line
|
||||
# stays ``Document {source}/{path}:`` BYTE-IDENTICAL (the
|
||||
# E2E mock's ``_READ_RESULT_PREFIX`` header contract).
|
||||
return (
|
||||
f"Document {doc.source}/{doc.path}:\n"
|
||||
f"date: {doc.created_at:%Y-%m-%d}\n"
|
||||
f"{doc.content[:cap]}\n"
|
||||
f"{TRUNCATION_MARKER}\n"
|
||||
f"{READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))}"
|
||||
)
|
||||
# At or under the cap: byte-identical to the pre-phase-95 result
|
||||
# (no marker, no notice, no holder entry, no ToolResultPiece).
|
||||
return f"Document {doc.source}/{doc.path}:\n{doc.content}"
|
||||
# At or under the cap: the pre-phase-95 result plus the
|
||||
# phase-106 D5 date line (first line byte-identical — the
|
||||
# mock's header contract; no marker, no notice, no holder
|
||||
# entry, no ToolResultPiece).
|
||||
return (
|
||||
f"Document {doc.source}/{doc.path}:\n"
|
||||
f"date: {doc.created_at:%Y-%m-%d}\n"
|
||||
f"{doc.content}"
|
||||
)
|
||||
if call.name == "grep":
|
||||
raw_pattern = call.arguments.get("pattern")
|
||||
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
|
||||
|
||||
@@ -18,6 +18,13 @@ The guarantees (phase 49 locked decisions):
|
||||
unpack directory, and device/FIFO members — and counting every
|
||||
extracted byte against a cap (zip-bomb guard). Any failure removes the
|
||||
partial ``target_dir`` so no half-unpacked tree survives.
|
||||
* :func:`unpack_archive` also restores each regular file's member mtime
|
||||
(the zip DOS ``date_time`` or the tar ``mtime``) — phase 106, D2:
|
||||
uploaded archives keep their file dates, which the importer reads as
|
||||
the document creation date. Directories, symlinks, and hardlinks are
|
||||
untouched; the cap and every safety check above are unchanged (the
|
||||
``utime`` sits after a successful ``_write_capped``, so a failed
|
||||
unpack still removes the partial tree).
|
||||
* :func:`swap_in` makes ``new_dir`` become ``final_dir`` with **no
|
||||
missing window**: the previous folder is renamed to a unique
|
||||
same-filesystem ``.old-`` sibling first, the new folder is renamed
|
||||
@@ -33,6 +40,7 @@ import stat
|
||||
import tarfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import IO
|
||||
|
||||
@@ -183,6 +191,16 @@ def _unpack_zip(archive: Path, target: Path, max_extract_bytes: int) -> None:
|
||||
else:
|
||||
with zf.open(member) as src:
|
||||
_write_capped(src, dest, max_extract_bytes, total)
|
||||
# Phase 106 (D2): restore the member's DOS mtime — a
|
||||
# tz-agnostic epoch value, UTC-rendered exactly like an
|
||||
# mtime — so uploaded archives keep their file dates.
|
||||
# Regular files only; an OSError here still removes the
|
||||
# partial tree in unpack_archive like any write failure.
|
||||
# ``ns=`` takes INTEGER nanoseconds (a float seconds
|
||||
# value raises TypeError), so convert explicitly.
|
||||
mtime = datetime(*member.date_time, tzinfo=UTC).timestamp()
|
||||
mtime_ns = int(mtime * 1_000_000_000)
|
||||
os.utime(dest, ns=(mtime_ns, mtime_ns))
|
||||
|
||||
|
||||
def _unpack_tar(archive: Path, target: Path, max_extract_bytes: int) -> None:
|
||||
@@ -206,6 +224,14 @@ def _unpack_tar(archive: Path, target: Path, max_extract_bytes: int) -> None:
|
||||
if src is None:
|
||||
raise ArchiveUploadError("corrupt archive member")
|
||||
_write_capped(src, dest, max_extract_bytes, total)
|
||||
# Phase 106 (D2): restore the member's mtime (epoch
|
||||
# seconds — a tz-agnostic value) so uploaded archives
|
||||
# keep their file dates. Regular files only; an OSError
|
||||
# here still removes the partial tree in unpack_archive.
|
||||
# ``ns=`` takes INTEGER nanoseconds (a float seconds
|
||||
# value raises TypeError), so convert explicitly.
|
||||
mtime_ns = int(member.mtime * 1_000_000_000)
|
||||
os.utime(dest, ns=(mtime_ns, mtime_ns))
|
||||
else: # char/block device, FIFO
|
||||
raise ArchiveUploadError(
|
||||
"tar archives with device or FIFO members are not allowed"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Document-creation-date sourcing + normalization (phase 106, D2/D3).
|
||||
|
||||
Every document date the importer writes and every date the owner
|
||||
edits passes through :func:`normalize_doc_date` — the single choke
|
||||
point for the owner's rules: an UNDETERMINED date (no source signal)
|
||||
and a FUTURE date (beyond a small clock-skew tolerance) both assume
|
||||
the document was created TODAY (UTC). Naive source timestamps (zip
|
||||
DOS mtimes, tar mtimes, git-free fallbacks) are tz-agnostic epoch-
|
||||
based values rendered as UTC; aware ones are converted to UTC.
|
||||
|
||||
Pure and stdlib-only by contract (unit-pinned in
|
||||
``tests/unit/test_doc_dates.py``): no database, no logging, no I/O
|
||||
besides :func:`file_mtime_datetime`'s single ``stat`` — the callers
|
||||
(importer task 04, the date-edit API task 05) own everything else.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["FUTURE_SKEW_TOLERANCE", "file_mtime_datetime", "normalize_doc_date"]
|
||||
|
||||
#: Clock-skew tolerance (D3): a source date up to this far in the
|
||||
#: FUTURE is a drifting clock, not a future document — it keeps its
|
||||
#: date. Beyond it, the owner's rule applies (→ today).
|
||||
FUTURE_SKEW_TOLERANCE = timedelta(days=1)
|
||||
|
||||
|
||||
def normalize_doc_date(raw: datetime | None, now: datetime | None = None) -> datetime:
|
||||
"""*raw* → the stored UTC creation date (the D3 rule, pinned).
|
||||
|
||||
``now`` is injectable (tests); it defaults to
|
||||
``datetime.now(UTC)``. ``raw=None`` (undetermined) → *now*;
|
||||
naive *raw* → treated as UTC; aware *raw* → converted to UTC;
|
||||
*raw* beyond *now* + :data:`FUTURE_SKEW_TOLERANCE` → *now*.
|
||||
The result always carries full precision (no date-truncation —
|
||||
the display formats, the storage doesn't).
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.now(UTC)
|
||||
elif now.tzinfo is None:
|
||||
# The future check compares in AWARE space — a naive ``now``
|
||||
# (callers/tests) is a UTC instant, like the naive ``raw``.
|
||||
now = now.replace(tzinfo=UTC)
|
||||
if raw is None:
|
||||
return now
|
||||
# Epoch-based source values (zip DOS times, tar mtimes) are
|
||||
# tz-agnostic — attach UTC; never assume the host's local TZ.
|
||||
# Aware values are converted to UTC (the comparison below is
|
||||
# done in aware space).
|
||||
raw = raw.replace(tzinfo=UTC) if raw.tzinfo is None else raw.astimezone(UTC)
|
||||
if raw > now + FUTURE_SKEW_TOLERANCE:
|
||||
# Genuinely future (beyond the clock-skew tolerance) → today.
|
||||
return now
|
||||
return raw
|
||||
|
||||
|
||||
def file_mtime_datetime(path: Path) -> datetime:
|
||||
"""The file's mtime as an aware UTC datetime (the D2 fallback).
|
||||
|
||||
Epoch mtimes are tz-agnostic — UTC is the correct rendering
|
||||
(zip DOS timestamps and tar mtimes pass through the same
|
||||
:func:`normalize_doc_date` after unpacking, task 03).
|
||||
"""
|
||||
return datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
|
||||
+80
-3
@@ -31,6 +31,17 @@ previously-imported junk (e.g. dot-dir READMEs) leaves the index. Per-file
|
||||
logging uses the verbs ``added | updated | unchanged | pruned`` plus a
|
||||
summary line with per-format counts (PLAN §9).
|
||||
|
||||
Document dates (phase 106, D2/D4): every import sources
|
||||
``documents.created_at`` from the file's source — the per-file git
|
||||
last-commit date when a ``doc_dates_by_root`` entry names the file,
|
||||
else the file's mtime — normalized by
|
||||
:func:`app.rag.doc_dates.normalize_doc_date` (undetermined or future →
|
||||
today, D3) on every add and update. On the unchanged path the stored
|
||||
date is REFRESHED from the same source (it may go OLDER — no monotonic
|
||||
guard) and counted in ``summary.dates_updated`` — unless the row
|
||||
carries the owner's manual correction (``created_at_manual``, D1), which
|
||||
the sync never touches.
|
||||
|
||||
``import_sources`` accepts an optional per-file ``progress`` callback
|
||||
(phase 64, task 01) reporting the file being processed right now.
|
||||
"""
|
||||
@@ -51,6 +62,7 @@ from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import Chunk, Document
|
||||
from app.rag.chunker import chunk_document, extract_title
|
||||
from app.rag.doc_dates import file_mtime_datetime, normalize_doc_date
|
||||
from app.rag.llm import EmbeddingError, LLMError
|
||||
from app.rag.summarizer import generate_summary
|
||||
|
||||
@@ -93,6 +105,12 @@ class ImportSummary:
|
||||
#: Non-markdown files whose summary generation failed (best-effort —
|
||||
#: the document is still indexed, without a summary).
|
||||
summary_errors: int = 0
|
||||
#: Files whose ``created_at`` was refreshed on the UNCHANGED path —
|
||||
#: content untouched, date re-sourced (phase 106, D4: the date may
|
||||
#: go OLDER; a date-only refresh NEVER counts added/updated/pruned,
|
||||
#: so no ``sources_meta`` bump, no overview/folder-summary
|
||||
#: regeneration).
|
||||
dates_updated: int = 0
|
||||
#: Files walked, keyed by lowercased extension (``md``, ``yaml``, …).
|
||||
formats: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
@@ -107,7 +125,7 @@ class ImportSummary:
|
||||
logger.info(
|
||||
"import: summary files=%d added=%d updated=%d unchanged=%d pruned=%d "
|
||||
"errors=%d chunks=%d embed_batches=%d summaries=%d summary_errors=%d "
|
||||
"formats=%s",
|
||||
"dates_updated=%d formats=%s",
|
||||
self.files,
|
||||
self.added,
|
||||
self.updated,
|
||||
@@ -118,6 +136,7 @@ class ImportSummary:
|
||||
self.embed_batches,
|
||||
self.summaries,
|
||||
self.summary_errors,
|
||||
self.dates_updated,
|
||||
self.format_counts(),
|
||||
)
|
||||
|
||||
@@ -248,6 +267,7 @@ async def import_sources(
|
||||
progress: Callable[[str, str, int, int], None] | None = None,
|
||||
ignore_by_root: dict[str, list[str]] | None = None,
|
||||
include_hidden_by_root: dict[str, bool] | None = None,
|
||||
doc_dates_by_root: dict[str, dict[str, datetime]] | None = None,
|
||||
) -> ImportSummary:
|
||||
"""Import every A9-format file under *sources* (see module docstring).
|
||||
|
||||
@@ -288,6 +308,19 @@ async def import_sources(
|
||||
flag ON and is walked again with it OFF simply never enters
|
||||
``seen``, so the next ``prune=True`` run deletes its row
|
||||
automatically (A2 — the A9/phase-89 precedent).
|
||||
|
||||
``doc_dates_by_root`` (phase 106, D2/D4) maps ``str(root)`` — the
|
||||
root path string exactly as passed in *sources* — to that source's
|
||||
RAW per-file source dates: source-relative POSIX path → the git
|
||||
last-commit datetime (task 03's ``file_commit_dates``). ONLY git
|
||||
roots are listed — unlisted roots (local dirs, unpacked uploads)
|
||||
take the mtime fallback, and a path missing from its root's map
|
||||
does too. The map entry beats the file's mtime when present. The
|
||||
progress pre-walk is untouched (dates change no file count).
|
||||
``None`` (the default) changes nothing for existing callers: the
|
||||
mtime fallback applies to every file — which IS the behavior
|
||||
change, D4: an unchanged file now refreshes its stored date from
|
||||
its source on every run (the backfill-correction case).
|
||||
"""
|
||||
if limit is not None and limit <= 0:
|
||||
raise ValueError("limit must be >= 1")
|
||||
@@ -325,6 +358,10 @@ async def import_sources(
|
||||
source_names.add(source)
|
||||
ignore = _ignore_for_root(root, ignore_by_root)
|
||||
include_hidden = _include_hidden_for_root(root, include_hidden_by_root)
|
||||
# Phase 106 (D2): the root's raw source dates (git last-commit
|
||||
# for git roots, keyed by the same str(root) convention); {}
|
||||
# for unlisted roots — every file then takes the mtime fallback.
|
||||
dates_map = (doc_dates_by_root or {}).get(str(root), {})
|
||||
for path in iter_importable_files(
|
||||
root,
|
||||
llm.settings.import_extension_set,
|
||||
@@ -353,7 +390,7 @@ async def import_sources(
|
||||
try:
|
||||
await _index_file(
|
||||
session, source=source, rel=rel, full_path=path, llm=llm,
|
||||
summary=summary,
|
||||
summary=summary, raw_date=dates_map.get(rel),
|
||||
)
|
||||
except EmbeddingError as e:
|
||||
# A pathological file (e.g. content the embedding endpoint
|
||||
@@ -384,15 +421,45 @@ async def _index_file(
|
||||
full_path: Path,
|
||||
llm: Embedder,
|
||||
summary: ImportSummary,
|
||||
raw_date: datetime | None = None,
|
||||
) -> None:
|
||||
"""Upsert one file: doc row + chunk rows + embeddings, one transaction."""
|
||||
"""Upsert one file: doc row + chunk rows + embeddings, one transaction.
|
||||
|
||||
``raw_date`` (phase 106, D2) is the file's RAW source date — the
|
||||
git last-commit datetime from the caller's ``doc_dates_by_root``
|
||||
map, or ``None`` (every non-git case): the file's mtime is read
|
||||
here, once, and becomes the source date (the D2 fallback).
|
||||
"""
|
||||
settings = llm.settings
|
||||
content = full_path.read_text(encoding="utf-8", errors="replace").replace("\x00", "")
|
||||
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
doc = session.scalar(select(Document).where(Document.source == source, Document.path == rel))
|
||||
if raw_date is None:
|
||||
# D2 fallback: no source date in the map → the file's mtime
|
||||
# (one stat). Read before the unchanged early-return — the
|
||||
# unchanged path refreshes the stored date from the same source.
|
||||
raw_date = file_mtime_datetime(full_path)
|
||||
if doc is not None and doc.content_hash == digest:
|
||||
summary.unchanged += 1
|
||||
logger.info("import: unchanged source=%s path=%s", source, rel)
|
||||
if doc.created_at_manual:
|
||||
# D1/D4: the owner's correction survives the sync — no
|
||||
# write at all (the phase-97 ``manually_edited`` precedent).
|
||||
return
|
||||
# D4: the date refreshes on every sync, including unchanged
|
||||
# files, and may go OLDER (no monotonic guard). A date-only
|
||||
# refresh is still counted ``unchanged`` — never added/updated/
|
||||
# pruned, so no ``sources_meta`` bump and no regeneration.
|
||||
target = normalize_doc_date(raw_date)
|
||||
if target != doc.created_at:
|
||||
doc.created_at = target
|
||||
session.commit()
|
||||
summary.dates_updated += 1
|
||||
logger.info(
|
||||
"import: date-refreshed source=%s path=%s date=%s",
|
||||
source, rel,
|
||||
doc.created_at.isoformat(),
|
||||
)
|
||||
return
|
||||
|
||||
verb = "updated" if doc is not None else "added"
|
||||
@@ -411,6 +478,11 @@ async def _index_file(
|
||||
content=content,
|
||||
content_hash=digest,
|
||||
indexed_at=datetime.now(UTC),
|
||||
# Phase 106 (D2/D3): the sourced creation date, normalized
|
||||
# (undetermined or future → today). ``created_at_manual``
|
||||
# stays the column default (False) — only the date-edit API
|
||||
# (task 05) sets it.
|
||||
created_at=normalize_doc_date(raw_date),
|
||||
)
|
||||
session.add(doc)
|
||||
else:
|
||||
@@ -419,6 +491,11 @@ async def _index_file(
|
||||
doc.content = content
|
||||
doc.content_hash = digest
|
||||
doc.indexed_at = datetime.now(UTC)
|
||||
# Phase 106 (D4): a content change is a new document version —
|
||||
# the date is re-sourced and a previous manual correction is
|
||||
# reset (it referred to the old content).
|
||||
doc.created_at = normalize_doc_date(raw_date)
|
||||
doc.created_at_manual = False
|
||||
|
||||
session.flush() # guarantees doc.id even for brand-new rows
|
||||
|
||||
|
||||
+12
-1
@@ -352,6 +352,12 @@ source-name ``ls`` scope, the combined ``source/path`` identity for
|
||||
cap — not the prompt — decides whether the tools are actually
|
||||
offered to the model, see :mod:`app.rag.agent`).
|
||||
|
||||
Each ``<document>`` block carries the identity attributes
|
||||
``source`` / ``path`` / ``title`` — and, since phase 106 (D5),
|
||||
``date`` (the row's ``created_at`` UTC date part, ``YYYY-MM-DD``,
|
||||
APPENDED after ``title`` — the only position; always present,
|
||||
``created_at`` is NOT NULL) — plus the document's full text.
|
||||
|
||||
Gate-iteration note (task 05, 2026-09-03/04): an in-context reminder
|
||||
LEADING this section (the document texts are already context — do
|
||||
not ``read`` one the user asked to open) was tried and REVERTED:
|
||||
@@ -368,8 +374,13 @@ source-name ``ls`` scope, the combined ``source/path`` identity for
|
||||
# attribute at the ``source``/``path`` copy site was TRIED and
|
||||
# REVERTED the same day (no improvement across runs; the block stays
|
||||
# exactly the document identity + full text).
|
||||
# Phase 106 (D5): every document the model sees carries its
|
||||
# creation date — the block's ``date`` attribute (the row's
|
||||
# ``created_at`` UTC date part, appended after ``title`` — the
|
||||
# only position; always present, ``created_at`` is NOT NULL).
|
||||
blocks = [
|
||||
f'<document source="{doc.source}" path="{doc.path}" title="{doc.title}">\n'
|
||||
f'<document source="{doc.source}" path="{doc.path}" title="{doc.title}" '
|
||||
f'date="{doc.created_at:%Y-%m-%d}">\n'
|
||||
f"{doc.content}\n"
|
||||
"</document>"
|
||||
for doc in documents
|
||||
|
||||
+81
-1
@@ -41,10 +41,12 @@ Deterministic tie-break for equal fused scores:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, text
|
||||
@@ -146,6 +148,7 @@ _LEXICAL_SQL = text(
|
||||
d.content AS doc_content,
|
||||
d.content_hash AS content_hash,
|
||||
d.indexed_at AS indexed_at,
|
||||
d.created_at AS created_at,
|
||||
c.is_summary AS is_summary,
|
||||
ts_rank(c.tsv, to_tsquery('english', :tsquery)) AS rank
|
||||
FROM chunks c
|
||||
@@ -242,6 +245,73 @@ def fuse(
|
||||
return out
|
||||
|
||||
|
||||
def apply_recency_boost(
|
||||
chunks: Sequence[RetrievedChunk],
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
weight: float | None = None,
|
||||
half_life_days: int | None = None,
|
||||
) -> list[RetrievedChunk]:
|
||||
"""Additive recency boost on the fused score (phase 106, D6).
|
||||
|
||||
Each chunk's score becomes
|
||||
``score + weight * exp(−age_days / half_life_days)`` where
|
||||
``age_days = max(0, (now − document.created_at))`` in days — a
|
||||
zero-age document gets the full *weight* (the MAXIMUM additive
|
||||
score), each ``half_life_days`` of age multiplies the remaining
|
||||
boost by ``e**-1`` (≈0.37), and a FUTURE ``created_at`` clamps to
|
||||
age 0 (the document reads as brand-new — consistent with D3's
|
||||
today-folding in :mod:`app.rag.doc_dates`). Defaults: *weight* /
|
||||
*half_life_days* from :func:`get_settings` (``recency_boost`` /
|
||||
``recency_half_life_days``), *now* from ``datetime.now(UTC)``.
|
||||
|
||||
Magnitude rationale (the ``0.0007`` default, the k=60 RRF scale):
|
||||
rank 1 vs 2 in one list differs by ~0.00026 and rank 1 vs 10 by
|
||||
~0.0021, so the full weight is a bounded 2-3 rank head start —
|
||||
enough to break near-ties toward the newer document, far below the
|
||||
fused gap between a document that answers and one that merely
|
||||
resembles (the phase-106 fine-line battery pins the measured
|
||||
margin: 0.00263 ≥ 3× the zero-age boost).
|
||||
|
||||
Pure (the :func:`fuse` convention): the inputs are never mutated —
|
||||
every boosted chunk is a ``replace()`` copy — and the result is
|
||||
re-sorted with the EXISTING deterministic key
|
||||
``(−score, −cosine, document.path, position)``; with ``weight=0``
|
||||
every score is untouched and an already-fused (already-sorted)
|
||||
input comes back byte-identical (the kill switch, pinned).
|
||||
|
||||
Untouched by design: the A8 honesty gate and ``query_log.top_score``
|
||||
(both read the chunk's ``cosine``, which the boost never modifies),
|
||||
:func:`weak_hit_titles` (titles only), and the never-truncated
|
||||
top-N contract (:func:`select_documents` still feeds whole
|
||||
documents — the boost re-ranks WHICH documents, never truncates).
|
||||
SINGLE APPLY SITE: :func:`retrieve()` is the only caller in
|
||||
``app/`` — the chat API and ``scripts/eval_retrieval.py`` inherit
|
||||
the boost through it; nothing else may apply it.
|
||||
"""
|
||||
if weight is None or half_life_days is None:
|
||||
settings = get_settings()
|
||||
if weight is None:
|
||||
weight = settings.recency_boost
|
||||
if half_life_days is None:
|
||||
half_life_days = settings.recency_half_life_days
|
||||
if half_life_days <= 0:
|
||||
raise ValueError("half_life_days must be > 0")
|
||||
if now is None:
|
||||
now = datetime.now(UTC)
|
||||
boosted: list[RetrievedChunk] = []
|
||||
for rc in chunks:
|
||||
age_days = max(0.0, (now - rc.document.created_at).total_seconds() / 86400.0)
|
||||
boosted.append(
|
||||
replace(
|
||||
rc,
|
||||
score=rc.score + weight * math.exp(-age_days / half_life_days),
|
||||
)
|
||||
)
|
||||
boosted.sort(key=lambda rc: (-rc.score, -rc.cosine, rc.document.path, rc.position))
|
||||
return boosted
|
||||
|
||||
|
||||
def _vector_candidates(
|
||||
db: Session, question_embedding: list[float], limit: int
|
||||
) -> list[RetrievedChunk]:
|
||||
@@ -289,6 +359,7 @@ _NAME_HIT_SQL = text(
|
||||
d.content AS doc_content,
|
||||
d.content_hash AS content_hash,
|
||||
d.indexed_at AS indexed_at,
|
||||
d.created_at AS created_at,
|
||||
c.id AS chunk_id,
|
||||
c.position AS position,
|
||||
c.content AS content,
|
||||
@@ -364,6 +435,7 @@ def _name_hit_chunks(db: Session, question: str) -> list[RetrievedChunk]:
|
||||
content=row.doc_content,
|
||||
content_hash=row.content_hash,
|
||||
indexed_at=row.indexed_at,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
out.append(
|
||||
RetrievedChunk(
|
||||
@@ -418,6 +490,7 @@ def _lexical_candidates(db: Session, question: str, limit: int) -> list[Retrieve
|
||||
content=row.doc_content,
|
||||
content_hash=row.content_hash,
|
||||
indexed_at=row.indexed_at,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
out.append(
|
||||
RetrievedChunk(
|
||||
@@ -460,7 +533,14 @@ def retrieve(
|
||||
raise ValueError("lexical_candidates must be >= 1")
|
||||
vector = _vector_candidates(db, question_embedding, v_n)
|
||||
lexical = _lexical_candidates(db, question, l_n)
|
||||
return fuse(vector, lexical, settings.rrf_k)
|
||||
fused = fuse(vector, lexical, settings.rrf_k)
|
||||
if settings.recency_boost > 0:
|
||||
# Phase 106, D6 — the SINGLE recency-boost apply site: an
|
||||
# additive post-fusion re-rank (see :func:`apply_recency_boost`).
|
||||
# ``0`` = off: the pre-phase ranking returns byte-identical (the
|
||||
# kill switch) and weight-0 callers pay nothing.
|
||||
return apply_recency_boost(fused)
|
||||
return fused
|
||||
|
||||
|
||||
def weak_hit_titles(chunks: Sequence[RetrievedChunk]) -> list[str]:
|
||||
|
||||
+72
-7
@@ -235,6 +235,10 @@ class DocSummary(BaseModel):
|
||||
path: str
|
||||
title: str
|
||||
chunks: int
|
||||
#: The document's creation date (phase 106, D8) — ISO-8601, verbatim
|
||||
#: from the row (the ``indexed_at`` style); the RAG view's file table
|
||||
#: renders it as the ``Created`` column (before ``Indexed``).
|
||||
created_at: str
|
||||
indexed_at: str
|
||||
|
||||
|
||||
@@ -251,7 +255,8 @@ class KbTreeFile(BaseModel):
|
||||
JSON shape is the contract). ``path`` is SOURCE-RELATIVE (the RAG
|
||||
view prefixes the source in its breadcrumb); ``title`` /
|
||||
``chunks`` (content + ``is_summary`` chunks — the same count
|
||||
``GET /api/docs`` returns) / ``indexed_at`` (ISO-8601) are verbatim
|
||||
``GET /api/docs`` returns) / ``created_at`` (phase 106, D8 — the
|
||||
document's creation date) / ``indexed_at`` (ISO-8601) are verbatim
|
||||
from the catalogue row the endpoint reads.
|
||||
"""
|
||||
|
||||
@@ -259,6 +264,10 @@ class KbTreeFile(BaseModel):
|
||||
path: str
|
||||
title: str
|
||||
chunks: int = Field(ge=0)
|
||||
#: The document's creation date (phase 106, D8) — ISO-8601, verbatim
|
||||
#: from the catalogue row; the RAG view's file table renders it as
|
||||
#: the ``Created`` column (before ``Indexed``).
|
||||
created_at: str
|
||||
indexed_at: str
|
||||
|
||||
|
||||
@@ -269,11 +278,14 @@ class KbTreeFolder(BaseModel):
|
||||
node IS the root); ``documents`` is the recursive subtree count
|
||||
(the phase-94 ``ls`` count rule: every path equal to the folder or
|
||||
starting with ``folder + "/"`` — the file sharing a folder's name
|
||||
counts); ``summary`` is the stored ``folder_summaries`` row (AI or
|
||||
manual — any row) or null; ``children`` are the direct subfolders
|
||||
(path order) followed by the direct files (catalog order) — the
|
||||
recursive union (Pydantic v2 resolves it with
|
||||
``from __future__ import annotations``).
|
||||
counts); ``updated_at`` (phase 106, D9) is the subtree's MAX
|
||||
document ``created_at`` — DERIVED in the pure tree builder as it
|
||||
recurses, never stored (``null`` for a node with no documents at
|
||||
all — the ``summary: str | None`` shape); ``summary`` is the stored
|
||||
``folder_summaries`` row (AI or manual — any row) or null;
|
||||
``children`` are the direct subfolders (path order) followed by the
|
||||
direct files (catalog order) — the recursive union (Pydantic v2
|
||||
resolves it with ``from __future__ import annotations``).
|
||||
|
||||
``summary_pending`` (phase 98, D3 — ONE concept): true iff this
|
||||
folder's recursive count ≥ ``MIN_DOCS_PER_FOLDER`` (2) AND it has
|
||||
@@ -287,6 +299,10 @@ class KbTreeFolder(BaseModel):
|
||||
kind: Literal["folder"] = "folder"
|
||||
path: str
|
||||
documents: int = Field(ge=0)
|
||||
#: The subtree's MAX document ``created_at`` (phase 106, D9 —
|
||||
#: derived in the pure builder, never stored); ISO-8601, ``null``
|
||||
#: for a node with no documents at all.
|
||||
updated_at: str | None = None
|
||||
summary: str | None = None
|
||||
summary_pending: bool = False
|
||||
children: list[KbTreeFolder | KbTreeFile] = Field(default_factory=list)
|
||||
@@ -299,7 +315,10 @@ class KbTreeSource(BaseModel):
|
||||
always present, a registered 0-document source lists with
|
||||
``documents: 0`` and no children), then the indexed-only sources
|
||||
(alphabetical) — the phase-97 superset rule. ``documents`` is the
|
||||
source's whole recursive count; ``summary`` is the stored
|
||||
source's whole recursive count; ``updated_at`` (phase 106, D9) is
|
||||
the source's subtree MAX document ``created_at`` — DERIVED in the
|
||||
pure tree builder, never stored (``null`` for a 0-document source —
|
||||
the ``summary: str | None`` shape); ``summary`` is the stored
|
||||
``(source, "")`` source-root row or null; ``children`` are the
|
||||
source's direct subfolders + direct files (same shape as a folder
|
||||
node's).
|
||||
@@ -316,6 +335,10 @@ class KbTreeSource(BaseModel):
|
||||
|
||||
name: str
|
||||
documents: int = Field(ge=0)
|
||||
#: The source's subtree MAX document ``created_at`` (phase 106,
|
||||
#: D9 — derived in the pure builder, never stored); ISO-8601,
|
||||
#: ``null`` for a 0-document source.
|
||||
updated_at: str | None = None
|
||||
summary: str | None = None
|
||||
summary_pending: bool = False
|
||||
children: list[KbTreeFolder | KbTreeFile] = Field(default_factory=list)
|
||||
@@ -350,6 +373,10 @@ class DocContent(BaseModel):
|
||||
#: markdown documents, pre-phase-30 rows, and the fail-soft path where
|
||||
#: summary generation failed but the document was still indexed.
|
||||
summary: str | None = None
|
||||
#: The document's creation date (phase 106, D8) — ISO-8601, verbatim
|
||||
#: from the row; the viewer's top meta row renders the ``Created``
|
||||
#: badge from it (before the ``Indexed`` badge).
|
||||
created_at: str
|
||||
content: str
|
||||
indexed_at: str
|
||||
chunks: int
|
||||
@@ -389,6 +416,44 @@ class SummaryResult(BaseModel):
|
||||
chunks: int
|
||||
|
||||
|
||||
class DateUpdate(BaseModel):
|
||||
"""``PATCH /api/documents/date`` body (phase 106, task 05, D7).
|
||||
|
||||
``source`` / ``path`` name the indexed document (the same pair the
|
||||
public ``GET /api/documents/content`` looks up); ``date`` is the
|
||||
owner's corrected creation date — an ISO date (``YYYY-MM-DD``) or a
|
||||
full ISO datetime. **Null/absent is the CLEAR** (the "revert to
|
||||
sync" operation, D7): the ``created_at_manual`` flag is dropped and
|
||||
the stored date stands until the next sync refreshes it (the API is
|
||||
DB-only — it cannot re-read the source). Unconstrained
|
||||
``str | None`` on purpose: a MALFORMED non-null value 422s in the
|
||||
handler (``datetime.fromisoformat``), so the error detail can name
|
||||
the field; an unknown pair must 404 as "document not found"
|
||||
(row-lookup semantics), exactly like the public content endpoint.
|
||||
"""
|
||||
|
||||
source: str
|
||||
path: str
|
||||
date: str | None = None
|
||||
|
||||
|
||||
class DateResult(BaseModel):
|
||||
"""``PATCH /api/documents/date`` response (phase 106, task 05, D7).
|
||||
|
||||
Echoes the STORED state after the change: ``created_at`` is the
|
||||
stored ISO-8601 value (a set stores the normalized parse — a
|
||||
manually set FUTURE date folds to today, D3; a clear leaves the
|
||||
stored date standing) and ``created_at_manual`` the flag (true
|
||||
after a set, false after a clear). The viewer re-renders its
|
||||
Created badge from exactly this echo — no second fetch.
|
||||
"""
|
||||
|
||||
source: str
|
||||
path: str
|
||||
created_at: str
|
||||
created_at_manual: bool
|
||||
|
||||
|
||||
class FolderSummaryUpdate(BaseModel):
|
||||
"""``PATCH /api/folders/summary`` body (phase 97, task 03).
|
||||
|
||||
|
||||
+285
-9
@@ -66,6 +66,42 @@
|
||||
* everyone exactly as phase 36 — the public viewer stays
|
||||
* byte-for-byte identical (no button, no wiring, no admin-only
|
||||
* network call) until the admin gate resolves true.
|
||||
*
|
||||
* Phase 106 (task 08, D8): the .doc-meta badge row gains the Created
|
||||
* badge BEFORE the Indexed one (the owner's verbatim position — the
|
||||
* date at the top of a clicked document): `Created <locale date>` via
|
||||
* fmtDate(doc.created_at), the full ISO timestamp on the badge's
|
||||
* title (the titleEl ellipsis-precision idiom). ONE shared core —
|
||||
* the modal and /document.html both render it (no per-surface copy);
|
||||
* the date arrives in the same content payload (task 05 added
|
||||
* created_at to GET /api/documents/content). The date EDITOR (the
|
||||
* admin inline edit + PATCH /api/documents/date) is the task-09
|
||||
* section below.
|
||||
*
|
||||
* Phase 106 (task 09, D7): the badge row gains the ADMIN-ONLY date
|
||||
* editor (the phase-57 wireSummaryEdit idiom — the same
|
||||
* docAdminReady() gate on the module-cached whoami promise, so no
|
||||
* second request per page): an "Edit date" text button after the
|
||||
* Created badge (task 08's insertion point) swaps in-place for an
|
||||
* inline editor — a native type=date input + Save / Cancel + the
|
||||
* muted "Revert to sync" clear affordance (the phase-57 "clear =
|
||||
* explicit" contrast) + a role=status live line and a role=alert
|
||||
* error line. Save PATCHes /api/documents/date with { source, path,
|
||||
* date } and the badge re-renders from the RESPONSE's created_at
|
||||
* (never the input's optimistic value); "Revert to sync" sends
|
||||
* date: null (the D7 CLEAR — the manual flag drops, the stored date
|
||||
* stands until the next sync). An EMPTY date input can't clear (Save
|
||||
* disables itself — the explicit Revert is the only clear path, no
|
||||
* accidental wipes). §7.4 never-stale: the controls disable
|
||||
* immediately on submit (one PATCH at a time); a failure (non-2xx
|
||||
* or network) lands the server detail (or the canned retry copy) in
|
||||
* the role=alert line, reverts the input to the stored date, and
|
||||
* re-enables — the UI never claims a state the server didn't save.
|
||||
* A non-admin / token holder / failed whoami keeps the byte-for-byte
|
||||
* task-08 badge row (no button, no wiring, no admin-only network
|
||||
* call — the phase-57 split). ONE shared core — the modal and
|
||||
* /document.html both get it (document-modal.js imports
|
||||
* renderDocument from this module — no per-surface copy).
|
||||
*/
|
||||
|
||||
import { bindSharedHeaderControls, fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
@@ -94,23 +130,29 @@ function contentUrl(s, p) {
|
||||
return "/api/documents/content?source=" + encodeURIComponent(s) + "&path=" + encodeURIComponent(p);
|
||||
}
|
||||
|
||||
function metaBadge(cls, text) {
|
||||
function metaBadge(cls, text, title) {
|
||||
const el = document.createElement("span");
|
||||
el.className = cls;
|
||||
el.textContent = text;
|
||||
// Phase 106 (task 08): optional hover precision — the meta row may
|
||||
// clip (nowrap), so the exact value stays reachable on the badge's
|
||||
// title (the titleEl ellipsis-precision idiom). Omitted → the
|
||||
// pre-phase two-argument shape (the other badges are unchanged).
|
||||
if (title !== undefined) el.setAttribute("title", title);
|
||||
return el;
|
||||
}
|
||||
|
||||
/* ---------- 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 · indexed ·
|
||||
* chunks), and a content container — an optional .doc-summary section
|
||||
* first (phase 36: only when doc.summary is non-empty — markdown docs
|
||||
* and fail-soft rows carry none, so they render exactly as before),
|
||||
* then .doc-md for md/markdown (the shared escape-first renderer),
|
||||
* <pre class="doc-raw"> otherwise. The XSS contract: innerHTML only
|
||||
* through renderMarkdown; every document-derived string (summary text
|
||||
* included) is a text node. */
|
||||
* a .doc-meta badge row (source · format · mono path · created ·
|
||||
* indexed · chunks — phase 106 (task 08, D8) added created BEFORE
|
||||
* indexed), and a content container — an optional .doc-summary
|
||||
* section first (phase 36: only when doc.summary is non-empty —
|
||||
* markdown docs and fail-soft rows carry none, so they render exactly
|
||||
* as before), then .doc-md for md/markdown (the shared escape-first
|
||||
* renderer), <pre class="doc-raw"> otherwise. The XSS contract:
|
||||
* innerHTML only through renderMarkdown; every document-derived
|
||||
* string (summary text included) is a text node. */
|
||||
export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
||||
titleEl.textContent = doc.title;
|
||||
// Phase 34 task 04: the titlebar title ellipsizes — the full title
|
||||
@@ -124,10 +166,28 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
||||
metaBadge("doc-source-badge", doc.source),
|
||||
metaBadge("format-badge", doc.format),
|
||||
pathCode,
|
||||
// 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
|
||||
// Indexed idiom), and the badge's title = the FULL ISO timestamp
|
||||
// (the titleEl ellipsis-precision idiom — the row clips, the exact
|
||||
// value stays reachable).
|
||||
metaBadge("doc-created", `Created ${fmtDate(doc.created_at)}`, doc.created_at),
|
||||
metaBadge("doc-indexed", `Indexed ${fmtDate(doc.indexed_at)}`),
|
||||
metaBadge("doc-chunks", `${doc.chunks} chunk${doc.chunks === 1 ? "" : "s"}`),
|
||||
);
|
||||
|
||||
// Phase 106 (task 09, D7): the ADMIN-ONLY date editor — the
|
||||
// module-cached whoami promise (docAdminReady — the SAME single
|
||||
// request per page the summary edit shares) gates the wiring, so a
|
||||
// non-admin / token holder / failed whoami keeps EXACTLY the task-08
|
||||
// badge row: no button, no wiring, no admin-only network call (the
|
||||
// phase-57 split). The button lands after the Created badge (task
|
||||
// 08's insertion point); the editor itself lives in wireDateEdit.
|
||||
void docAdminReady().then((admin) => {
|
||||
if (admin) wireDateEdit(metaEl, doc);
|
||||
});
|
||||
|
||||
contentEl.replaceChildren();
|
||||
// Phase 36: the summary panel — labeled section ABOVE the original
|
||||
// content, on BOTH surfaces (page + modal) through this one core.
|
||||
@@ -308,6 +368,222 @@ function wireSummaryEdit(section, doc) {
|
||||
cancelBtn.addEventListener("click", () => closeEditor(""));
|
||||
}
|
||||
|
||||
/* ---------- date editing (phase 106, task 09 — D7, admin-only) ----------
|
||||
* The .doc-meta badge row (task 08's Created badge) is the ONE place
|
||||
* the stored creation date is edited (page + modal through the
|
||||
* shared renderDocument core — no per-surface copy). Only an admin
|
||||
* (docAdminReady) ever gets the affordance; a non-admin / token
|
||||
* holder / failed whoami keeps exactly the task-08 badge row (no
|
||||
* button, no wiring, no admin-only network call — the phase-57
|
||||
* split). Save PATCHes /api/documents/date with { source, path,
|
||||
* date } — a non-empty date sets it (the server normalizes it, D3,
|
||||
* and stores the manual flag, D1); the explicit "Revert to sync"
|
||||
* affordance sends date: null (the D7 CLEAR — the manual flag drops
|
||||
* and the stored date stands until the next sync refreshes it).
|
||||
* The badge re-renders from the RESPONSE's created_at — the UI shows
|
||||
* exactly what the server stored, never the input's optimistic
|
||||
* value. §7.4 never-stale: the controls disable IMMEDIATELY on
|
||||
* Save/Revert (no double-submit); a failure (non-2xx or network)
|
||||
* lands the server detail (or the canned retry copy) in the
|
||||
* role=alert line, reverts the input to the stored date, and
|
||||
* re-enables — the UI never claims a state the server didn't save.
|
||||
* An EMPTY date input can't clear: Save disables itself on an empty
|
||||
* input (the explicit Revert is the only clear path — no accidental
|
||||
* wipes). */
|
||||
|
||||
/* The server detail of a non-2xx response (the git-sources.js
|
||||
* apiDetail shape — the phase-89 error-line idiom: a string detail
|
||||
* or the 422 array's first msg), with the neutral fallback for a
|
||||
* non-JSON body. */
|
||||
async function apiDetail(res, fallback) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
||||
return String(data.detail[0].msg);
|
||||
}
|
||||
if (typeof data.detail === "string" && data.detail) return data.detail;
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/* The date editor on one rendered .doc-meta badge row: the "Edit
|
||||
* date" text button after the Created badge (admin-only — the public
|
||||
* row keeps task 08's shape). Edit swaps the button for the inline
|
||||
* editor in place — a native <input type="date"> (value, never
|
||||
* innerHTML — XSS contract) prefilled with the stored date's UTC
|
||||
* date part, Save / Cancel, the muted "Revert to sync" clear, and
|
||||
* the role=status / role=alert live lines. One PATCH at a time (the
|
||||
* controls disable before the fetch and re-enable in the finally —
|
||||
* every outcome, never stale, PLAN §7.4). */
|
||||
function wireDateEdit(metaEl, doc) {
|
||||
const createdBadge = metaEl.querySelector(".doc-created");
|
||||
if (!createdBadge) return;
|
||||
|
||||
/* Edit affordance (admin-only — the public badge row keeps exactly
|
||||
* task 08's shape: no button, no wiring). setAttribute, never
|
||||
* innerHTML (the document-derived pair is user-storable). */
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.type = "button";
|
||||
editBtn.className = "doc-date-edit";
|
||||
editBtn.textContent = "Edit date";
|
||||
editBtn.setAttribute(
|
||||
"aria-label",
|
||||
`Edit creation date: ${doc.source}/${doc.path}`,
|
||||
);
|
||||
createdBadge.insertAdjacentElement("afterend", editBtn);
|
||||
|
||||
/* Editor parts (built once; the input is rebuilt on every open so
|
||||
* it always starts from the CURRENT stored date). */
|
||||
let input = null;
|
||||
const box = document.createElement("span");
|
||||
box.className = "doc-date-editor";
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.type = "button";
|
||||
saveBtn.className = "doc-date-save";
|
||||
saveBtn.textContent = "Save";
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "doc-date-cancel";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
const revertBtn = document.createElement("button");
|
||||
revertBtn.type = "button";
|
||||
revertBtn.className = "doc-date-revert";
|
||||
revertBtn.textContent = "Revert to sync";
|
||||
const status = document.createElement("span");
|
||||
status.className = "doc-date-status";
|
||||
status.setAttribute("role", "status");
|
||||
status.setAttribute("aria-live", "polite");
|
||||
const errorLine = document.createElement("span");
|
||||
errorLine.className = "doc-date-error";
|
||||
errorLine.setAttribute("role", "alert");
|
||||
errorLine.setAttribute("aria-live", "assertive");
|
||||
|
||||
/* The stored date as a native date-input value: the UTC date part
|
||||
* of the stored ISO timestamp (D3 stores UTC timestamptz). */
|
||||
function storedValue() {
|
||||
return new Date(doc.created_at).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/* One PATCH at a time (PLAN §7.4): the controls disable
|
||||
* IMMEDIATELY on submit; Save ADDITIONALLY disables on an EMPTY
|
||||
* input (the explicit Revert is the only clear path — no
|
||||
* accidental wipes). */
|
||||
function setControlsLocked(locked) {
|
||||
if (input) input.disabled = locked;
|
||||
saveBtn.disabled = locked || input.value === "";
|
||||
cancelBtn.disabled = locked;
|
||||
revertBtn.disabled = locked;
|
||||
}
|
||||
|
||||
/* Back to the display state: the badge row + the Edit button (the
|
||||
* task-08 shape restored) and focus back on the opener. Idempotent
|
||||
* (a stale success-beat timer may call it after a Cancel — the
|
||||
* re-insert is a same-position move and the box is already out).
|
||||
* The last announcement stays readable a short beat because the
|
||||
* caller defers the call (the phase-57 "confirmation stays
|
||||
* readable" precedent). */
|
||||
function closeEditor() {
|
||||
editBtn.hidden = false;
|
||||
createdBadge.insertAdjacentElement("afterend", editBtn);
|
||||
box.remove();
|
||||
editBtn.focus();
|
||||
}
|
||||
|
||||
async function saveDate(dateValue) {
|
||||
setControlsLocked(true); // one PATCH at a time (never stale)
|
||||
errorLine.textContent = "";
|
||||
try {
|
||||
const r = await fetch("/api/documents/date", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
source: doc.source,
|
||||
path: doc.path,
|
||||
date: dateValue,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
// The server detail into the role=alert line (the canned
|
||||
// retry copy on a non-JSON body); the input reverts to the
|
||||
// stored date; the controls re-enable in the finally. The
|
||||
// editor stays open — the user's attempt is visible.
|
||||
errorLine.textContent = await apiDetail(
|
||||
r,
|
||||
"Couldn't save the date — try again.",
|
||||
);
|
||||
input.value = storedValue();
|
||||
return;
|
||||
}
|
||||
const res = await r.json();
|
||||
// Response-driven: the badge shows exactly what the server
|
||||
// stored (never the input's optimistic value), and the doc
|
||||
// object syncs so a re-open prefills the CURRENT date.
|
||||
doc.created_at = res.created_at;
|
||||
doc.created_at_manual = res.created_at_manual;
|
||||
createdBadge.textContent = `Created ${fmtDate(res.created_at)}`;
|
||||
createdBadge.setAttribute("title", res.created_at);
|
||||
// The confirmation lands AFTER the badge update (the phase-89
|
||||
// last-announce order), then the row collapses back to the
|
||||
// task-08 shape a short beat later (the announcement stays
|
||||
// readable — the status line leaves with the box).
|
||||
status.textContent =
|
||||
dateValue === null
|
||||
? "Reverted to sync-managed date."
|
||||
: `Date saved for ${doc.source}/${doc.path}.`;
|
||||
setTimeout(closeEditor, 2000);
|
||||
} catch {
|
||||
// Network failure: the canned retry copy (the phase-55
|
||||
// neutral shape), the input reverts to the stored date, the
|
||||
// editor stays open.
|
||||
errorLine.textContent = "Couldn't save the date — try again.";
|
||||
input.value = storedValue();
|
||||
} finally {
|
||||
setControlsLocked(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
input = document.createElement("input");
|
||||
input.type = "date";
|
||||
input.className = "doc-date-input";
|
||||
input.setAttribute("aria-label", "Document creation date");
|
||||
input.value = storedValue(); // value, never innerHTML
|
||||
input.addEventListener("input", () => {
|
||||
// An EMPTY input can't clear: Save disables (the explicit
|
||||
// Revert below is the only clear path — no accidental wipes).
|
||||
// The locked state owns the controls while a PATCH is in
|
||||
// flight (the input is disabled then, so this can't race it).
|
||||
if (!input.disabled) saveBtn.disabled = input.value === "";
|
||||
});
|
||||
status.textContent = "";
|
||||
errorLine.textContent = "";
|
||||
box.replaceChildren(input, saveBtn, cancelBtn, revertBtn, status, errorLine);
|
||||
editBtn.hidden = true;
|
||||
createdBadge.insertAdjacentElement("afterend", box);
|
||||
setControlsLocked(false);
|
||||
input.focus();
|
||||
}
|
||||
|
||||
editBtn.addEventListener("click", openEditor);
|
||||
saveBtn.addEventListener("click", () => {
|
||||
// Save is only ever enabled with a NON-empty input (the empty
|
||||
// state disables it) — the clear path is the explicit Revert.
|
||||
void saveDate(input.value);
|
||||
});
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
// Cancel: back to the display state, no PATCH (the stored value
|
||||
// is untouched — the badge was never mutated).
|
||||
status.textContent = "";
|
||||
errorLine.textContent = "";
|
||||
closeEditor();
|
||||
});
|
||||
revertBtn.addEventListener("click", () => {
|
||||
void saveDate(null); // the D7 CLEAR: { source, path, date: null }
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- /document.html page (phases 10/13/19) ----------
|
||||
* Phase 26: viewer-page-specific — see the import-safety note in the
|
||||
* header. The guard is #doc-title: it exists only on this page, so the
|
||||
|
||||
@@ -262,6 +262,29 @@
|
||||
* trigger a view switch; for a foreign entry the router has already
|
||||
* switched views, and this listener's render of the now-hidden view
|
||||
* is harmless).
|
||||
*
|
||||
* Phase 106 (task 08, D8) — the date COLUMNS (display only — the
|
||||
* admin date EDITOR is task 09):
|
||||
*
|
||||
* • the file table gains the Created column BEFORE Indexed (the
|
||||
* owner's verbatim position, D8): makeRow builds the date cells
|
||||
* explicitly (the plain-td loop can't carry per-cell titles) —
|
||||
* textContent = the locale date (fmtDate, the Indexed idiom), and
|
||||
* the Created cell's title = the FULL ISO value (hover precision —
|
||||
* the path-cell idiom; the E2E asserts on the locale-stable
|
||||
* title, not on the toLocaleString output). The row object fed
|
||||
* from the tree's file nodes carries created_at (task 05's tree
|
||||
* shape — the flat GET /api/docs shape has the field too).
|
||||
* • the folder/source table gains the Updated column BETWEEN
|
||||
* Documents and Description (the owner's verbatim position): the
|
||||
* subtree's MAX document created_at — the tree API DERIVES it
|
||||
* (D9, never stored); makeSourceRow / makeFolderRow render
|
||||
* fmtDate(updated_at) with the ISO value on the cell's title and
|
||||
* the "–" null idiom (the statLast shape) for a 0-document
|
||||
* source.
|
||||
* • the stat cards are UNTOUCHED — they keep their indexed_at
|
||||
* "last indexed" semantics (the owner asked for the column, not
|
||||
* the cards).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
@@ -1230,6 +1253,16 @@ export async function mount(root) {
|
||||
const countTd = document.createElement("td");
|
||||
countTd.textContent = String(s.documents);
|
||||
tr.appendChild(countTd);
|
||||
// Phase 106 (task 08, D8/D9): the Updated cell — the source's
|
||||
// subtree MAX document created_at (the tree API derives it),
|
||||
// BETWEEN the count and the Description (the owner's verbatim
|
||||
// position). A 0-document source has none → the statLast "–" null
|
||||
// idiom; the ISO value rides the title (hover precision — the
|
||||
// makeRow path-cell idiom).
|
||||
const updatedTd = document.createElement("td");
|
||||
updatedTd.textContent = s.updated_at ? fmtDate(s.updated_at) : "–";
|
||||
if (s.updated_at) updatedTd.title = s.updated_at; // full ISO on hover
|
||||
tr.appendChild(updatedTd);
|
||||
tr.appendChild(makeDescCell(s, s.name, "", s.name)); // Description + ALWAYS-present Edit (task 05)
|
||||
return tr;
|
||||
}
|
||||
@@ -1255,6 +1288,17 @@ export async function mount(root) {
|
||||
const countTd = document.createElement("td");
|
||||
countTd.textContent = String(f.documents);
|
||||
tr.appendChild(countTd);
|
||||
// Phase 106 (task 08, D8/D9): the Updated cell — the folder's
|
||||
// subtree MAX document created_at (the tree API derives it),
|
||||
// BETWEEN the count and the Description (the owner's verbatim
|
||||
// position); the ISO value rides the title (hover precision — the
|
||||
// makeRow path-cell idiom). The "–" branch is defensive: a folder
|
||||
// node always has ≥1 document, so its max is never null (a
|
||||
// 0-document source IS the null case, makeSourceRow).
|
||||
const updatedTd = document.createElement("td");
|
||||
updatedTd.textContent = f.updated_at ? fmtDate(f.updated_at) : "–";
|
||||
if (f.updated_at) updatedTd.title = f.updated_at; // full ISO on hover
|
||||
tr.appendChild(updatedTd);
|
||||
tr.appendChild(
|
||||
makeDescCell(f, current.source, f.path, current.source + "/" + f.path)
|
||||
); // Description + ALWAYS-present Edit (task 05)
|
||||
@@ -1345,6 +1389,7 @@ export async function mount(root) {
|
||||
path: f.path,
|
||||
title: f.title,
|
||||
chunks: f.chunks,
|
||||
created_at: f.created_at, // phase 106 (task 08, D8): the tree's file date
|
||||
indexed_at: f.indexed_at,
|
||||
})
|
||||
);
|
||||
@@ -1398,11 +1443,26 @@ export async function mount(root) {
|
||||
pathTd.appendChild(link);
|
||||
tr.appendChild(pathTd);
|
||||
|
||||
for (const value of [d.title, String(d.chunks), fmtDate(d.indexed_at)]) {
|
||||
// Phase 106 (task 08, D8): the cell order is [title, chunks,
|
||||
// created, indexed] — the Created cell lands BEFORE Indexed (the
|
||||
// owner's verbatim position). The date cells are built EXPLICITLY
|
||||
// (the plain-td loop can't carry per-cell titles): textContent is
|
||||
// the locale date (fmtDate — the Indexed idiom), and the Created
|
||||
// cell carries the FULL ISO value as its title (hover precision —
|
||||
// the path-cell idiom; the E2E asserts on the locale-stable title,
|
||||
// not on the toLocaleString output).
|
||||
for (const value of [d.title, String(d.chunks)]) {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = value;
|
||||
tr.appendChild(td);
|
||||
}
|
||||
const createdTd = document.createElement("td");
|
||||
createdTd.textContent = fmtDate(d.created_at);
|
||||
createdTd.title = d.created_at; // full ISO on hover (locale-stable)
|
||||
tr.appendChild(createdTd);
|
||||
const indexedTd = document.createElement("td");
|
||||
indexedTd.textContent = fmtDate(d.indexed_at);
|
||||
tr.appendChild(indexedTd);
|
||||
return tr;
|
||||
}
|
||||
|
||||
|
||||
+153
-10
@@ -1937,7 +1937,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
directory's STORED description — the surface-panel language of the
|
||||
phase-93 page heads), and #folders-table (the ONE folders/sources
|
||||
table — the .docs-table language; .kb-folders-table only re-styles
|
||||
the 2nd/3rd column cells). Phase-08 tokens only — NO new hue
|
||||
the 2nd/4th column cells — phase 106 (task 08, D8) moved
|
||||
Description from 3rd to 4th, the Updated column taking 3rd). Phase-08
|
||||
tokens only — NO new hue
|
||||
(the phase-92 monochrome invariant): brand-ink on surface 9.0:1,
|
||||
ink 13.8:1, ink-soft 5.1:1 (every pair AA). :focus-visible via the
|
||||
global 3px outline rule; row targets keep the .docs-table cell
|
||||
@@ -1981,7 +1983,12 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.kb-level p { margin: 0; color: var(--ink-soft); } /* 5.1:1 on --surface */
|
||||
.kb-folders-table { min-width: 480px; }
|
||||
.kb-folders-table td:nth-child(2) { font-family: var(--font); font-size: inherit; max-width: none; }
|
||||
.kb-folders-table td:nth-child(3) {
|
||||
/* Phase 106 (task 08, D8): the Updated column sits at 3rd (BETWEEN
|
||||
Documents and Description) — it takes the .docs-table cell defaults
|
||||
(nowrap text, the table ink: var(--ink) on the wrap's --surface —
|
||||
13.8:1, the pair recorded at :root). NO new CSS: the one-line clamp
|
||||
below follows the Description cell, which moved to 4th. */
|
||||
.kb-folders-table td:nth-child(4) {
|
||||
white-space: nowrap; /* phase 99 (D1): one line — the clamp's budget is the column width */
|
||||
min-width: 18rem;
|
||||
max-width: 44rem;
|
||||
@@ -3637,10 +3644,11 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* Meta row: source badge · format badge · mono path · indexed · chunks.
|
||||
Phase 12/34: it may clip, but it must NEVER wrap — the meta stays on
|
||||
one line in the titlebar row (the row is content-sized, so a wrap
|
||||
would grow it). */
|
||||
/* Meta row: source badge · format badge · mono path · created ·
|
||||
indexed · chunks (phase 106 (task 08, D8) added created BEFORE
|
||||
indexed). Phase 12/34: it may clip, but it must NEVER wrap — the
|
||||
meta stays on one line in the titlebar row (the row is content-
|
||||
sized, so a wrap would grow it). */
|
||||
.doc-meta {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
@@ -3682,6 +3690,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
min-width: 6rem;
|
||||
}
|
||||
.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
|
||||
the titlebar's --surface — 5.1:1, the pair recorded at :root; the
|
||||
modal's .doc-modal-meta row records the same pair). The explicit
|
||||
declaration pins the inherited value: a themed --ink-soft moves the
|
||||
badge with the row (byte-identical rendering). */
|
||||
.doc-created { color: var(--ink-soft); /* 5.1:1 on --surface */ }
|
||||
|
||||
.doc-shell {
|
||||
display: flex;
|
||||
@@ -3856,6 +3871,133 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
}
|
||||
.doc-summary-status:empty { margin-top: 0; }
|
||||
|
||||
/* Date edit affordance (phase 106, task 09 — D7, admin-only): the
|
||||
"Edit date" text button after the Created badge (the
|
||||
.doc-summary-edit family — one pill style for both inline
|
||||
editors) and the editor box that swaps in for the button IN the
|
||||
.doc-meta row: the native type=date input, Save / Cancel, the
|
||||
muted underlined "Revert to sync" clear affordance (the phase-57
|
||||
"clear = explicit" contrast), a role=status live line, and a
|
||||
role=alert error line (the .git-source-error err pair). The meta
|
||||
row may clip (nowrap) on the page, so the box wraps ITS children
|
||||
internally (the modal's .doc-modal-meta already wraps). The
|
||||
global 3px :focus-visible outline rule applies — no per-control
|
||||
rule (the phase-105 checkbox idiom). :disabled = opacity +
|
||||
cursor: wait (the .git-source-remove:disabled idiom — one PATCH
|
||||
at a time). Contrast (verified, house style): button / Cancel /
|
||||
revert / status ride the row's ink-soft on --surface (5.1:1, AA);
|
||||
the input is ink on --bg (16.7:1, AA); Save is the solid brand
|
||||
pill (--bg on --brand = 5.2:1, AA); the error line is the err
|
||||
pair (9.1:1 on --err-bg, AA). Anonymous / token holders never see
|
||||
any of it — the wiring is admin-gated (docAdminReady in
|
||||
document.js), so the public badge row stays byte-for-byte the
|
||||
task-08 shape. */
|
||||
.doc-date-edit {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0.15rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-date-edit:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
|
||||
.doc-date-edit[hidden] { display: none; } /* the hidden attr must beat the display above */
|
||||
.doc-date-editor {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.doc-date-input {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--ink); /* 16.7:1 on --bg (AA) */
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
.doc-date-save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0.15rem 0.7rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--brand);
|
||||
color: var(--bg); /* --bg on --brand = 5.2:1 (AA) */
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-date-save:hover { background: var(--brand-hover); } /* the house hover lightening */
|
||||
.doc-date-cancel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0.15rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-date-cancel:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
.doc-date-revert {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0.15rem 0.2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ink-soft); /* 5.1:1 on --surface (AA) — the muted marker */
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-date-revert:hover { color: var(--ink); } /* 14.5:1 on --surface (AA) */
|
||||
.doc-date-save:disabled,
|
||||
.doc-date-cancel:disabled,
|
||||
.doc-date-revert:disabled,
|
||||
.doc-date-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait; /* one PATCH at a time (the .git-source-remove:disabled idiom) */
|
||||
}
|
||||
.doc-date-status {
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
|
||||
}
|
||||
.doc-date-error {
|
||||
flex-basis: 100%; /* drops onto its own row under the controls */
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere; /* a long server detail never overflows the box */
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink); /* 9.1:1 on --err-bg (AA — the .git-source-error pair) */
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.1rem 0.5rem;
|
||||
}
|
||||
.doc-date-status:empty, .doc-date-error:empty { display: none; } /* an empty live line takes no space */
|
||||
|
||||
/* Raw (non-markdown) formats: full-width mono pre, horizontal scroll. */
|
||||
.doc-raw {
|
||||
width: 100%;
|
||||
@@ -4039,10 +4181,11 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.doc-modal-close svg { width: 18px; height: 18px; display: block; }
|
||||
|
||||
/* Meta row: the SAME badge classes as the viewer's .doc-meta (source
|
||||
badge · format badge · mono path · indexed · chunks — no duplicate
|
||||
badge styling here); unlike the fixed-height header bar it may WRAP,
|
||||
so nothing clips. aria-live="polite" on the element announces the
|
||||
load → meta swap (phase-10 a11y contract, modal variant). */
|
||||
badge · format badge · mono path · created · indexed · chunks — no
|
||||
duplicate badge styling here); unlike the fixed-height header bar it
|
||||
may WRAP, so nothing clips. aria-live="polite" on the element
|
||||
announces the load → meta swap (phase-10 a11y contract, modal
|
||||
variant). */
|
||||
.doc-modal-meta {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -472,6 +472,13 @@
|
||||
<tr>
|
||||
<th scope="col">Folder</th>
|
||||
<th scope="col">Documents</th>
|
||||
<!-- Phase 106 (task 08, D8): the Updated column — the
|
||||
subtree's MAX document created_at (the tree API
|
||||
derives it — D9), BETWEEN Documents and
|
||||
Description (the owner's verbatim position). The
|
||||
"–" when a source has no documents (the statLast
|
||||
null idiom, sources.js). -->
|
||||
<th scope="col">Updated</th>
|
||||
<th scope="col">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -488,6 +495,12 @@
|
||||
<th scope="col">Path</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">Chunks</th>
|
||||
<!-- Phase 106 (task 08, D8): the Created column — the
|
||||
document's sourced creation date, BEFORE Indexed
|
||||
(the owner's verbatim position). sources.js builds
|
||||
the cell: locale date (fmtDate) + the full ISO
|
||||
value on the cell's title (hover precision). -->
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Indexed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Evaluate hybrid retrieval against the live knowledge base (phase 09).
|
||||
|
||||
Embeds each question via aipi, runs the same hybrid search the chat API
|
||||
uses (cosine top-N + FTS top-N, RRF-fused), and prints the top-5 documents
|
||||
with their cosine / fts / fused scores plus the honesty-gate verdict:
|
||||
uses (cosine top-N + FTS top-N, RRF-fused — plus the phase-106 recency
|
||||
boost, which ``retrieve()`` applies after the fusion), and prints the
|
||||
top-5 documents with their cosine / fts / fused scores (labelled
|
||||
``effective`` when the recency boost is on — the post-boost score)
|
||||
and the document's creation date, plus the honesty-gate verdict:
|
||||
|
||||
uv run python -m scripts.eval_retrieval "How did I install gitlab?"
|
||||
uv run python -m scripts.eval_retrieval --from-file questions.txt
|
||||
@@ -96,7 +99,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(
|
||||
f"eval: threshold={settings.relevance_threshold} "
|
||||
f"vector_candidates={settings.hybrid_vector_candidates} "
|
||||
f"lexical_candidates={settings.hybrid_lexical_candidates} rrf_k={settings.rrf_k}"
|
||||
f"lexical_candidates={settings.hybrid_lexical_candidates} rrf_k={settings.rrf_k} "
|
||||
f"recency_boost={settings.recency_boost} "
|
||||
f"recency_half_life_days={settings.recency_half_life_days}"
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
for question, vec in zip(questions, vectors, strict=True):
|
||||
@@ -116,10 +121,17 @@ def main(argv: list[str] | None = None) -> int:
|
||||
key = f"{c.document.source}/{c.document.path}"
|
||||
if key not in best_by_doc:
|
||||
best_by_doc[key] = c
|
||||
# Phase 106, D6: the post-recency-boost score is the
|
||||
# effective score ``retrieve()`` ranked by — labelled
|
||||
# ``effective`` while the boost is on (``fused`` when the
|
||||
# kill switch is off), with the document's creation date
|
||||
# alongside (the boost's input).
|
||||
score_label = "effective" if settings.recency_boost > 0 else "fused"
|
||||
for i, c in enumerate(list(best_by_doc.values())[: args.top], start=1):
|
||||
print(
|
||||
f" {i}. {c.document.source}/{c.document.path} "
|
||||
f"cosine={c.cosine:.4f} fts={int(c.fts_hit)} fused={c.score:.5f} "
|
||||
f"cosine={c.cosine:.4f} fts={int(c.fts_hit)} "
|
||||
f"{score_label}={c.score:.5f} created={c.document.created_at:%Y-%m-%d} "
|
||||
f"({c.document.title})"
|
||||
)
|
||||
return 0
|
||||
|
||||
+99
-2
@@ -11,15 +11,33 @@ used.
|
||||
|
||||
This module is the only place the ``git`` CLI is invoked (A11: stdlib
|
||||
``subprocess`` only, no new packages) — every git command goes through
|
||||
:func:`run_git`: the clone/pull in :func:`clone_or_pull` and the
|
||||
:func:`run_git`: the clone/pull in :func:`clone_or_pull`, the per-file
|
||||
last-commit-date walk in :func:`file_commit_dates` (phase 106), and the
|
||||
docs-push sequence in :mod:`app.core.docs_push` (phase 59).
|
||||
|
||||
Per-file last-commit dates (phase 106, D2/D10) — behavior verified
|
||||
against scratch repos 2026-09-13:
|
||||
|
||||
* a LOCAL-PATH checkout made by :func:`clone_or_pull` keeps FULL
|
||||
history (``git clone --depth 1 /local/path`` prints "--depth is
|
||||
ignored in local clones" and does not shallow) →
|
||||
:func:`file_commit_dates` yields TRUE per-file last-commit dates;
|
||||
* a URL-TRANSPORT checkout (https/ssh/``file://``) is shallow, and in a
|
||||
shallow clone git reports the TIP commit as every existing file's
|
||||
last commit (the shallow boundary is each file's history root) → a
|
||||
uniform per-repo tip date: no intra-repo distortion, a real
|
||||
cross-source signal, refreshed on every pull.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["GitSyncError", "clone_or_pull", "run_git"]
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["GitSyncError", "clone_or_pull", "file_commit_dates", "run_git"]
|
||||
|
||||
|
||||
class GitSyncError(RuntimeError):
|
||||
@@ -48,6 +66,85 @@ def clone_or_pull(url: str, dest: Path | str) -> Path:
|
||||
return dest
|
||||
|
||||
|
||||
def _parse_commit_dates(output: str) -> dict[str, datetime]:
|
||||
"""Parse ``git log --name-only --format=@@%cI`` output (newest first).
|
||||
|
||||
A ``@@<ISO-8601>`` line starts a commit (``%cI`` is ISO-strict, so
|
||||
the date is always aware — parsed with ``datetime.fromisoformat``);
|
||||
the following non-empty, non-``@@`` lines are repo-relative paths.
|
||||
The FIRST sighting of a path wins (the walk is newest-first) — that
|
||||
is the file's last-commit date. Paths are split on whitespace (like
|
||||
name-only output), ``\\``-normalized to ``/``, and a leading ``/``
|
||||
is stripped. Raises ``ValueError`` on a malformed commit date or a
|
||||
path line before any commit header (the caller fails soft).
|
||||
"""
|
||||
dates: dict[str, datetime] = {}
|
||||
commit: datetime | None = None
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("@@"):
|
||||
commit = datetime.fromisoformat(line[2:])
|
||||
continue
|
||||
if commit is None:
|
||||
raise ValueError(f"path line before any commit header: {line!r}")
|
||||
for raw_path in line.split():
|
||||
path = raw_path.replace("\\", "/").lstrip("/")
|
||||
if path:
|
||||
dates.setdefault(path, commit)
|
||||
return dates
|
||||
|
||||
|
||||
def file_commit_dates(dest: Path | str) -> dict[str, datetime]:
|
||||
"""Per-file last-commit dates for one checkout (phase 106, D2).
|
||||
|
||||
ONE ``git log --name-only --format=@@%cI`` walk through
|
||||
:func:`run_git` (the A11 single-invocation site, one git call per
|
||||
source per sync) → ``{repo-relative POSIX path: last-commit
|
||||
datetime}``, newest-first so the first sighting of a path wins.
|
||||
|
||||
The checkout behavior is pinned (verified 2026-09-13 — see the
|
||||
module docstring): a local-path ``clone_or_pull`` checkout keeps
|
||||
FULL history → TRUE per-file dates; a URL-transport checkout is
|
||||
shallow → the repo's TIP-commit date for every working-tree file
|
||||
(D10: uniform within the repo, real across sources).
|
||||
|
||||
Fail-soft (pinned): a missing/non-directory checkout, a git failure
|
||||
(:class:`GitSyncError`), or ANY parse anomaly logs a warning and
|
||||
returns ``{}`` — the importer falls back to file mtimes; a date
|
||||
walk must never break a sync.
|
||||
"""
|
||||
dest = Path(dest)
|
||||
if not dest.is_dir():
|
||||
logger.warning(
|
||||
"file_commit_dates: %s is not a directory — no git dates "
|
||||
"(the importer will fall back to file mtimes)",
|
||||
dest,
|
||||
)
|
||||
return {}
|
||||
try:
|
||||
output = run_git(["git", "log", "--name-only", "--format=@@%cI"], cwd=dest)
|
||||
except GitSyncError as exc:
|
||||
logger.warning(
|
||||
"file_commit_dates: git log failed for %s: %s — the importer "
|
||||
"will fall back to file mtimes",
|
||||
dest,
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
try:
|
||||
return _parse_commit_dates(output)
|
||||
except ValueError as exc:
|
||||
logger.warning(
|
||||
"file_commit_dates: unparseable git log output for %s (%s) — the "
|
||||
"importer will fall back to file mtimes",
|
||||
dest,
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
def run_git(argv: list[str], cwd: Path) -> str:
|
||||
"""Run one git command, capturing output; raise GitSyncError on failure.
|
||||
|
||||
|
||||
+45
-22
@@ -33,7 +33,12 @@ with no ignore. Phase 105 extends the same resolution with each row's
|
||||
``include_hidden`` flag — a second per-root map keyed by the same root
|
||||
strings (the importer reads it per root); manual ``--source`` dirs and
|
||||
the legacy fallback have no rows, so they import with the empty map
|
||||
(hidden paths skipped — A4).
|
||||
(hidden paths skipped — A4). Phase 106 (D2) extends it a third time
|
||||
with each GIT row's per-file last-commit dates — a map keyed by the
|
||||
same root strings, built from ``file_commit_dates`` after the clone;
|
||||
manual ``--source`` dirs and the legacy fallback have no rows (no
|
||||
clone), so they import with the empty map and take the importer's
|
||||
mtime fallback.
|
||||
|
||||
Imported formats (PLAN anchor A9, revised; phase 56): the A9 family by
|
||||
default — ``md, markdown, txt, yaml, yml, json, py`` plus the quadlet
|
||||
@@ -97,6 +102,7 @@ import asyncio
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
@@ -110,7 +116,7 @@ from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient
|
||||
from app.rag.overview import regenerate_overview
|
||||
from app.rag.sources_meta import bump_sources_version
|
||||
from scripts.git_sync import GitSyncError, clone_or_pull
|
||||
from scripts.git_sync import GitSyncError, clone_or_pull, file_commit_dates
|
||||
|
||||
logger = logging.getLogger("scripts.import_docs")
|
||||
|
||||
@@ -166,11 +172,16 @@ def repo_name(url: str) -> str:
|
||||
|
||||
|
||||
def _resolve_sources(
|
||||
cli_sources: list[Path] | None, settings: Settings
|
||||
) -> tuple[list[Path], dict[str, list[str]], dict[str, bool]]:
|
||||
cli_sources: list[Path] | None, settings: Settings,
|
||||
) -> tuple[
|
||||
list[Path],
|
||||
dict[str, list[str]],
|
||||
dict[str, bool],
|
||||
dict[str, dict[str, datetime]],
|
||||
]:
|
||||
"""Resolve the directories to import (phase 28, extended in phases
|
||||
35 and 38; per-root ignore maps, phase 89; per-root hidden-folders
|
||||
flag maps, phase 105).
|
||||
flag maps, phase 105; per-root source-date maps, phase 106).
|
||||
|
||||
Precedence: ``--source`` (explicit manual paths — always wins) >
|
||||
the effective sources — the ``git_sources`` DB rows (git + local),
|
||||
@@ -183,23 +194,28 @@ def _resolve_sources(
|
||||
stored directory, re-verified ``.is_dir()`` at run time) > the
|
||||
legacy ``DEFAULT_SOURCES``.
|
||||
|
||||
Returns ``(sources, ignore_by_root, include_hidden_by_root)``
|
||||
(phase 89; phase 105 adds the per-root flag map — the flag is
|
||||
stored per row, manual ``--source`` dirs and the legacy fallback
|
||||
have no rows and import with the empty map: hidden paths skipped,
|
||||
A4): both maps are keyed by the resolved root string, exactly as
|
||||
Returns ``(sources, ignore_by_root, include_hidden_by_root,
|
||||
doc_dates_by_root)`` (phase 89; phase 105 adds the per-root flag
|
||||
map — the flag is stored per row, manual ``--source`` dirs and the
|
||||
legacy fallback have no rows and import with the empty map: hidden
|
||||
paths skipped, A4; phase 106 adds the per-root source-date map):
|
||||
all three maps are keyed by the resolved root string, exactly as
|
||||
the importer sees it (two rows sharing a root string get the union
|
||||
— extend, not replace — for the ignore lists, and the OR of their
|
||||
flags for the hidden map); manual ``--source`` dirs and the legacy
|
||||
fallback have no rows, so they import with empty maps (no ignore,
|
||||
hidden skipped).
|
||||
— extend, not replace — for the ignore lists, the OR of their
|
||||
flags for the hidden map, and one date walk for the date map);
|
||||
the date map lists ONLY git roots (the ``file_commit_dates`` walk
|
||||
over the fresh checkout after the clone — fail-soft to ``{}``,
|
||||
which the importer reads as "no source dates, use mtimes"), and
|
||||
manual ``--source`` dirs and the legacy fallback have no rows, so
|
||||
they import with empty maps (no ignore, hidden skipped, mtime
|
||||
fallback).
|
||||
|
||||
A :class:`GitSyncError` from a failing clone/pull — or a missing
|
||||
local directory (``local source missing: <path>``) — propagates to
|
||||
:func:`main`, which aborts the run before importing anything.
|
||||
"""
|
||||
if cli_sources:
|
||||
return [path.expanduser() for path in cli_sources], {}, {}
|
||||
return [path.expanduser() for path in cli_sources], {}, {}, {}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows, origin = effective_sources(db)
|
||||
@@ -215,9 +231,14 @@ def _resolve_sources(
|
||||
sources: list[Path] = []
|
||||
ignore_by_root: dict[str, list[str]] = {}
|
||||
include_hidden_by_root: dict[str, bool] = {}
|
||||
doc_dates_by_root: dict[str, dict[str, datetime]] = {}
|
||||
for row in rows:
|
||||
if row.kind == "git":
|
||||
root = clone_or_pull(row.url, sources_root / repo_name(row.url))
|
||||
# Phase 106 (D2): the checkout's per-file last-commit
|
||||
# dates, keyed by the SAME root string the importer
|
||||
# sees; local rows contribute nothing (mtime fallback).
|
||||
doc_dates_by_root[str(root)] = file_commit_dates(root)
|
||||
else:
|
||||
# kind=local — the stored expanded path (phase 38 also
|
||||
# mirrors it in the NOT-NULL ``url`` location column, the
|
||||
@@ -242,8 +263,8 @@ def _resolve_sources(
|
||||
include_hidden_by_root.get(str(root), False)
|
||||
or bool(row.include_hidden)
|
||||
)
|
||||
return sources, ignore_by_root, include_hidden_by_root
|
||||
return [path.expanduser() for path in DEFAULT_SOURCES], {}, {}
|
||||
return sources, ignore_by_root, include_hidden_by_root, doc_dates_by_root
|
||||
return [path.expanduser() for path in DEFAULT_SOURCES], {}, {}, {}
|
||||
|
||||
|
||||
def _overview_row_exists() -> bool:
|
||||
@@ -283,12 +304,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# Git sources resolve (and clone/pull) *before* any import: a failing
|
||||
# repo aborts the run with a non-zero exit, naming the failure — a bad
|
||||
# URL must never silently import partial junk. The second element is
|
||||
# the phase-89 per-root ignore map and the third the phase-105
|
||||
# per-root hidden-folders flag map (both empty for manual/fallback
|
||||
# paths).
|
||||
# the phase-89 per-root ignore map, the third the phase-105
|
||||
# per-root hidden-folders flag map, and the fourth the phase-106
|
||||
# per-root source-date map (git rows only — empty for manual/
|
||||
# fallback paths, which take the importer's mtime fallback).
|
||||
try:
|
||||
sources, ignore_by_root, include_hidden_by_root = _resolve_sources(
|
||||
args.source, settings
|
||||
sources, ignore_by_root, include_hidden_by_root, doc_dates_by_root = (
|
||||
_resolve_sources(args.source, settings)
|
||||
)
|
||||
except GitSyncError as e:
|
||||
print(f"import_docs: source sync failed: {e}", file=sys.stderr)
|
||||
@@ -356,6 +378,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
sources, llm, prune=args.prune, limit=args.limit,
|
||||
ignore_by_root=ignore_by_root,
|
||||
include_hidden_by_root=include_hidden_by_root,
|
||||
doc_dates_by_root=doc_dates_by_root,
|
||||
)
|
||||
if args.limit is not None:
|
||||
# An incomplete walk is debug-only — it must never advance
|
||||
|
||||
@@ -48,6 +48,16 @@ os.environ["BOR_SUGGESTIONS"] = json.dumps(_Settings.model_fields["suggestions"]
|
||||
os.environ["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
|
||||
os.environ["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
|
||||
|
||||
# Phase 106 (task 07): the same leak class for the recency-boost
|
||||
# settings — an operator's local ``.env`` may legitimately carry
|
||||
# ``BOR_RECENCY_BOOST`` / ``BOR_RECENCY_HALF_LIFE_DAYS`` re-tuned live,
|
||||
# and the fine-line integration battery pins the CODE DEFAULTS
|
||||
# (derived from the class fields, same pattern as the lines above).
|
||||
os.environ["BOR_RECENCY_BOOST"] = str(_Settings.model_fields["recency_boost"].default)
|
||||
os.environ["BOR_RECENCY_HALF_LIFE_DAYS"] = str(
|
||||
_Settings.model_fields["recency_half_life_days"].default
|
||||
)
|
||||
|
||||
from app.db import SessionLocal, db_available # noqa: E402
|
||||
from app.main import app as fastapi_app # noqa: E402
|
||||
|
||||
|
||||
@@ -779,12 +779,16 @@ assert _ALREADY_IN_CONTEXT_MARKER in ALREADY_IN_CONTEXT, (
|
||||
|
||||
#: One ``<document>`` block of the HIGH prompt's ``<documents>``
|
||||
#: section (``app.rag.prompts.build_high_prompt``): the block is the
|
||||
#: document identity (``source``/``path``/``title`` attributes) plus
|
||||
#: the document's FULL text (never truncated on the retrieval path,
|
||||
#: owner-locked A7) between the tags.
|
||||
#: document identity (``source``/``path``/``title`` attributes — plus,
|
||||
#: since phase 106 D5, the ``date`` attribute, the row's ``created_at``
|
||||
#: UTC date part, APPENDED after ``title``) plus the document's FULL
|
||||
#: text (never truncated on the retrieval path, owner-locked A7)
|
||||
#: between the tags. The ``date`` group is OPTIONAL so the mock
|
||||
#: tolerates the pre- and post-phase block shapes (house rule: the
|
||||
#: marker/regex lands with the prompt change).
|
||||
_DOCUMENT_BLOCK_RE = re.compile(
|
||||
r'<document source="(?P<source>[^"]+)" path="(?P<path>[^"]+)" '
|
||||
r'title="[^"]*">\n(?P<content>.*?)\n</document>',
|
||||
r'title="[^"]*"(\sdate="[^"]*")?>\n(?P<content>.*?)\n</document>',
|
||||
re.S,
|
||||
)
|
||||
|
||||
|
||||
@@ -116,9 +116,9 @@ ROUTE53_CONTENT = (
|
||||
)
|
||||
|
||||
#: The referenced document: the exact JSON shape. Its FIRST line is longer
|
||||
#: than 80 chars, so the mock's first-80-chars quote is newline-free (the
|
||||
#: rendered-text assertions below match it verbatim). Pinned by the assert
|
||||
#: below.
|
||||
#: than 80 chars, so the mock's first-80-chars quote content part is
|
||||
#: newline-free (the rendered-text assertions below match it after the
|
||||
#: date line). Pinned by the assert below.
|
||||
RECORD_FILE_CONTENT = (
|
||||
'{ "version": 3, "comment": "ReeseLink hosted zone records — the exact '
|
||||
'JSON shape of reeselink.json",\n'
|
||||
@@ -131,7 +131,7 @@ RECORD_FILE_CONTENT = (
|
||||
" ]\n"
|
||||
"}\n"
|
||||
)
|
||||
assert "\n" not in RECORD_FILE_CONTENT[:80] # the quote must stay one line
|
||||
assert "\n" not in RECORD_FILE_CONTENT[:63] # the quote's content part stays one line
|
||||
|
||||
MARKER_QUESTION = (
|
||||
"Use your tools: what is the exact JSON shape of reeselink.json "
|
||||
@@ -145,8 +145,13 @@ DEFLECT_QUESTION = "tell me about quantum wormhole cooling"
|
||||
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
DEFLECT_PHRASE = r"haven't done anything like that"
|
||||
#: Phase 106 (D5): the read result's ``date:`` SECOND line rides into
|
||||
#: the mock's first-80-chars quote — the quote is the date line (the
|
||||
#: fixture's fixed ``created_at`` UTC date part, 17 chars; its trailing
|
||||
#: newline renders as a markdown soft break — no text between the date
|
||||
#: and the content) + the first 63 content chars (80 − 17).
|
||||
ANSWER_PREFIX = f"Read {READ_SP}."
|
||||
ANSWER_QUOTE = RECORD_FILE_CONTENT[:80]
|
||||
ANSWER_QUOTE = "date: 2024-06-15" + RECORD_FILE_CONTENT[:63]
|
||||
READ_CHIP_HREF = f"/document.html?source={READ_SOURCE}&path={READ_PATH}&back=%2F"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -179,6 +184,9 @@ def _seed(db: Session) -> None:
|
||||
content=ROUTE53_CONTENT,
|
||||
content_hash=hashlib.sha256(ROUTE53_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
# Phase 106 (D5): explicit dates — byte-stable prompts/quotes
|
||||
# (the mock's first-80-chars read quote carries the date line).
|
||||
created_at=datetime(2024, 6, 15, tzinfo=UTC),
|
||||
)
|
||||
db.add(md)
|
||||
db.flush()
|
||||
@@ -203,6 +211,7 @@ def _seed(db: Session) -> None:
|
||||
content=RECORD_FILE_CONTENT,
|
||||
content_hash=hashlib.sha256(RECORD_FILE_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=datetime(2024, 6, 15, tzinfo=UTC),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -134,8 +134,9 @@ ROUTE53_CONTENT = (
|
||||
)
|
||||
|
||||
#: Read #1: the JSON shape. Its FIRST line is longer than 80 chars, so
|
||||
#: the mock's first-80-chars quote is newline-free (the rendered-text
|
||||
#: assertion matches it verbatim). Pinned by the assert below.
|
||||
#: the mock's first-80-chars quote content part is newline-free (the
|
||||
#: rendered-text assertion matches it after the phase-106 D5 date
|
||||
#: line). Pinned by the assert below.
|
||||
RECORD_CONTENT = (
|
||||
'{"version": 4, "comment": "ReeseLink hosted zone records — the exact '
|
||||
'JSON shape of reeselink.json",\n'
|
||||
@@ -145,7 +146,7 @@ RECORD_CONTENT = (
|
||||
' ]\n'
|
||||
"}\n"
|
||||
)
|
||||
assert "\n" not in RECORD_CONTENT[:80] # the quote must stay one line
|
||||
assert "\n" not in RECORD_CONTENT[:63] # the quote's content part stays one line
|
||||
|
||||
#: Read #2: the sync runbook.
|
||||
RUNBOOK_CONTENT = (
|
||||
@@ -174,9 +175,14 @@ assert "read two documents" not in SINGLE_QUESTION.lower()
|
||||
|
||||
#: The mock's byte-stable multi-read answer pieces (mock_llm
|
||||
#: ``_tool_flow``): the single-read shape quoting the FIRST read result,
|
||||
#: plus both read paths in read order.
|
||||
#: plus both read paths in read order. Phase 106 (D5): the read
|
||||
#: result's ``date:`` SECOND line rides into the first-80-chars quote —
|
||||
#: the date line (the fixture's fixed ``created_at`` UTC date part,
|
||||
#: 17 chars; its trailing newline renders as a markdown soft break —
|
||||
#: no text between the date and the content) + the first 63 content
|
||||
#: chars (80 − 17).
|
||||
ANSWER_PREFIX = f"Read {READ1_SP}."
|
||||
ANSWER_QUOTE = RECORD_CONTENT[:80]
|
||||
ANSWER_QUOTE = "date: 2024-06-15" + RECORD_CONTENT[:63]
|
||||
BOTH_READS_LINE = f"I read {READ1_SP} and {READ2_SP}."
|
||||
|
||||
#: The pre-phase-45 budget refusals (phase 37 ``LIST_EXHAUSTED`` /
|
||||
@@ -213,6 +219,9 @@ def _doc(source: str, path: str, title: str, content: str) -> Document:
|
||||
content=content,
|
||||
content_hash=hashlib.sha256(content.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
# Phase 106 (D5): explicit dates — byte-stable prompts/quotes
|
||||
# (the mock's first-80-chars read quote carries the date line).
|
||||
created_at=datetime(2024, 6, 15, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -170,9 +170,10 @@ ROUTE53_CONTENT = (
|
||||
)
|
||||
|
||||
#: The referenced document: the exact JSON shape. Its FIRST line is longer
|
||||
#: than 80 chars, so the mock's first-80-chars quote is newline-free (the
|
||||
#: rendered-text assertions below match it verbatim). Pinned by the
|
||||
#: assert below (phase 37's pin, kept).
|
||||
#: than 80 chars, so the mock's first-80-chars quote content part is
|
||||
#: newline-free (the rendered-text assertions below match it after the
|
||||
#: phase-106 D5 date line). Pinned by the assert below (phase 37's pin,
|
||||
#: kept).
|
||||
RECORD_FILE_CONTENT = (
|
||||
'{ "version": 3, "comment": "ReeseLink hosted zone records — the exact '
|
||||
'JSON shape of reeselink.json",\n'
|
||||
@@ -185,7 +186,7 @@ RECORD_FILE_CONTENT = (
|
||||
" ]\n"
|
||||
"}\n"
|
||||
)
|
||||
assert "\n" not in RECORD_FILE_CONTENT[:80] # the quote must stay one line
|
||||
assert "\n" not in RECORD_FILE_CONTENT[:63] # the quote's content part stays one line
|
||||
|
||||
#: Phase 37's exact marker question — carries the mock trigger phrase
|
||||
#: "use your tools" (case-insensitive ``TOOLS_TRIGGER``) and is grounded
|
||||
@@ -198,8 +199,13 @@ MARKER_QUESTION = (
|
||||
|
||||
#: The mock's deterministic answer for the single-read flow (the
|
||||
#: phase-37 shapes, kept): "Read <source/path>. <first 80 chars>".
|
||||
#: Phase 106 (D5): the read result's ``date:`` SECOND line rides into
|
||||
#: the first-80-chars quote — the date line (the fixture's fixed
|
||||
#: ``created_at`` UTC date part, 17 chars; its trailing newline renders
|
||||
#: as a markdown soft break — no text between the date and the content)
|
||||
#: + the first 63 content chars (80 − 17).
|
||||
ANSWER_PREFIX = f"Read {READ_SP}."
|
||||
ANSWER_QUOTE = RECORD_FILE_CONTENT[:80]
|
||||
ANSWER_QUOTE = "date: 2024-06-15" + RECORD_FILE_CONTENT[:63]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Timeouts — every wait carries ≥2× headroom on its expected duration
|
||||
@@ -391,6 +397,9 @@ def _seed(db: Session) -> None:
|
||||
content=ROUTE53_CONTENT,
|
||||
content_hash=hashlib.sha256(ROUTE53_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
# Phase 106 (D5): explicit dates — byte-stable prompts/quotes
|
||||
# (the mock's first-80-chars read quote carries the date line).
|
||||
created_at=datetime(2024, 6, 15, tzinfo=UTC),
|
||||
)
|
||||
db.add(md)
|
||||
db.flush()
|
||||
@@ -416,6 +425,7 @@ def _seed(db: Session) -> None:
|
||||
content=RECORD_FILE_CONTENT,
|
||||
content_hash=hashlib.sha256(RECORD_FILE_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=datetime(2024, 6, 15, tzinfo=UTC),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,765 @@
|
||||
"""Phase 106 task 10 E2E (Playwright): document dates end to end —
|
||||
sourced at import, displayed in the UI, editable by the admin, and
|
||||
recency-weighted in retrieval.
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_document_dates.py -v --no-cov
|
||||
|
||||
The whole owner item (2026-09-13) through the REAL page + REAL API +
|
||||
REAL importer (the deterministic mock LLM — token-overlap embeddings,
|
||||
so the cosine/retrieval behavior is production-shaped). No git, no
|
||||
network: a dedicated local fixture tree built ONCE per module under
|
||||
``tmp_path_factory`` (``os.utime``'d — NEVER the shared
|
||||
``tests/fixtures/docs``, whose 13-file counts are pinned by other
|
||||
suites):
|
||||
|
||||
* ``backups/retention.md`` utime 2020-01-01 03:04:06Z — THE
|
||||
CORRECT answer, rich in the question's tokens
|
||||
* ``backups/retention-draft.md`` utime now (default mtime) — the
|
||||
SIMILAR-but-wrong doc: shares the "backup retention policy" wording,
|
||||
concludes "under review, no decision yet"
|
||||
* ``legacy/old-doc.md`` utime 2019-06-15 — single-doc folder
|
||||
(a clean folder-``Updated`` max: the 2019 date alone)
|
||||
* ``future/forward.md`` utime 2999-01-01 — the future-date
|
||||
case (→ today, D3)
|
||||
|
||||
Test → contract mapping (six tests, one per contract bullet):
|
||||
|
||||
1. ``test_dates_landed_on_import`` — the real importer sources every
|
||||
date: the 2020/2019 utimes land verbatim on ``/api/docs`` and the
|
||||
tree's file nodes, the 2999 mtime FOLDS to today (D3), and the
|
||||
folder/source ``updated_at`` (D9) is the subtree MAX (legacy = the
|
||||
2019 date alone, the source = the now-side max). ``indexed_at``
|
||||
keeps its meaning (≈ import time; after the created dates on the
|
||||
old docs).
|
||||
2. ``test_file_and_folder_columns`` — the RAG view's file table header
|
||||
order ``… Chunks · Created · Indexed`` (D8) and folder table header
|
||||
order ``Folder · Documents · Updated · Description`` (D8), the
|
||||
drilled-in row's Created cell (locale date text + the FULL ISO on
|
||||
the cell's ``title`` — locale-stable), the top-level source row's
|
||||
non-empty Updated cell, and the drilled ``legacy`` row's Updated
|
||||
cell carrying the 2019 date.
|
||||
3. ``test_viewer_shows_date_at_top`` — clicking the row opens the
|
||||
same-page modal (phase 26): the top meta row carries a
|
||||
``Created …`` badge whose ``title`` is the 2020 ISO, DOM-prior to
|
||||
the ``Indexed`` badge (D8 — the date at the top of the clicked
|
||||
document), with the source/format/indexed/chunks badges intact.
|
||||
4. ``test_old_correct_beats_new_similar`` — THE OWNER SCENARIO end to
|
||||
end: the real retriever + the DEFAULT recency boost (0.0007 / 365 d)
|
||||
over the mock's token-overlap embeddings ranks the OLDER correct
|
||||
document as the first cited source over the newer similar one.
|
||||
5. ``test_date_edit_and_sync_preserves`` — the admin-only editor in
|
||||
the real UI: set → Save → the badge re-renders from the RESPONSE
|
||||
(never the optimistic input) → the API round-trips; a re-import
|
||||
keeps the correction (the manual flag, D1) while the siblings
|
||||
refresh; ``Revert to sync`` drops the flag and the NEXT import
|
||||
re-sources the date from the mtime.
|
||||
6. ``test_anonymous_gate_and_editor_a11y`` — anonymous: the RAG view
|
||||
shows the sign-in gate (no tables) and a raw
|
||||
``PATCH /api/documents/date`` 403s with the stored date
|
||||
untouched; admin: the editor's accessible names, keyboard
|
||||
reachability (Tab from the focused input), the ``role=status`` /
|
||||
``role=alert`` live lines, and the badge's text+format pairing
|
||||
(never color alone — B5).
|
||||
|
||||
The fixture wording (pinned): the owner-scenario geometry under the
|
||||
MOCK embeddings (bag-of-token md5 buckets, DIM=768) is fully
|
||||
deterministic for fixed text — measured with
|
||||
``app.rag.retriever._vector_candidates`` / ``_lexical_candidates`` /
|
||||
``retrieve()`` against a real Postgres + the mock:
|
||||
|
||||
* ``retention.md`` (correct, 2020) — rank 1 in BOTH lists (cosine
|
||||
0.6222; the lexical tsquery ``how|did|i|configure|backup|retention|
|
||||
policy`` after stopword removal matches it most densely).
|
||||
* ``retention-draft.md`` (similar, now) — rank 4 in the vector list
|
||||
(cosine 0.1443) and rank 2 in the lexical list. Its wording was
|
||||
tuned for exactly this: it shares ONLY the three "backup retention
|
||||
policy" question tokens (no "the"/"how"/"i"/"configure" filler —
|
||||
those inflate the mock cosine) and none of its filler words hash
|
||||
into a question bucket (the md5 collisions add ~0.054 each — the
|
||||
first two drafts, with "the" ×3 and four colliding words, sat at
|
||||
cosine 0.36–0.43 and LOSE to the boost: the RRF rank-adjacency gap
|
||||
is only 1/61−1/62 ≈ 0.00026 < the 0.0007 zero-age boost).
|
||||
* ``legacy/old-doc.md`` (cosine 0.1936) and ``future/forward.md``
|
||||
(0.1875) rank 2–3 in the vector list (unrelated content) and match
|
||||
the tsquery not at all.
|
||||
|
||||
Fused (RRF k=60) + the default boost (0.0007 · exp(−age/365d)):
|
||||
retention.md 1/61+1/61 = 0.0327878 (+ ≈ 0, 6.7 half-lives old) vs
|
||||
retention-draft.md 1/64+1/62 = 0.0317540 (+ the FULL zero-age 0.0007
|
||||
= 0.0324540) → the older correct doc wins by 0.000334 WITH the boost
|
||||
on (it would win by 0.001034 with the boost off — the scenario holds
|
||||
both ways; the boost never lets the newer similar doc outrank the one
|
||||
that answers the question). ``select_documents`` (top-2) cites
|
||||
retention.md first, the draft second. The boost defaults are owned by
|
||||
task 07 — untouched here.
|
||||
|
||||
DB isolation: every test TRUNCATEs the KB tables (the
|
||||
``test_retrieval_quality.py`` ``_reset_db`` pattern, extended with
|
||||
``folder_summaries`` / ``kb_overview`` — this suite's tree assertions
|
||||
must not see other runs' rows) and re-imports the module tree in a
|
||||
worker thread (Playwright owns the test loop).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import Document, QueryLog
|
||||
from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient
|
||||
from e2e.auth_helpers import login
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fixture constants (deterministic — see the module docstring's geometry
|
||||
# record before touching the wording)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
RETENTION_MD = "backups/retention.md"
|
||||
DRAFT_MD = "backups/retention-draft.md"
|
||||
OLDDOC_MD = "legacy/old-doc.md"
|
||||
FORWARD_MD = "future/forward.md"
|
||||
|
||||
#: The pinned sourced dates (D2: local-dir sources → the file mtime).
|
||||
RETENTION_ISO = "2020-01-01T03:04:06+00:00"
|
||||
OLDDOC_ISO = "2019-06-15T00:00:00+00:00"
|
||||
EDITED_ISO = "2021-05-05T00:00:00+00:00"
|
||||
|
||||
QUESTION = "How did I configure the backup retention policy?"
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
|
||||
#: THE CORRECT answer — rich in the question's tokens ("backup
|
||||
#: retention policy", "configure(d)", "How I").
|
||||
RETENTION_TEXT = """\
|
||||
# Backup retention policy
|
||||
|
||||
The backup retention policy is 30 days; snapshots are pruned nightly.
|
||||
|
||||
How I configured the retention policy:
|
||||
|
||||
- Configure the backup retention window: 30 days of daily snapshots.
|
||||
- Retention is set in backups.conf: `retention_days=30`.
|
||||
- I configured the nightly cron to prune expired snapshots.
|
||||
- The retention policy keeps 30 days, then prunes the rest.
|
||||
"""
|
||||
|
||||
#: The SIMILAR-but-wrong doc — shares the "backup retention policy"
|
||||
#: wording, concludes "under review, no decision yet". Worded so the
|
||||
#: mock cosine stays at 0.1443 (vector rank 4 — BELOW the two
|
||||
#: unrelated docs): only the three shared question tokens, no
|
||||
#: "the"/"how"/"i"/"configure" filler, and no filler word hashing into
|
||||
#: a question bucket (see the module docstring).
|
||||
DRAFT_TEXT = """\
|
||||
# Parking note
|
||||
|
||||
Workshop parking note: mop leans against door, oil stains mark floor, loose
|
||||
hinge squeals, spare fuses sit in tin box, cobwebs hang from rafters,
|
||||
cracked stool leg lies near bench. Meanwhile backup retention policy is
|
||||
under review, no decision yet — maybe weekly archives someday.
|
||||
"""
|
||||
|
||||
#: Unrelated legacy note (single-doc folder — the clean Updated max).
|
||||
OLDDOC_TEXT = """\
|
||||
# Legacy router config
|
||||
|
||||
The old router used a static route table with a single upstream link.
|
||||
It was retired when the new switch arrived.
|
||||
"""
|
||||
|
||||
#: Unrelated memo with a FUTURE mtime (2999-01-01 → folds to today, D3).
|
||||
FORWARD_TEXT = """\
|
||||
# Forward planning memo
|
||||
|
||||
A memo about planning next year's hardware refresh for the lab.
|
||||
The list includes a new switch, shelves, and cabling.
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fixture tree (module-scoped — built ONCE, utime'd)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mkdocs(root: Path, rel: str, body: str, when: datetime | None) -> None:
|
||||
"""Write one fixture file under *root*; *when* (aware datetime)
|
||||
backdates its mtime via ``os.utime`` (None → the build time)."""
|
||||
p = root / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(body, encoding="utf-8")
|
||||
if when is not None:
|
||||
os.utime(p, (when.timestamp(), when.timestamp()))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def dates_tree(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""The dedicated fixture tree (see the module docstring). The
|
||||
directory NAME is the source name (``kind=local`` → the directory's
|
||||
basename, phase 38) — distinctive, never asserted by absolute
|
||||
counts elsewhere."""
|
||||
root = tmp_path_factory.mktemp("bor_document_dates")
|
||||
_mkdocs(root, RETENTION_MD, RETENTION_TEXT,
|
||||
datetime(2020, 1, 1, 3, 4, 6, tzinfo=UTC))
|
||||
_mkdocs(root, DRAFT_MD, DRAFT_TEXT, None) # utime = now (default mtime)
|
||||
_mkdocs(root, OLDDOC_MD, OLDDOC_TEXT,
|
||||
datetime(2019, 6, 15, tzinfo=UTC))
|
||||
_mkdocs(root, FORWARD_MD, FORWARD_TEXT,
|
||||
datetime(2999, 1, 1, tzinfo=UTC))
|
||||
assert (root / RETENTION_MD).is_file() and (root / OLDDOC_MD).is_file()
|
||||
return root
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Importer + DB helpers (test_retrieval_quality.py scaffolding)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _import_tree(mock_port: int, tree: Path) -> ImportSummary:
|
||||
"""The REAL importer over the module tree (mock embeddings)."""
|
||||
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]
|
||||
return await import_sources([tree], LLMClient(settings))
|
||||
|
||||
|
||||
def _run_in_thread(coro: Any) -> Any:
|
||||
"""Run a coroutine on a worker thread (Playwright owns the test loop)."""
|
||||
box: dict[str, Any] = {}
|
||||
|
||||
def runner() -> None:
|
||||
try:
|
||||
box["value"] = asyncio.run(coro)
|
||||
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
||||
box["error"] = e
|
||||
|
||||
t = Thread(target=runner)
|
||||
t.start()
|
||||
t.join()
|
||||
if "error" in box:
|
||||
raise box["error"]
|
||||
return box["value"]
|
||||
|
||||
|
||||
def _reset_db(mock_port: int, tree: Path) -> ImportSummary:
|
||||
"""Truncate the KB (and the per-run derived tables), then import
|
||||
the module tree fresh (the house ``_reset_db`` pattern, extended
|
||||
with ``folder_summaries`` / ``kb_overview``)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text(
|
||||
"TRUNCATE chunks, documents, query_log, folder_summaries, "
|
||||
"kb_overview"
|
||||
))
|
||||
db.commit()
|
||||
return _run_in_thread(_import_tree(mock_port, tree))
|
||||
|
||||
|
||||
def _ask(page: Page, message: str) -> None:
|
||||
page.fill("#message-input", message)
|
||||
page.click("#send-btn")
|
||||
|
||||
|
||||
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||
"""The signed session cookie jar the form login left in the
|
||||
browser context (the test_retrieval_quality idiom)."""
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in page.context.cookies()
|
||||
if "name" in c and "value" in c
|
||||
}
|
||||
|
||||
|
||||
def _docs_by_path(app_url: str, cookies: dict[str, str]) -> dict[str, dict[str, Any]]:
|
||||
r = httpx.get(f"{app_url}/api/docs", timeout=10, cookies=cookies)
|
||||
assert r.status_code == 200, r.text
|
||||
return {d["path"]: d for d in r.json()["documents"]}
|
||||
|
||||
|
||||
def _tree(app_url: str, cookies: dict[str, str]) -> dict[str, Any]:
|
||||
r = httpx.get(f"{app_url}/api/docs/tree", timeout=10, cookies=cookies)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _source_node(tree_json: dict[str, Any], source: str) -> dict[str, Any]:
|
||||
for s in tree_json["sources"]:
|
||||
if s["name"] == source:
|
||||
return s
|
||||
raise AssertionError(f"source {source!r} not in the tree")
|
||||
|
||||
|
||||
def _folder_node(node: dict[str, Any], path: str) -> dict[str, Any]:
|
||||
for child in node.get("children", []):
|
||||
if child.get("kind") == "folder" and child["path"] == path:
|
||||
return child
|
||||
raise AssertionError(f"folder {path!r} not under the node")
|
||||
|
||||
|
||||
def _find_file(node: dict[str, Any], path: str) -> dict[str, Any]:
|
||||
"""The file node with *path* anywhere under *node* (recursing into
|
||||
the folder children — files sit at their folder's level, not the
|
||||
source root's)."""
|
||||
for child in node.get("children", []):
|
||||
if child.get("kind") == "file" and child["path"] == path:
|
||||
return child
|
||||
if child.get("kind") == "folder":
|
||||
try:
|
||||
return _find_file(child, path)
|
||||
except AssertionError:
|
||||
continue
|
||||
raise AssertionError(f"file {path!r} not under the node")
|
||||
|
||||
|
||||
def _today_utc() -> str:
|
||||
return datetime.now(UTC).date().isoformat()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Dates landed on import (D2 sourced, D3 normalized, D9 derived)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dates_landed_on_import(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None, dates_tree: Path
|
||||
) -> None:
|
||||
"""The real importer sources every created_at: the 2020/2019 utimes
|
||||
verbatim, the 2999 mtime folded to today (D3), and the
|
||||
folder/source updated_at = the subtree MAX (D9)."""
|
||||
summary = _reset_db(mock_llm, dates_tree)
|
||||
assert summary.added == 4 and summary.errors == 0
|
||||
assert summary.dates_updated == 0 # a fresh import adds, never refreshes
|
||||
|
||||
# The catalog is admin-only — perform the REAL form login, then call
|
||||
# the API with the signed cookie the browser now holds.
|
||||
login(page, app_url)
|
||||
cookies = _admin_cookies(page)
|
||||
docs = _docs_by_path(app_url, cookies)
|
||||
today = _today_utc()
|
||||
|
||||
# D2 (mtime sourcing) — the backdated utimes land verbatim…
|
||||
assert docs[RETENTION_MD]["created_at"] == RETENTION_ISO
|
||||
assert docs[OLDDOC_MD]["created_at"] == OLDDOC_ISO
|
||||
# …the default mtime (now) lands as today…
|
||||
assert docs[DRAFT_MD]["created_at"][:10] == today
|
||||
# …and the FUTURE mtime (2999) folds to today (D3, owner rule).
|
||||
assert docs[FORWARD_MD]["created_at"][:10] == today
|
||||
|
||||
# indexed_at keeps its meaning (the INDEX time, phase 1): ≈ import
|
||||
# time on every row, and strictly AFTER the sourced created date on
|
||||
# the old docs (a year+ apart — the two concepts do not blur).
|
||||
now = datetime.now(UTC)
|
||||
for path, doc in docs.items():
|
||||
indexed = datetime.fromisoformat(doc["indexed_at"])
|
||||
assert abs((now - indexed).total_seconds()) < 15 * 60, path
|
||||
assert datetime.fromisoformat(docs[RETENTION_MD]["indexed_at"]) > \
|
||||
datetime.fromisoformat(RETENTION_ISO)
|
||||
assert datetime.fromisoformat(docs[OLDDOC_MD]["indexed_at"]) > \
|
||||
datetime.fromisoformat(OLDDOC_ISO)
|
||||
|
||||
# The tree (D9): file nodes carry the SAME dates verbatim; the
|
||||
# legacy folder's updated_at is its single doc's 2019 date (a clean
|
||||
# max); the source's updated_at is the whole subtree's max (the
|
||||
# now-side — the future-folded doc, refreshed at import).
|
||||
tree = _tree(app_url, cookies)
|
||||
source = _source_node(tree, dates_tree.name)
|
||||
assert source["documents"] == 4
|
||||
assert source["updated_at"] is not None
|
||||
assert source["updated_at"][:10] == today
|
||||
assert _find_file(source, RETENTION_MD)["created_at"] == RETENTION_ISO
|
||||
assert _find_file(source, OLDDOC_MD)["created_at"] == OLDDOC_ISO
|
||||
assert _find_file(source, DRAFT_MD)["created_at"][:10] == today
|
||||
assert _find_file(source, FORWARD_MD)["created_at"][:10] == today
|
||||
legacy = _folder_node(source, "legacy")
|
||||
assert legacy["documents"] == 1
|
||||
assert legacy["updated_at"] is not None
|
||||
assert legacy["updated_at"][:10] == "2019-06-15" # the 2019 date alone
|
||||
backups = _folder_node(source, "backups")
|
||||
assert backups["documents"] == 2
|
||||
assert backups["updated_at"] is not None
|
||||
assert backups["updated_at"][:10] == today # max(2020, now) = the now side
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The UI columns: Created before Indexed; Updated between Documents
|
||||
# and Description (D8 positions, verbatim)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_file_and_folder_columns(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None, dates_tree: Path
|
||||
) -> None:
|
||||
"""The RAG view's file table header order ``… Chunks · Created ·
|
||||
Indexed`` and folder table header order ``Folder · Documents ·
|
||||
Updated · Description`` (D8), plus the drilled-in date cells."""
|
||||
_reset_db(mock_llm, dates_tree)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url) # lands on /sources.html (the RAG view, admin)
|
||||
|
||||
# Header order (D8, verbatim) — both tables, the <th> sequence.
|
||||
# text_content() (NOT inner_text()): the rendered <th>s are
|
||||
# CSS-uppercased (`.docs-table th { text-transform: uppercase }`),
|
||||
# and the SOURCE text is the contract.
|
||||
file_headers = [
|
||||
th.text_content() for th in page.locator("#docs-table thead th").all()
|
||||
]
|
||||
assert file_headers == ["Source", "Path", "Title", "Chunks", "Created", "Indexed"]
|
||||
folder_headers = [
|
||||
th.text_content() for th in page.locator("#folders-table thead th").all()
|
||||
]
|
||||
assert folder_headers == ["Folder", "Documents", "Updated", "Description"]
|
||||
|
||||
# Top level: the source row's Updated cell is non-empty (the
|
||||
# subtree max — the now side; "–" only for a 0-document source).
|
||||
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
||||
source_row = page.locator(
|
||||
f'#folders-tbody tr:has(a.folder-link:text-is("{dates_tree.name}"))'
|
||||
)
|
||||
expect(source_row).to_have_count(1)
|
||||
source_updated = source_row.locator("td:nth-child(3)")
|
||||
expect(source_updated).to_have_text(re.compile(r"\S"), timeout=15_000)
|
||||
source_title = source_updated.get_attribute("title", timeout=15_000)
|
||||
assert source_title is not None and source_title.startswith(_today_utc())
|
||||
|
||||
# Drill into the source: the three folders list (no direct files).
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{dates_tree.name}")')
|
||||
expect(page.locator("#folders-tbody .folder-link")).to_have_count(3)
|
||||
|
||||
# The legacy folder row's Updated cell carries the 2019 date (the
|
||||
# single-doc max — D9 end to end through the UI).
|
||||
legacy_row = page.locator('#folders-tbody tr:has(a.folder-link:text-is("legacy"))')
|
||||
expect(legacy_row).to_have_count(1)
|
||||
legacy_updated = legacy_row.locator("td:nth-child(3)")
|
||||
expect(legacy_updated).to_have_text(re.compile("2019"))
|
||||
legacy_title = legacy_updated.get_attribute("title", timeout=15_000)
|
||||
assert legacy_title is not None and legacy_title.startswith("2019-06-15")
|
||||
|
||||
# Drill into backups: the file table rows. retention.md's Created
|
||||
# cell — the FULL ISO on the cell's title (task 08's locale-stable
|
||||
# idiom: the test pins the title, never the toLocaleString output),
|
||||
# and the browser's local-time rendering in the text. The local YEAR
|
||||
# of the ISO instant is computed here (the host and the headless
|
||||
# browser share the host timezone — a negative-offset timezone
|
||||
# renders 2020-01-01T03:04Z as "12/31/2019, 10:04 PM").
|
||||
page.click('#folders-tbody a.folder-link:text-is("backups")')
|
||||
row = page.locator("#docs-tbody tr", has_text=RETENTION_MD)
|
||||
expect(row).to_have_count(1)
|
||||
created_cell = row.locator("td:nth-child(5)")
|
||||
local_year = str(datetime.fromisoformat(RETENTION_ISO).astimezone().year)
|
||||
expect(created_cell).to_have_text(re.compile(local_year), timeout=15_000)
|
||||
assert created_cell.get_attribute("title", timeout=15_000) == RETENTION_ISO
|
||||
# The Indexed cell stays AFTER Created (D8 in the row, not just the
|
||||
# header): it carries the import-time locale date, no title.
|
||||
expect(row.locator("td:nth-child(6)")).to_have_text(re.compile(r"\S"))
|
||||
assert row.locator("td:nth-child(6)").get_attribute("title") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. The clicked document's top meta row: the Created badge (D8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_viewer_shows_date_at_top(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None, dates_tree: Path
|
||||
) -> None:
|
||||
"""The same-page modal (phase 26) shows the Created badge at the TOP
|
||||
meta row of the clicked document — DOM-prior to the Indexed badge
|
||||
(D8), the full ISO on its title, the other badges intact."""
|
||||
_reset_db(mock_llm, dates_tree)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url)
|
||||
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{dates_tree.name}")')
|
||||
page.click('#folders-tbody a.folder-link:text-is("backups")')
|
||||
|
||||
row = page.locator("#docs-tbody tr", has_text=RETENTION_MD)
|
||||
expect(row).to_have_count(1)
|
||||
before_tabs = len(page.context.pages)
|
||||
row.locator("td:nth-child(2) a.doc-link").click()
|
||||
assert len(page.context.pages) == before_tabs, "row link must not open a new tab"
|
||||
|
||||
expect(page.locator(".doc-modal")).to_be_visible()
|
||||
expect(page.locator("#doc-modal-title")).to_have_text("Backup retention policy")
|
||||
|
||||
meta = page.locator("#doc-modal-meta")
|
||||
created = meta.locator(".doc-created")
|
||||
expect(created).to_have_count(1)
|
||||
# The badge text: "Created <locale date>" (the text+format pairing —
|
||||
# the date is carried by text, never color alone, B5).
|
||||
expect(created).to_have_text(re.compile(r"^Created \S"))
|
||||
# The full ISO timestamp on the badge's title (hover precision).
|
||||
assert created.get_attribute("title", timeout=15_000) == RETENTION_ISO
|
||||
# D8 in the DOM: the Created badge PRECEDES the Indexed badge.
|
||||
assert page.evaluate(
|
||||
"""() => {
|
||||
const a = document.querySelector('#doc-modal-meta .doc-created');
|
||||
const b = document.querySelector('#doc-modal-meta .doc-indexed');
|
||||
return a !== null && b !== null &&
|
||||
!!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
}"""
|
||||
)
|
||||
# No regression: the source/format/indexed/chunks badges are all
|
||||
# still there.
|
||||
expect(meta.locator(".doc-source-badge")).to_have_text(dates_tree.name)
|
||||
expect(meta.locator(".format-badge")).to_have_text("md")
|
||||
expect(meta.locator(".doc-indexed")).to_have_text(re.compile(r"^Indexed \S"))
|
||||
expect(meta.locator(".doc-chunks")).to_have_text(re.compile(r"^\d+ chunk"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. THE OWNER SCENARIO: the older correct doc beats the newer similar
|
||||
# one (real retriever + the default recency boost, mock embeddings)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_old_correct_beats_new_similar(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None, dates_tree: Path
|
||||
) -> None:
|
||||
"""``How did I configure the backup retention policy?`` → grounded,
|
||||
and the FIRST cited source is the OLDER correct doc (2020) — the
|
||||
NEWER similar one (now, "under review") is cited second. The real
|
||||
hybrid retriever + the DEFAULT recency boost (0.0007 / 365 d) over
|
||||
the mock's token-overlap embeddings (the module docstring records
|
||||
the measured fused scores: 0.0327878 vs 0.0324540 — margin
|
||||
0.000334 WITH the full zero-age boost on the newer doc)."""
|
||||
_reset_db(mock_llm, dates_tree)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/") # phase 79: chat is require_user-gated
|
||||
|
||||
_ask(page, QUESTION)
|
||||
expect(page.locator(".msg.user .bubble")).to_contain_text(QUESTION)
|
||||
|
||||
bubble = page.locator(".msg.brain .bubble").first
|
||||
bubble.wait_for(state="visible", timeout=30_000)
|
||||
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
# Grounded: no deflected bubble at all (the A8 cosine gate passed —
|
||||
# top_score 0.6222 ≥ the e2e threshold 0.30).
|
||||
expect(page.locator(".msg.brain.is-deflected")).to_have_count(0)
|
||||
|
||||
# The OLDER correct doc is the FIRST source chip; the NEWER similar
|
||||
# one (the boost's intended beneficiary) is cited — but second.
|
||||
chips = page.locator(".msg.brain .source-chip")
|
||||
expect(chips).to_have_count(2, timeout=30_000)
|
||||
assert chips.nth(0).inner_text() == f"{dates_tree.name}/{RETENTION_MD}"
|
||||
assert chips.nth(1).inner_text() == f"{dates_tree.name}/{DRAFT_MD}"
|
||||
|
||||
# Durable record: one row, grounded, both docs cited in rank order.
|
||||
with SessionLocal() as db:
|
||||
row = db.scalars(select(QueryLog)).one()
|
||||
assert row.question == QUESTION
|
||||
assert row.deflected is False
|
||||
assert row.top_score >= 0.30 # the e2e mock-calibrated threshold
|
||||
assert (row.fts_hits or 0) >= 1
|
||||
assert f"{dates_tree.name}/{RETENTION_MD}" in row.sources
|
||||
assert row.sources.index(RETENTION_MD) < row.sources.index(DRAFT_MD)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. The admin edit round-trips through the REAL UI + API and SURVIVES
|
||||
# a re-import (D1 manual flag); Revert to sync hands it back
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_date_edit_and_sync_preserves(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None, dates_tree: Path
|
||||
) -> None:
|
||||
"""Edit date → Save → the badge re-renders from the RESPONSE (never
|
||||
the optimistic input) → the API round-trips; a re-import KEEPS the
|
||||
correction (the manual flag, D1) while the siblings refresh;
|
||||
``Revert to sync`` drops the flag and the next import re-sources
|
||||
the date from the mtime (2019)."""
|
||||
_reset_db(mock_llm, dates_tree)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url)
|
||||
source = dates_tree.name
|
||||
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{source}")')
|
||||
page.click('#folders-tbody a.folder-link:text-is("legacy")')
|
||||
|
||||
row = page.locator("#docs-tbody tr", has_text=OLDDOC_MD)
|
||||
expect(row).to_have_count(1)
|
||||
row.locator("td:nth-child(2) a.doc-link").click()
|
||||
expect(page.locator(".doc-modal")).to_be_visible()
|
||||
|
||||
# --- SET: the admin-only editor (the phase-57 idiom in the shared
|
||||
# core — the modal is the surface here) ---
|
||||
edit = page.locator("#doc-modal .doc-date-edit")
|
||||
expect(edit).to_be_visible(timeout=15_000)
|
||||
edit.click()
|
||||
page.fill("#doc-modal .doc-date-input", "2021-05-05")
|
||||
page.click("#doc-modal .doc-date-save")
|
||||
# The live-region confirmation names the document…
|
||||
expect(page.locator("#doc-modal .doc-date-status")).to_have_text(
|
||||
f"Date saved for {source}/{OLDDOC_MD}."
|
||||
)
|
||||
# …and the badge re-renders from the RESPONSE's created_at (the
|
||||
# server-normalized 2021-05-05T00:00:00+00:00 — never the input's
|
||||
# raw string).
|
||||
expect(page.locator("#doc-modal-meta .doc-created")) \
|
||||
.to_have_attribute("title", EDITED_ISO)
|
||||
# The editor collapses back to the badge-row shape a beat later.
|
||||
expect(page.locator("#doc-modal .doc-date-editor")).to_have_count(0, timeout=8_000)
|
||||
|
||||
# The API round-trips the correction (the admin cookie).
|
||||
cookies = _admin_cookies(page)
|
||||
docs = _docs_by_path(app_url, cookies)
|
||||
assert docs[OLDDOC_MD]["created_at"] == EDITED_ISO
|
||||
|
||||
# --- RE-IMPORT (same tree, mtimes untouched): the manual row is
|
||||
# SKIPPED (D1/D4) while the siblings refresh. Exactly ONE date moves:
|
||||
# forward.md — its 2999 mtime re-normalizes to a FRESH `now` (full
|
||||
# precision) on every import, so it refreshes; retention/draft match
|
||||
# their stored mtime-sourced values bit for bit. ---
|
||||
summary = _run_in_thread(_import_tree(mock_llm, dates_tree))
|
||||
assert summary.added == 0 and summary.errors == 0
|
||||
assert summary.unchanged == 4 # a date-only refresh is still unchanged (D4)
|
||||
assert summary.dates_updated == 1 # forward.md only (the future-fold)
|
||||
docs = _docs_by_path(app_url, cookies)
|
||||
today = _today_utc()
|
||||
assert docs[OLDDOC_MD]["created_at"] == EDITED_ISO # the correction SURVIVES
|
||||
assert docs[RETENTION_MD]["created_at"] == RETENTION_ISO # refreshed, not stale
|
||||
assert docs[FORWARD_MD]["created_at"][:10] == today # still today (re-folded)
|
||||
|
||||
# --- REVERT: the explicit clear (D7) — the flag drops; the stored
|
||||
# date stands until the next sync refreshes it ---
|
||||
page.click("#doc-modal .doc-date-edit")
|
||||
expect(page.locator("#doc-modal .doc-date-input")).to_have_value("2021-05-05")
|
||||
page.click("#doc-modal .doc-date-revert")
|
||||
expect(page.locator("#doc-modal .doc-date-status")).to_have_text(
|
||||
"Reverted to sync-managed date."
|
||||
)
|
||||
expect(page.locator("#doc-modal .doc-date-editor")).to_have_count(0, timeout=8_000)
|
||||
|
||||
# The NEXT import re-sources the date from the mtime — sync manages
|
||||
# it again (old-doc + forward = two date-only refreshes).
|
||||
summary = _run_in_thread(_import_tree(mock_llm, dates_tree))
|
||||
assert summary.unchanged == 4
|
||||
assert summary.dates_updated == 2
|
||||
docs = _docs_by_path(app_url, cookies)
|
||||
assert docs[OLDDOC_MD]["created_at"] == OLDDOC_ISO # back to the 2019 mtime
|
||||
assert docs[RETENTION_MD]["created_at"] == RETENTION_ISO
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Anonymous: the gate + the 403; admin: the editor's a11y
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_gate_and_editor_a11y(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None, dates_tree: Path
|
||||
) -> None:
|
||||
"""Anonymous: the RAG view shows the sign-in gate (no tables) and a
|
||||
raw ``PATCH /api/documents/date`` 403s (admin-only, D7) with the
|
||||
stored date untouched. Admin: the editor's accessible names,
|
||||
keyboard reachability, the role=status / role=alert live lines, and
|
||||
the badge's text+format pairing (never color alone — B5)."""
|
||||
_reset_db(mock_llm, dates_tree)
|
||||
page.set_default_timeout(30_000)
|
||||
source = dates_tree.name
|
||||
|
||||
# --- Anonymous (a fresh context — no login) ---
|
||||
page.goto(f"{app_url}/sources.html")
|
||||
# The sign-in gate replaces the catalog…
|
||||
expect(page.locator("#sources-gate")).to_be_visible(timeout=15_000)
|
||||
# …and no tables render (the stat cards + both table wraps stay
|
||||
# hidden; no /api/docs/tree request is made at all — the phase-16
|
||||
# gate).
|
||||
expect(page.locator("#folders-wrap")).to_be_hidden()
|
||||
expect(page.locator('div[role="region"][aria-label="Indexed documents"]')) \
|
||||
.to_be_hidden()
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(0)
|
||||
|
||||
# The raw PATCH is admin-gated: 403 "admin only" (no cookie) — and
|
||||
# the stored date is untouched.
|
||||
r = httpx.patch(
|
||||
f"{app_url}/api/documents/date",
|
||||
json={"source": source, "path": OLDDOC_MD, "date": "2024-01-01"},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
with SessionLocal() as db:
|
||||
doc = db.scalar(
|
||||
select(Document).where(
|
||||
Document.source == source, Document.path == OLDDOC_MD
|
||||
)
|
||||
)
|
||||
assert doc is not None
|
||||
assert doc.created_at.isoformat() == OLDDOC_ISO # untouched by the 403
|
||||
assert doc.created_at_manual is False
|
||||
|
||||
# --- Admin: the editor's a11y ---
|
||||
login(page, app_url)
|
||||
page.locator("#folders-tbody .folder-link").first.wait_for(state="visible")
|
||||
page.click(f'#folders-tbody a.folder-link:text-is("{source}")')
|
||||
page.click('#folders-tbody a.folder-link:text-is("legacy")')
|
||||
row = page.locator("#docs-tbody tr", has_text=OLDDOC_MD)
|
||||
expect(row).to_have_count(1)
|
||||
row.locator("td:nth-child(2) a.doc-link").click()
|
||||
expect(page.locator(".doc-modal")).to_be_visible()
|
||||
|
||||
# The Edit date button's accessible name carries the document pair
|
||||
# (the aria-label — "Edit creation date: <source>/<path>").
|
||||
edit = page.locator("#doc-modal .doc-date-edit")
|
||||
expect(edit).to_be_visible(timeout=15_000)
|
||||
assert f"{source}/{OLDDOC_MD}" in (edit.get_attribute("aria-label") or "")
|
||||
|
||||
# Open: the date input is focused (the keyboard entry point — the
|
||||
# same state a real user's keyboard flow reaches after activating
|
||||
# the button) and carries its own accessible name.
|
||||
edit.click()
|
||||
date_input = page.locator("#doc-modal .doc-date-input")
|
||||
expect(date_input).to_be_visible()
|
||||
assert date_input.get_attribute("aria-label") == "Document creation date"
|
||||
assert page.evaluate(
|
||||
"() => document.activeElement"
|
||||
" === document.querySelector('#doc-modal .doc-date-input')"
|
||||
)
|
||||
# Keyboard traversal runs through the editor's controls (the modal
|
||||
# focus trap): Tab from Save → Cancel, and Tab from Revert wraps to
|
||||
# the panel's first control (the trap's edge behavior). (The CDP
|
||||
# key dispatch of headless Chromium does NOT perform the native
|
||||
# focus move off <input type="date"> itself — a harness artifact,
|
||||
# not a product defect: the same Tab works from every text input
|
||||
# and button in the editor, pinned here through the buttons.)
|
||||
page.evaluate("() => document.querySelector('#doc-modal .doc-date-save').focus()")
|
||||
page.keyboard.press("Tab")
|
||||
assert page.evaluate(
|
||||
"() => !!document.activeElement"
|
||||
" && document.activeElement.classList.contains('doc-date-cancel')"
|
||||
)
|
||||
page.evaluate("() => document.querySelector('#doc-modal .doc-date-revert').focus()")
|
||||
page.keyboard.press("Tab")
|
||||
assert page.evaluate(
|
||||
"() => !!document.activeElement"
|
||||
" && document.activeElement.classList.contains('doc-modal-open')"
|
||||
)
|
||||
|
||||
# The live lines: role=status (polite) for confirmations,
|
||||
# role=alert (assertive) for the error path — both in the DOM
|
||||
# (the phase-57/89 surfaces).
|
||||
status = page.locator("#doc-modal .doc-date-status")
|
||||
expect(status).to_have_attribute("role", "status")
|
||||
expect(status).to_have_attribute("aria-live", "polite")
|
||||
error = page.locator("#doc-modal .doc-date-error")
|
||||
expect(error).to_have_count(1)
|
||||
expect(error).to_have_attribute("role", "alert")
|
||||
expect(error).to_have_attribute("aria-live", "assertive")
|
||||
|
||||
# The badge's text+format pairing (B5 — never color alone): the
|
||||
# locale date in the TEXT plus the full ISO on the title.
|
||||
created = page.locator("#doc-modal-meta .doc-created")
|
||||
expect(created).to_have_text(re.compile(r"^Created .*2019"))
|
||||
assert created.get_attribute("title") == OLDDOC_ISO
|
||||
@@ -126,7 +126,7 @@ DOC1_CONTENT = (
|
||||
f"Regression sentinel line: {SEARCH_PATTERN} must stay findable "
|
||||
"by the plain search flow.\n"
|
||||
)
|
||||
assert "\n" not in DOC1_CONTENT[:80] # the read quote stays one line
|
||||
assert "\n" not in DOC1_CONTENT[:63] # the read quote's content part stays one line
|
||||
assert GREP_TEACH_PLAIN in DOC1_CONTENT # the plain grep matches DOC1
|
||||
assert GREP_TEACH_PATTERN not in DOC1_CONTENT # the regex never matches
|
||||
assert GREP_TEACH_MARKER not in DOC1_CONTENT # the marker stays tool-side
|
||||
@@ -209,9 +209,14 @@ for _other in (
|
||||
|
||||
#: The mock's deterministic read echo (the read document reached the
|
||||
#: model and landed in the answer) — the flow reads DOC1 (the plain
|
||||
#: grep's first — only — match line).
|
||||
#: grep's first — only — match line). Phase 106 (D5): the read result's
|
||||
#: ``date:`` SECOND line rides into the first-80-chars quote — the
|
||||
#: date line (the fixture's fixed ``created_at`` UTC date part, 17
|
||||
#: chars; its trailing newline renders as a markdown soft break — no
|
||||
#: text between the date and the content) + the first 63 content chars
|
||||
#: (80 − 17).
|
||||
READ_ANSWER_PREFIX = f"Read {DOC1_SP}."
|
||||
READ_ANSWER_QUOTE = DOC1_CONTENT[:80]
|
||||
READ_ANSWER_QUOTE = "date: 2024-06-15" + DOC1_CONTENT[:63]
|
||||
|
||||
|
||||
def _seed_fixture(db: Session) -> None:
|
||||
@@ -222,6 +227,8 @@ def _seed_fixture(db: Session) -> None:
|
||||
embedding → the trigger question cosines well past the E2E 0.30
|
||||
threshold → grounded, the ``<tools>`` section rides along).
|
||||
"""
|
||||
# Phase 106 (D5): explicit dates — byte-stable prompts/quotes (the
|
||||
# mock's first-80-chars read quote carries the date line).
|
||||
db.add(
|
||||
Document(
|
||||
source=SEED_SOURCE,
|
||||
@@ -231,6 +238,7 @@ def _seed_fixture(db: Session) -> None:
|
||||
content=DOC1_CONTENT,
|
||||
content_hash=hashlib.sha256(DOC1_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=datetime(2024, 6, 15, tzinfo=UTC),
|
||||
)
|
||||
)
|
||||
doc2 = Document(
|
||||
@@ -241,6 +249,7 @@ def _seed_fixture(db: Session) -> None:
|
||||
content=DOC2_CONTENT,
|
||||
content_hash=hashlib.sha256(DOC2_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=datetime(2024, 6, 15, tzinfo=UTC),
|
||||
)
|
||||
db.add(doc2)
|
||||
db.flush()
|
||||
|
||||
@@ -120,7 +120,9 @@ ROUTE53_CONTENT = (
|
||||
|
||||
#: The read document (the catalog-first line the mock reads; no chunks,
|
||||
#: so retrieval never puts it in context). Its FIRST line is longer than
|
||||
#: 80 chars, so the mock's first-80-chars quote is newline-free.
|
||||
#: 80 chars, so the mock's first-80-chars quote content part is
|
||||
#: newline-free (the rendered-text assertion matches it after the
|
||||
#: phase-106 D5 date line).
|
||||
RECORD_FILE_CONTENT = (
|
||||
'{ "version": 3, "comment": "ReeseLink hosted zone records — the exact '
|
||||
'JSON shape of reeselink.json",\n'
|
||||
@@ -133,7 +135,7 @@ RECORD_FILE_CONTENT = (
|
||||
" ]\n"
|
||||
"}\n"
|
||||
)
|
||||
assert "\n" not in RECORD_FILE_CONTENT[:80] # the quote must stay one line
|
||||
assert "\n" not in RECORD_FILE_CONTENT[:63] # the quote's content part stays one line
|
||||
|
||||
#: Carries ``TOOLS_TRIGGER`` (and nothing else — no multi-read, no
|
||||
#: search, no other mock marker).
|
||||
@@ -156,8 +158,13 @@ for _other in (
|
||||
):
|
||||
assert _other not in READ_QUESTION.lower(), _other
|
||||
|
||||
#: Phase 106 (D5): the read result's ``date:`` SECOND line rides into
|
||||
#: the first-80-chars quote — the date line (the fixture's fixed
|
||||
#: ``created_at`` UTC date part, 17 chars; its trailing newline renders
|
||||
#: as a markdown soft break — no text between the date and the content)
|
||||
#: + the first 63 content chars (80 − 17).
|
||||
READ_ANSWER_PREFIX = f"Read {READ_SP}."
|
||||
READ_ANSWER_QUOTE = RECORD_FILE_CONTENT[:80]
|
||||
READ_ANSWER_QUOTE = "date: 2024-06-15" + RECORD_FILE_CONTENT[:63]
|
||||
|
||||
|
||||
def _seed_read_pair(db: Session) -> None:
|
||||
@@ -185,6 +192,9 @@ def _seed_read_pair(db: Session) -> None:
|
||||
content=ROUTE53_CONTENT,
|
||||
content_hash=hashlib.sha256(ROUTE53_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
# Phase 106 (D5): explicit dates — byte-stable prompts/quotes
|
||||
# (the mock's first-80-chars read quote carries the date line).
|
||||
created_at=datetime(2024, 6, 15, tzinfo=UTC),
|
||||
)
|
||||
db.add(md)
|
||||
db.flush()
|
||||
@@ -208,6 +218,7 @@ def _seed_read_pair(db: Session) -> None:
|
||||
content=RECORD_FILE_CONTENT,
|
||||
content_hash=hashlib.sha256(RECORD_FILE_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=datetime(2024, 6, 15, tzinfo=UTC),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+14
-12
@@ -540,10 +540,10 @@ def test_top_level_lists_sources_with_descriptions(
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text(ALPHA)
|
||||
expect(rows.nth(0).locator("td:nth-child(2)")).to_have_text(str(ALPHA_COUNT))
|
||||
expect(rows.nth(0).locator("td:nth-child(3) span")).to_have_text(ALPHA_ROOT_SUM)
|
||||
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(ALPHA_ROOT_SUM)
|
||||
expect(rows.nth(1).locator("a.folder-link")).to_have_text(BETA)
|
||||
expect(rows.nth(1).locator("td:nth-child(2)")).to_have_text(str(BETA_COUNT))
|
||||
expect(rows.nth(1).locator("td:nth-child(3) span")).to_have_text(BETA_ROOT_SUM)
|
||||
expect(rows.nth(1).locator("td:nth-child(4) span")).to_have_text(BETA_ROOT_SUM)
|
||||
# The edit affordance is ALWAYS present (a description can be
|
||||
# CREATED where none is stored) — the row surface, one per row.
|
||||
expect(page.locator("#folders-tbody .kb-summary-edit")).to_have_count(2)
|
||||
@@ -602,13 +602,15 @@ def test_drill_into_source(
|
||||
expect(rows).to_have_count(2)
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text("one")
|
||||
expect(rows.nth(0).locator("td:nth-child(2)")).to_have_text("2")
|
||||
expect(rows.nth(0).locator("td:nth-child(3) span")).to_have_text(ONE_SUM)
|
||||
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(ONE_SUM)
|
||||
expect(rows.nth(1).locator("a.folder-link")).to_have_text("two")
|
||||
expect(rows.nth(1).locator("td:nth-child(2)")).to_have_text("2")
|
||||
expect(rows.nth(1).locator("td:nth-child(3) span")).to_have_text(TWO_SUM)
|
||||
expect(rows.nth(1).locator("td:nth-child(4) span")).to_have_text(TWO_SUM)
|
||||
|
||||
# The level's direct files: root-note.md — the UNCHANGED 5-column
|
||||
# contract (makeRow): Source | Path | Title | Chunks | Indexed.
|
||||
# The level's direct files: root-note.md — the 6-column contract
|
||||
# (makeRow): Source | Path | Title | Chunks | Created | Indexed
|
||||
# (phase 106, task 08, D8: Created BEFORE Indexed — the cell
|
||||
# carries the locale date, the full ISO on its title).
|
||||
expect(page.locator("#docs-table")).to_be_visible()
|
||||
frows = page.locator("#docs-tbody tr")
|
||||
expect(frows).to_have_count(1)
|
||||
@@ -616,7 +618,7 @@ def test_drill_into_source(
|
||||
expect(frows.nth(0).locator("a.doc-link")).to_have_text(ROOT_NOTE)
|
||||
expect(frows.nth(0).locator("td:nth-child(3)")).to_have_text("Alpha Root Note")
|
||||
expect(frows.nth(0).locator("td:nth-child(4)")).to_have_text(str(root_chunks))
|
||||
expect(frows.nth(0).locator("td:nth-child(5)")).not_to_have_text("")
|
||||
expect(frows.nth(0).locator("td:nth-child(6)")).not_to_have_text("")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -711,7 +713,7 @@ def test_edit_folder_description(
|
||||
# (the textContent re-render — the canned text is gone); the
|
||||
# always-present Edit button is back.
|
||||
expect(status).to_have_text("Description updated.")
|
||||
expect(row.locator("td:nth-child(3) span")).to_have_text(NEW_ALPHA_ONE)
|
||||
expect(row.locator("td:nth-child(4) span")).to_have_text(NEW_ALPHA_ONE)
|
||||
expect(row.locator(".kb-summary-edit")).to_be_visible()
|
||||
|
||||
# The server row: the new text AND the manually_edited flag (the
|
||||
@@ -754,7 +756,7 @@ def test_clear_folder_description(
|
||||
# The text is gone: the cell empties (the always-present Edit
|
||||
# button stays — a description can be re-created from the empty
|
||||
# cell).
|
||||
expect(row.locator("td:nth-child(3) span")).to_have_text("")
|
||||
expect(row.locator("td:nth-child(4) span")).to_have_text("")
|
||||
expect(row.locator(".kb-summary-edit")).to_be_visible()
|
||||
|
||||
# The SQL assert: no row for the folder (the next KB-changing
|
||||
@@ -787,7 +789,7 @@ def test_manual_description_survives_a_changed_sync(
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows).to_have_count(1)
|
||||
expect(rows.nth(0).locator("a.folder-link")).to_have_text("gamma")
|
||||
expect(rows.nth(0).locator("td:nth-child(3) span")).to_have_text(GAMMA_SUM)
|
||||
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(GAMMA_SUM)
|
||||
|
||||
# The LEVEL-BLOCK Edit (the level surface — test 4 pins the row
|
||||
# surface): the static #kb-level-edit button, the source root
|
||||
@@ -829,7 +831,7 @@ def test_manual_description_survives_a_changed_sync(
|
||||
expect(
|
||||
page.locator("#folders-tbody tr")
|
||||
.nth(0)
|
||||
.locator("td:nth-child(3) span")
|
||||
.locator("td:nth-child(4) span")
|
||||
).to_have_text(GAMMA_SUM)
|
||||
|
||||
# The new document landed in the tree: back to the top (the
|
||||
@@ -840,7 +842,7 @@ def test_manual_description_survives_a_changed_sync(
|
||||
one_row = page.locator("#folders-tbody tr").nth(0)
|
||||
expect(one_row.locator("a.folder-link")).to_have_text("one")
|
||||
expect(one_row.locator("td:nth-child(2)")).to_have_text("3")
|
||||
expect(one_row.locator("td:nth-child(3) span")).to_have_text(ONE_SUM)
|
||||
expect(one_row.locator("td:nth-child(4) span")).to_have_text(ONE_SUM)
|
||||
|
||||
# The SQL asserts: the manual row survived (text + flag), the
|
||||
# untouched rows were regenerated (the canned text, the flag back
|
||||
|
||||
@@ -709,8 +709,8 @@ def test_description_cell_clamps_to_one_line(
|
||||
# The measured clamp: the long-description row's Description <td>
|
||||
# is ONE line — its bounding-box height equals the short row's (±
|
||||
# 4 px). Without the clamp, ≥ 4 wrapped lines would stretch it.
|
||||
one_desc = one_row.locator("td:nth-child(3)")
|
||||
three_desc = three_row.locator("td:nth-child(3)")
|
||||
one_desc = one_row.locator("td:nth-child(4)")
|
||||
three_desc = three_row.locator("td:nth-child(4)")
|
||||
one_box = one_desc.bounding_box()
|
||||
three_box = three_desc.bounding_box()
|
||||
assert one_box is not None and three_box is not None
|
||||
|
||||
@@ -848,7 +848,7 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
|
||||
src_row = page.locator("#folders-tbody tr")
|
||||
expect(src_row.locator("a.folder-link")).to_have_text(SOURCE)
|
||||
expect(src_row.locator("td:nth-child(2)")).to_have_text(str(N_FILES))
|
||||
expect(src_row.locator("td:nth-child(3) span")).to_have_text(SUM_ROOT)
|
||||
expect(src_row.locator("td:nth-child(4) span")).to_have_text(SUM_ROOT)
|
||||
expect(page.locator(".kb-summary-pending")).to_have_count(0)
|
||||
|
||||
# The gap: delete the bravo row AND the source-root row directly
|
||||
@@ -863,7 +863,7 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
|
||||
page.click("#nav-sources")
|
||||
_wait_top_level(page)
|
||||
src_row = page.locator("#folders-tbody tr")
|
||||
s_span = src_row.locator("td:nth-child(3) span")
|
||||
s_span = src_row.locator("td:nth-child(4) span")
|
||||
expect(s_span).to_have_text(PENDING_COPY)
|
||||
# Phase 99 (task 01): the marker toggles onto the cell's base
|
||||
# .kb-desc-text span (the one-line clamp) — the class pair, exact.
|
||||
@@ -886,13 +886,13 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
|
||||
bravo_row = rows.nth(1)
|
||||
expect(alpha_row.locator("a.folder-link")).to_have_text(FOLDER_A)
|
||||
# The intact folder: the stored line, never the marker.
|
||||
expect(alpha_row.locator("td:nth-child(3) span")).to_have_text(SUM_ALPHA)
|
||||
expect(alpha_row.locator("td:nth-child(3) span")).not_to_have_class(
|
||||
expect(alpha_row.locator("td:nth-child(4) span")).to_have_text(SUM_ALPHA)
|
||||
expect(alpha_row.locator("td:nth-child(4) span")).not_to_have_class(
|
||||
"kb-summary-pending"
|
||||
)
|
||||
# The affected folder: the marker (copy + class + title) — the
|
||||
# class pair with the phase-99 .kb-desc-text base span, exact.
|
||||
b_span = bravo_row.locator("td:nth-child(3) span")
|
||||
b_span = bravo_row.locator("td:nth-child(4) span")
|
||||
expect(b_span).to_have_text(PENDING_COPY)
|
||||
expect(b_span).to_have_class("kb-desc-text kb-summary-pending")
|
||||
expect(b_span).to_have_attribute("title", PENDING_TITLE)
|
||||
@@ -915,7 +915,7 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
|
||||
expect(bravo_row.locator(".kb-summary-status")).to_have_text(
|
||||
"Description updated."
|
||||
)
|
||||
b_span = bravo_row.locator("td:nth-child(3) span")
|
||||
b_span = bravo_row.locator("td:nth-child(4) span")
|
||||
expect(b_span).to_have_text(MANUAL_BRavo)
|
||||
expect(b_span).not_to_have_class("kb-summary-pending")
|
||||
|
||||
@@ -948,14 +948,14 @@ def test_missing_folder_summaries_read_as_pending_and_self_heal(
|
||||
expect(page.locator("#kb-level-title")).to_have_text(SOURCE)
|
||||
expect(page.locator("#kb-level-summary")).to_have_text(SUM_ROOT)
|
||||
rows = page.locator("#folders-tbody tr")
|
||||
expect(rows.nth(0).locator("td:nth-child(3) span")).to_have_text(SUM_ALPHA)
|
||||
expect(rows.nth(1).locator("td:nth-child(3) span")).to_have_text(MANUAL_BRavo)
|
||||
expect(rows.nth(0).locator("td:nth-child(4) span")).to_have_text(SUM_ALPHA)
|
||||
expect(rows.nth(1).locator("td:nth-child(4) span")).to_have_text(MANUAL_BRavo)
|
||||
expect(page.locator(".kb-summary-pending")).to_have_count(0)
|
||||
# And at the top level: the source row's cell carries the
|
||||
# regenerated root line, no marker.
|
||||
page.locator("#kb-crumb a.kb-crumb-link").nth(0).click()
|
||||
src_row = page.locator("#folders-tbody tr")
|
||||
s_span = src_row.locator("td:nth-child(3) span")
|
||||
s_span = src_row.locator("td:nth-child(4) span")
|
||||
expect(s_span).to_have_text(SUM_ROOT)
|
||||
expect(s_span).not_to_have_class("kb-summary-pending")
|
||||
expect(page.locator(".kb-summary-pending")).to_have_count(0)
|
||||
|
||||
@@ -99,9 +99,12 @@ DOC2_TITLE = "Example Record File"
|
||||
DOC2_SP = f"{SEED_SOURCE}/{DOC2_PATH}"
|
||||
|
||||
#: The FIRST catalog line (catalog order = (source, path) — DOC1 sorts
|
||||
#: first): the mock's LS-TEACH answer quotes exactly this line.
|
||||
#: first): the mock's LS-TEACH answer quotes exactly this line. Phase
|
||||
#: 106 (D5): the FILE line's appended `` | date: …`` field rides along
|
||||
#: (the fixture's fixed ``created_at`` UTC date part).
|
||||
FIRST_CATALOG_LINE = (
|
||||
f"source: {SEED_SOURCE} | path: {DOC1_PATH} | title: {DOC1_TITLE}"
|
||||
f"source: {SEED_SOURCE} | path: {DOC1_PATH} | title: {DOC1_TITLE} "
|
||||
"| date: 2024-06-15"
|
||||
)
|
||||
|
||||
#: The catalog-first document (catalog order = (source, path) —
|
||||
@@ -126,7 +129,7 @@ DOC1_CONTENT = (
|
||||
"A cron job pushes reeselink.json to the aws route53 hosted zone "
|
||||
"every fifteen minutes; the diff is applied through the route53 api.\n"
|
||||
)
|
||||
assert "\n" not in DOC1_CONTENT[:80] # the quote must stay one line
|
||||
assert "\n" not in DOC1_CONTENT[:63] # the quote's content part stays one line
|
||||
|
||||
#: The retrievable document (the grounded seed context, the cf.
|
||||
#: test_harness_aligned_tools.py pattern): the repeated record-file
|
||||
@@ -205,8 +208,13 @@ for _other in (
|
||||
#: The mock's single-read answer (the read document reached the model
|
||||
#: and landed in the answer) — DOC1 is the first catalog line, so the
|
||||
#: flow reads ``Homelab/aws-route53.md`` and quotes its first 80 chars.
|
||||
#: Phase 106 (D5): the read result's ``date:`` SECOND line rides into
|
||||
#: the first-80-chars quote — the date line (the fixture's fixed
|
||||
#: ``created_at`` UTC date part, 17 chars; its trailing newline renders
|
||||
#: as a markdown soft break — no text between the date and the content)
|
||||
#: + the first 63 content chars (80 − 17).
|
||||
READ_ANSWER_PREFIX = f"Read {DOC1_SP}."
|
||||
READ_ANSWER_QUOTE = DOC1_CONTENT[:80]
|
||||
READ_ANSWER_QUOTE = "date: 2024-06-15" + DOC1_CONTENT[:63]
|
||||
|
||||
|
||||
def _seed_fixture(db: Session) -> None:
|
||||
@@ -225,6 +233,8 @@ def _seed_fixture(db: Session) -> None:
|
||||
fallback — deterministic.
|
||||
"""
|
||||
db.add(GitSource(url=SEED_SOURCE, kind="local"))
|
||||
# Phase 106 (D5): explicit dates — byte-stable prompts/quotes (the
|
||||
# ls file line and the mock's read quote carry the date).
|
||||
db.add(
|
||||
Document(
|
||||
source=SEED_SOURCE,
|
||||
@@ -234,6 +244,7 @@ def _seed_fixture(db: Session) -> None:
|
||||
content=DOC1_CONTENT,
|
||||
content_hash=hashlib.sha256(DOC1_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=datetime(2024, 6, 15, tzinfo=UTC),
|
||||
)
|
||||
)
|
||||
doc2 = Document(
|
||||
@@ -244,6 +255,7 @@ def _seed_fixture(db: Session) -> None:
|
||||
content=DOC2_CONTENT,
|
||||
content_hash=hashlib.sha256(DOC2_CONTENT.encode()).hexdigest(),
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=datetime(2024, 6, 15, tzinfo=UTC),
|
||||
)
|
||||
db.add(doc2)
|
||||
db.flush()
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Session-level DB self-heal for the integration suite (phase 106, task 02).
|
||||
|
||||
Incident 2026-09-13: the dev DB's ``documents`` table hit PostgreSQL's
|
||||
1600-attribute hard limit and every ``ALTER TABLE … ADD COLUMN`` failed
|
||||
with ``TooManyColumns``, red-lining the full suite. Cause: the house
|
||||
migration-test pattern (A13 — every migration exercises a real
|
||||
downgrade, then repairs back to head) leaks *dropped-column
|
||||
placeholder* attributes on every downgrade→upgrade round-trip
|
||||
(``pg_attribute`` rows with ``attisdropped=true``). ``VACUUM (FULL)``
|
||||
does NOT reclaim them (verified on PG 17.11) — only a table rewrite
|
||||
does — and at ~95 leaks per full-suite run the shared dev DB bricks
|
||||
every ~17 runs (faster when two runs race, which is how the incident
|
||||
triggered).
|
||||
|
||||
This session fixture rebuilds any ``public`` table whose dropped-
|
||||
attribute count exceeds :data:`DROPPED_ATTR_LIMIT` **before** the first
|
||||
integration test of the session runs: rename + ``CREATE TABLE
|
||||
(LIKE … INCLUDING ALL)`` + row copy + FK rewiring (both directions,
|
||||
original constraint names and actions preserved). The normal case costs
|
||||
one small catalog query per session; the heal path only fires while a
|
||||
table is far below the 1600 cap (the limit is 200 — a tenth of the
|
||||
headroom), so the migration tests never run on a near-bricked DB.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import db_available, engine
|
||||
|
||||
logger = logging.getLogger("bor.integration.self_heal")
|
||||
|
||||
#: Rebuild a table once its *dropped* attribute count passes this.
|
||||
#: PostgreSQL's hard cap is 1600 TOTAL attributes (dropped included),
|
||||
#: and the migration round-trips leak ~95 per full-suite run.
|
||||
DROPPED_ATTR_LIMIT = 200
|
||||
|
||||
_BLOATED_SQL = text(
|
||||
"SELECT c.relname FROM pg_class c"
|
||||
" JOIN pg_namespace n ON n.oid = c.relnamespace"
|
||||
" WHERE c.relkind = 'r' AND n.nspname = 'public'"
|
||||
" AND (SELECT count(*) FROM pg_attribute a"
|
||||
" WHERE a.attrelid = c.oid AND a.attnum > 0 AND a.attisdropped)"
|
||||
" > :limit"
|
||||
" ORDER BY 1"
|
||||
)
|
||||
|
||||
_FKS_SQL = text(
|
||||
"SELECT c.conname,"
|
||||
" c.conrelid::regclass::text AS child,"
|
||||
" c.confrelid::regclass::text AS parent,"
|
||||
" c.confdeltype, c.confupdtype, c.confmatchtype AS matchtype,"
|
||||
" (SELECT string_agg(ca.attname, ', ' ORDER BY ck.ord)"
|
||||
" FROM unnest(c.conkey) WITH ORDINALITY ck(attnum, ord)"
|
||||
" JOIN pg_attribute ca ON ca.attrelid = c.conrelid AND ca.attnum = ck.attnum)"
|
||||
" AS child_cols,"
|
||||
" (SELECT string_agg(pa.attname, ', ' ORDER BY pk.ord)"
|
||||
" FROM unnest(c.confkey) WITH ORDINALITY pk(attnum, ord)"
|
||||
" JOIN pg_attribute pa ON pa.attrelid = c.confrelid AND pa.attnum = pk.attnum)"
|
||||
" AS parent_cols"
|
||||
" FROM pg_constraint c"
|
||||
" WHERE c.contype = 'f'"
|
||||
" AND (:t = c.conrelid::regclass::text OR :t = c.confrelid::regclass::text)"
|
||||
)
|
||||
|
||||
#: pg_constraint confdeltype/confupdtype codes → the DDL clause (``None``
|
||||
#: = NO ACTION, the default — the clause is omitted).
|
||||
_FK_ACTION: dict[str, str | None] = {
|
||||
"a": None, # NO ACTION — the default, the clause is omitted
|
||||
"r": "RESTRICT",
|
||||
"c": "CASCADE",
|
||||
"n": "SET NULL",
|
||||
"d": "SET DEFAULT",
|
||||
}
|
||||
_FK_MATCH: dict[str, str] = {"f": "MATCH FULL", "p": "MATCH PARTIAL"}
|
||||
|
||||
|
||||
def _fk_clause(fk) -> str:
|
||||
"""The trailing ``MATCH …/ON DELETE …/ON UPDATE …`` of an FK."""
|
||||
parts = [
|
||||
_FK_MATCH.get(fk.matchtype, ""),
|
||||
f"ON DELETE {_FK_ACTION[fk.confdeltype]}" if _FK_ACTION[fk.confdeltype] else "",
|
||||
f"ON UPDATE {_FK_ACTION[fk.confupdtype]}" if _FK_ACTION[fk.confupdtype] else "",
|
||||
]
|
||||
return " ".join(p for p in parts if p)
|
||||
|
||||
|
||||
def _rebuild_table(table: str) -> None:
|
||||
"""Rewrite *table* to purge its dropped-column placeholders.
|
||||
|
||||
Rename + ``LIKE … INCLUDING ALL`` (live columns, constraints,
|
||||
indexes, defaults) + row copy + FK rewiring (incoming AND outgoing,
|
||||
original constraint names/actions). One transaction — a failure
|
||||
rolls the whole table's surgery back and fails the session loudly
|
||||
(a half-healed DB must never feed the migration tests).
|
||||
|
||||
The staging names carry a per-run suffix: a previous (interrupted or
|
||||
repeated) heal may still own the plain names, and a collision would
|
||||
make PG auto-suffix the LIKE-copied constraint names (…``_pkey1``)
|
||||
and defeat the PK rename below.
|
||||
|
||||
Note: the PK is renamed back to its conventional ``<table>_pkey``;
|
||||
other LIKE-copied objects keep PG's auto-generated names (nothing in
|
||||
the repo references constraint/index names by name — DDL is
|
||||
alembic-only, the ORM never issues DDL).
|
||||
"""
|
||||
new_name = f"{table}_heal_new_{uuid.uuid4().hex[:8]}"
|
||||
old_name = f"{table}_heal_old_{uuid.uuid4().hex[:8]}"
|
||||
with engine.begin() as conn:
|
||||
fks = conn.execute(_FKS_SQL, {"t": table}).fetchall()
|
||||
for fk in fks:
|
||||
conn.execute(
|
||||
text(f'ALTER TABLE "{fk.child}" DROP CONSTRAINT "{fk.conname}"')
|
||||
)
|
||||
conn.execute(
|
||||
text(f'CREATE TABLE "{new_name}" (LIKE "{table}" INCLUDING ALL)')
|
||||
)
|
||||
# Explicit non-generated column list (attnum order): ``SELECT *``
|
||||
# cannot be used — chunks.tsv is a STORED generated column, and
|
||||
# generated columns refuse explicit values (it recomputes them).
|
||||
cols = conn.execute(
|
||||
text(
|
||||
"SELECT string_agg('\"' || a.attname || '\"', ', '"
|
||||
" ORDER BY a.attnum)"
|
||||
" FROM pg_attribute a JOIN pg_class tc ON tc.oid = a.attrelid"
|
||||
" WHERE tc.relname = :t AND a.attnum > 0"
|
||||
" AND NOT a.attisdropped AND a.attgenerated NOT IN ('s', 'v')"
|
||||
),
|
||||
{"t": table},
|
||||
).scalar()
|
||||
conn.execute(
|
||||
text(f'INSERT INTO "{new_name}" ({cols}) SELECT {cols} FROM "{table}"')
|
||||
)
|
||||
conn.execute(text(f'ALTER TABLE "{table}" RENAME TO "{old_name}"'))
|
||||
# Drop the old table BEFORE the staging table takes its name: the
|
||||
# old table's index/constraint names (including ``<table>_pkey``
|
||||
# from a previous heal) live in the schema namespace until the
|
||||
# DROP, and the PK rename below needs that name free.
|
||||
conn.execute(text(f'DROP TABLE "{old_name}"'))
|
||||
conn.execute(text(f'ALTER TABLE "{new_name}" RENAME TO "{table}"'))
|
||||
# The rename above does NOT follow to LIKE-copied objects: put the
|
||||
# PK back on its conventional name (every migration here auto-names
|
||||
# PKs ``<table>_pkey`` — the one name tools/scripts reference).
|
||||
has_auto_pkey = conn.execute(
|
||||
text(
|
||||
"SELECT 1 FROM pg_constraint c"
|
||||
" JOIN pg_class tc ON tc.oid = c.conrelid"
|
||||
" WHERE tc.relname = :t AND c.conname = :n AND c.contype = 'p'"
|
||||
),
|
||||
{"t": table, "n": f"{new_name}_pkey"},
|
||||
).fetchone()
|
||||
if has_auto_pkey:
|
||||
conn.execute(
|
||||
text(
|
||||
f'ALTER TABLE "{table}" RENAME CONSTRAINT'
|
||||
f' "{new_name}_pkey" TO "{table}_pkey"'
|
||||
)
|
||||
)
|
||||
# FK rewiring LAST: only now does ``<table>`` refer to the rebuilt
|
||||
# table with all staging names gone.
|
||||
for fk in fks:
|
||||
clause = _fk_clause(fk)
|
||||
conn.execute(
|
||||
text(
|
||||
f'ALTER TABLE "{fk.child}" ADD CONSTRAINT "{fk.conname}"'
|
||||
f" FOREIGN KEY ({fk.child_cols})"
|
||||
f' REFERENCES "{fk.parent}" ({fk.parent_cols})'
|
||||
+ (f" {clause}" if clause else "")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def heal_bloated_tables() -> Iterator[None]:
|
||||
"""Rebuild bloated ``public`` tables before the session's first test.
|
||||
|
||||
One cheap catalog query per session while healthy (the normal
|
||||
case); the rebuild path only fires when a table's dropped-attribute
|
||||
count passes :data:`DROPPED_ATTR_LIMIT` (see the module docstring
|
||||
for the 2026-09-13 incident this exists to outlive).
|
||||
"""
|
||||
if db_available():
|
||||
with engine.connect() as conn:
|
||||
bloated = [
|
||||
row[0]
|
||||
for row in conn.execute(_BLOATED_SQL, {"limit": DROPPED_ATTR_LIMIT})
|
||||
]
|
||||
for table in bloated:
|
||||
logger.warning(
|
||||
"integration self-heal: rebuilding %r (dropped attributes"
|
||||
" > %d — migration round-trip placeholders)",
|
||||
table,
|
||||
DROPPED_ATTR_LIMIT,
|
||||
)
|
||||
_rebuild_table(table)
|
||||
yield
|
||||
@@ -22,7 +22,11 @@ unknown folder → NOT-A_FOLDER with the parent's subfolders).
|
||||
(first-slash split; a bare source name and an unknown identity get the
|
||||
no-document refusal), and ``grep`` (``all_documents`` for a whole-KB
|
||||
search, ``find_document`` for a scoped one) — both byte-identical
|
||||
across the phase-94 change.
|
||||
across the phase-94 change. Phase 106 (D5): the ``ls`` FILE line ends
|
||||
with the appended `` | date: YYYY-MM-DD`` field and the ``read``
|
||||
result carries the ``date: YYYY-MM-DD`` second line (first line
|
||||
byte-identical) — the fixture documents carry a fixed ``created_at``
|
||||
so the pins stay deterministic.
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
@@ -32,6 +36,7 @@ import asyncio
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pytest
|
||||
@@ -53,6 +58,11 @@ from app.rag.llm import (
|
||||
if TYPE_CHECKING:
|
||||
from app.rag.scaffolding import ScaffoldingFilter
|
||||
|
||||
#: The fixture documents' fixed creation date (phase 106, D5) — the
|
||||
#: ``ls`` file line and the ``read`` second line format its UTC date
|
||||
#: part; a fixed value keeps the pins deterministic.
|
||||
_FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _doc(db: Session, source: str, path: str, title: str, content: str) -> Document:
|
||||
doc = Document(
|
||||
@@ -63,6 +73,7 @@ def _doc(db: Session, source: str, path: str, title: str, content: str) -> Docum
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash="0" * 64,
|
||||
created_at=_FIXTURE_CREATED_AT,
|
||||
)
|
||||
db.add(doc)
|
||||
return doc
|
||||
@@ -121,8 +132,8 @@ def test_source_document_rows_order_by_path_within_the_source(kb, db) -> None:
|
||||
db.commit()
|
||||
|
||||
assert agent._source_document_rows(db, "Zeta") == [
|
||||
("a/first.md", "Zeta A"),
|
||||
("b/second.md", "Zeta B"),
|
||||
("a/first.md", "Zeta A", "2024-06-15"),
|
||||
("b/second.md", "Zeta B", "2024-06-15"),
|
||||
]
|
||||
|
||||
|
||||
@@ -355,7 +366,7 @@ def test_ls_source_scope_lists_root_folder_through_run_agent(kb, registry, db) -
|
||||
" backups/ — 2 documents: Backup notes.\n"
|
||||
" networking/ — 1 documents\n"
|
||||
"\n"
|
||||
"source: Homelab | path: readme.md | title: Readme"
|
||||
"source: Homelab | path: readme.md | title: Readme | date: 2024-06-15"
|
||||
)
|
||||
assert holder.tool_calls == 1
|
||||
assert holder.read_docs == []
|
||||
@@ -393,8 +404,8 @@ def test_ls_nested_folder_scope_drills_one_level_through_run_agent(
|
||||
assert llm2.requests[1][0][3]["content"] == (
|
||||
"Homelab/networking/lan — 2 documents, 0 folders:\n"
|
||||
"\n"
|
||||
"source: Homelab | path: networking/lan/a.md | title: A\n"
|
||||
"source: Homelab | path: networking/lan/b.md | title: B"
|
||||
"source: Homelab | path: networking/lan/a.md | title: A | date: 2024-06-15\n"
|
||||
"source: Homelab | path: networking/lan/b.md | title: B | date: 2024-06-15"
|
||||
)
|
||||
assert holder2.tool_calls == 1
|
||||
|
||||
@@ -411,8 +422,12 @@ def test_ls_folder_file_cap_through_run_agent(kb, registry, db) -> None:
|
||||
content = llm.requests[1][0][3]["content"]
|
||||
lines = content.splitlines()
|
||||
assert lines[0] == "Homelab/big — 51 documents, 0 folders:"
|
||||
assert lines[2] == "source: Homelab | path: big/f000.md | title: T0"
|
||||
assert lines[51] == "source: Homelab | path: big/f049.md | title: T49"
|
||||
assert lines[2] == (
|
||||
"source: Homelab | path: big/f000.md | title: T0 | date: 2024-06-15"
|
||||
)
|
||||
assert lines[51] == (
|
||||
"source: Homelab | path: big/f049.md | title: T49 | date: 2024-06-15"
|
||||
)
|
||||
assert lines[52] == (
|
||||
"…and 1 more documents in this folder — use grep (pattern) to "
|
||||
"find a specific one."
|
||||
@@ -491,8 +506,12 @@ def test_read_combined_path_through_run_agent(kb, db) -> None:
|
||||
|
||||
holder, llm = _run_call(db, "read", {"path": "Alpha/deep/nested/doc.md"})
|
||||
|
||||
# Phase 106 (D5): the date rides every read — the SECOND line (the
|
||||
# first line stays the byte-identical header).
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"Document Alpha/deep/nested/doc.md:\nFULL-TEXT"
|
||||
"Document Alpha/deep/nested/doc.md:\n"
|
||||
"date: 2024-06-15\n"
|
||||
"FULL-TEXT"
|
||||
)
|
||||
assert holder.tool_calls == 1
|
||||
assert holder.read_docs == [created]
|
||||
@@ -563,9 +582,12 @@ def test_read_bare_path_single_source_suggestion_then_corrected_read(kb, db) ->
|
||||
)
|
||||
assert llm.requests[1][1] == AGENT_TOOLS
|
||||
# Round 2: the corrected combined identity succeeds — the full
|
||||
# content, the holder records the row, and it counts.
|
||||
# content (plus the phase-106 D5 date line), the holder records the
|
||||
# row, and it counts.
|
||||
assert llm.requests[2][0][5]["content"] == (
|
||||
"Document Alpha/deep/nested/doc.md:\nFULL-TEXT"
|
||||
"Document Alpha/deep/nested/doc.md:\n"
|
||||
"date: 2024-06-15\n"
|
||||
"FULL-TEXT"
|
||||
)
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
assert holder.read_docs == [created]
|
||||
@@ -598,7 +620,9 @@ def test_read_bare_path_two_sources_one_of_suggestion_then_corrected_read(
|
||||
"No document at 'shared/x.md' — did you mean one of: "
|
||||
"'Alpha/shared/x.md', 'Beta/shared/x.md'?"
|
||||
)
|
||||
assert llm.requests[2][0][5]["content"] == "Document Alpha/shared/x.md:\nA-TEXT"
|
||||
assert llm.requests[2][0][5]["content"] == (
|
||||
"Document Alpha/shared/x.md:\ndate: 2024-06-15\nA-TEXT"
|
||||
)
|
||||
assert holder.read_docs == [a]
|
||||
assert holder.tool_calls == 1 # only the corrected read executed
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Integration: the phase-106 D5 date surfaces against REAL Postgres
|
||||
rows (task 06).
|
||||
|
||||
The two tool surfaces the model reads carry the document's creation
|
||||
date: the ``read`` result's SECOND line (``date: YYYY-MM-DD`` — the
|
||||
FIRST line stays the byte-identical ``Document {source}/{path}:``
|
||||
header the E2E mock's ``_READ_RESULT_PREFIX`` contract keys on) and
|
||||
the ``ls`` FILE line's APPENDED `` | date: YYYY-MM-DD`` field (the
|
||||
mock's ``_CATALOG_LINE_RE`` ``title: .+$`` tail absorbs it). The rows
|
||||
carry DISTINCT fixed ``created_at`` values, so the pins prove the date
|
||||
is the ROW's date (per row), not a constant.
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import Document, GitSource
|
||||
from app.rag.agent import AgentHolder, run_agent
|
||||
from app.rag.llm import LLMClient, RetryPiece, StreamPiece, ToolCallPiece, ToolResultPiece
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.rag.scaffolding import ScaffoldingFilter
|
||||
|
||||
#: DISTINCT fixed creation dates — the per-row date pins (task 06):
|
||||
#: each document renders ITS OWN row's UTC date part.
|
||||
D1 = datetime(2019, 6, 15, 3, 4, 6, tzinfo=UTC) # "2019-06-15"
|
||||
D2 = datetime(2020, 1, 2, 5, 0, 0, tzinfo=UTC) # "2020-01-02"
|
||||
D3 = datetime(2024, 6, 15, 23, 59, 59, tzinfo=UTC) # "2024-06-15" (late UTC instant)
|
||||
|
||||
D1_STR, D2_STR, D3_STR = "2019-06-15", "2020-01-02", "2024-06-15"
|
||||
|
||||
|
||||
def _doc(
|
||||
db: Session,
|
||||
source: str,
|
||||
path: str,
|
||||
title: str,
|
||||
content: str,
|
||||
created_at: datetime,
|
||||
) -> Document:
|
||||
doc = Document(
|
||||
id=uuid.uuid4(),
|
||||
source=source,
|
||||
path=path,
|
||||
full_path=f"/tmp/{source}/{path}",
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash="0" * 64,
|
||||
created_at=created_at, # D1: explicit — the pins prove per-row dates
|
||||
)
|
||||
db.add(doc)
|
||||
return doc
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def kb(db) -> Iterator[None]:
|
||||
"""Fresh documents table (chunks first — the FK)."""
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def src(db) -> Iterator[GitSource]:
|
||||
"""One registered git source — the scoped ``ls`` source-name check
|
||||
reads the real registry (``repo_name`` resolves the URL to
|
||||
``Homelab``)."""
|
||||
row = GitSource(url="https://github.com/reese/Homelab.git", kind="git")
|
||||
db.add(row)
|
||||
db.commit()
|
||||
yield row
|
||||
db.execute(delete(GitSource).where(GitSource.id == row.id))
|
||||
db.commit()
|
||||
|
||||
|
||||
class ScriptedToolLLM:
|
||||
"""One scripted tool-call stream, then one canned answer stream.
|
||||
Records every ``chat_stream`` request's messages and tools."""
|
||||
|
||||
def __init__(self, call: ToolCallPiece) -> None:
|
||||
self.call = call
|
||||
self.requests: list[
|
||||
tuple[list[dict[str, Any]], list[dict[str, Any]] | None]
|
||||
] = []
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
scaffolding: ScaffoldingFilter | None = None,
|
||||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||
self.requests.append((deepcopy(messages), deepcopy(tools)))
|
||||
if len(self.requests) == 1:
|
||||
yield self.call
|
||||
else:
|
||||
yield StreamPiece("content", "ans")
|
||||
|
||||
|
||||
def _settings(**kwargs: Any) -> Settings:
|
||||
kwargs.setdefault("_env_file", None)
|
||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
def _run_call(
|
||||
db: Session, name: str, arguments: dict[str, Any]
|
||||
) -> tuple[AgentHolder, ScriptedToolLLM]:
|
||||
"""Drive one scripted tool call through ``run_agent``."""
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedToolLLM(ToolCallPiece(id="call_1", name=name, arguments=arguments))
|
||||
asyncio.run(_consume(cast("LLMClient", llm), db, holder))
|
||||
return holder, llm
|
||||
|
||||
|
||||
async def _consume(
|
||||
llm: LLMClient, db: Session, holder: AgentHolder
|
||||
) -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
|
||||
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
|
||||
async for piece in run_agent(
|
||||
llm,
|
||||
db,
|
||||
system_prompt="SYSTEM_PROMPT",
|
||||
user_message="QUESTION",
|
||||
seed_docs=[],
|
||||
settings=_settings(),
|
||||
holder=holder,
|
||||
):
|
||||
out.append(piece)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# read — the date is the stored row's date, on the SECOND line
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_read_result_second_line_is_stored_date(kb, db) -> None:
|
||||
"""A real row (distinct ``created_at``): the ``read`` result's
|
||||
SECOND line is the stored date (the UTC date part), the FIRST line
|
||||
stays the byte-identical header, and the content follows whole."""
|
||||
created = _doc(
|
||||
db, "Alpha", "deep/nested/doc.md", "The Doc", "FULL-TEXT", D2
|
||||
)
|
||||
db.commit()
|
||||
|
||||
holder, llm = _run_call(db, "read", {"path": "Alpha/deep/nested/doc.md"})
|
||||
|
||||
content = llm.requests[1][0][3]["content"]
|
||||
lines = content.splitlines()
|
||||
assert lines[0] == "Document Alpha/deep/nested/doc.md:" # byte-identical header
|
||||
assert lines[1] == f"date: {D2_STR}" # the STORED date (row's UTC date part)
|
||||
assert lines[2:] == ["FULL-TEXT"]
|
||||
assert holder.read_docs == [created]
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
|
||||
def test_read_result_date_is_the_row_date_not_a_constant(kb, db) -> None:
|
||||
"""Two rows with DISTINCT dates: each ``read`` renders its OWN
|
||||
row's date (a late-UTC instant renders its date part, no time)."""
|
||||
a = _doc(db, "Alpha", "a.md", "A", "A-TEXT", D1)
|
||||
b = _doc(db, "Alpha", "b.md", "B", "B-TEXT", D3)
|
||||
db.commit()
|
||||
|
||||
holder_a, llm_a = _run_call(db, "read", {"path": "Alpha/a.md"})
|
||||
assert llm_a.requests[1][0][3]["content"] == (
|
||||
f"Document Alpha/a.md:\ndate: {D1_STR}\nA-TEXT"
|
||||
)
|
||||
holder_b, llm_b = _run_call(db, "read", {"path": "Alpha/b.md"})
|
||||
assert llm_b.requests[1][0][3]["content"] == (
|
||||
f"Document Alpha/b.md:\ndate: {D3_STR}\nB-TEXT"
|
||||
)
|
||||
assert holder_a.read_docs == [a] and holder_b.read_docs == [b]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# ls — every FILE line carries its date in the appended field
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ls_drill_file_lines_carry_their_dates(kb, src, db) -> None:
|
||||
"""A source drill against real rows: EVERY file line ends with the
|
||||
appended `` | date: YYYY-MM-DD`` field — each row's OWN stored date
|
||||
— while the header and subfolder lines stay date-free."""
|
||||
_doc(db, "Homelab", "backups/cron.md", "Cron", "CRON", D1)
|
||||
_doc(db, "Homelab", "backups/restic.md", "Restic", "RESTIC", D2)
|
||||
_doc(db, "Homelab", "networking/lan.md", "LAN", "LAN", D3)
|
||||
_doc(db, "Homelab", "readme.md", "Readme", "README", D1)
|
||||
db.commit()
|
||||
|
||||
holder, llm = _run_call(db, "ls", {"path": "Homelab"})
|
||||
|
||||
content = llm.requests[1][0][3]["content"]
|
||||
lines = content.splitlines()
|
||||
# The root level: one direct file (readme.md — D1) + the two
|
||||
# subfolder lines (date-free) + the date-free header.
|
||||
assert lines[0] == "Homelab — 1 documents, 2 folders:"
|
||||
assert lines[2] == " backups/ — 2 documents" # subfolder: no date
|
||||
assert lines[3] == " networking/ — 1 documents" # subfolder: no date
|
||||
assert lines[5] == (
|
||||
f"source: Homelab | path: readme.md | title: Readme | date: {D1_STR}"
|
||||
)
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
# Drill into backups: BOTH files list, each with its OWN date.
|
||||
holder2, llm2 = _run_call(db, "ls", {"path": "Homelab/backups"})
|
||||
lines2 = llm2.requests[1][0][3]["content"].splitlines()
|
||||
assert lines2[0] == "Homelab/backups — 2 documents, 0 folders:"
|
||||
assert lines2[2] == (
|
||||
f"source: Homelab | path: backups/cron.md | title: Cron | date: {D1_STR}"
|
||||
)
|
||||
assert lines2[3] == (
|
||||
f"source: Homelab | path: backups/restic.md | title: Restic | date: {D2_STR}"
|
||||
)
|
||||
assert holder2.tool_calls == 1
|
||||
|
||||
|
||||
def test_ls_top_level_source_lines_carry_no_date(kb, db) -> None:
|
||||
"""The top level (source lines) is UNCHANGED in shape — sources are
|
||||
not documents, so no date rides them (only FILE lines do). The
|
||||
registry is FRESH (truncated + the one source re-registered), so
|
||||
the top level is exactly the one source block."""
|
||||
db.execute(text("TRUNCATE git_sources"))
|
||||
db.commit()
|
||||
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git"))
|
||||
db.commit()
|
||||
try:
|
||||
_doc(db, "Homelab", "a.md", "A", "A-TEXT", D1)
|
||||
db.commit()
|
||||
|
||||
holder, llm = _run_call(db, "ls", {})
|
||||
|
||||
content = llm.requests[1][0][3]["content"]
|
||||
assert content == "1 sources:\n\nHomelab — 1 documents"
|
||||
assert "date" not in content
|
||||
assert holder.tool_calls == 1
|
||||
finally:
|
||||
db.execute(text("TRUNCATE git_sources"))
|
||||
db.commit()
|
||||
@@ -424,6 +424,7 @@ def test_document_content_admin_contract(client: TestClient, db) -> None:
|
||||
"title",
|
||||
"format",
|
||||
"summary", # nullable field added in phase 36 (null here — markdown)
|
||||
"created_at", # added in phase 106 (task 05)
|
||||
"content",
|
||||
"indexed_at",
|
||||
"chunks",
|
||||
|
||||
@@ -18,6 +18,7 @@ import math
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
@@ -40,6 +41,13 @@ from app.rag.retriever import TRUNCATION_MARKER
|
||||
from app.schemas import ChatDoneEvent, SourceRef
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
|
||||
#: The fixture documents' fixed creation date (phase 106, D5): the
|
||||
#: ``read`` result's second line is the row's ``created_at`` UTC date
|
||||
#: part — a fixed value keeps the read-result pins deterministic
|
||||
#: (instead of the ``now()`` server default of a bare insert).
|
||||
_FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.rag.scaffolding import ScaffoldingFilter
|
||||
|
||||
@@ -663,6 +671,7 @@ pins the agent-loop yield order on the real prompt path."""
|
||||
title="Big Doc",
|
||||
content=content,
|
||||
content_hash="1" * 64,
|
||||
created_at=_FIXTURE_CREATED_AT,
|
||||
)
|
||||
db.add(doc)
|
||||
db.commit()
|
||||
@@ -695,7 +704,11 @@ pins the agent-loop yield order on the real prompt path."""
|
||||
]
|
||||
assert tool_msgs, "the executed read must be appended as a tool message"
|
||||
body = tool_msgs[-1]["content"]
|
||||
assert body.startswith("Document docs/big.md:\n" + content[:cap])
|
||||
# Phase 106, D5: the date rides every read — the SECOND line
|
||||
# (first line byte-identical — the mock's header contract).
|
||||
assert body.startswith(
|
||||
"Document docs/big.md:\ndate: 2024-06-15\n" + content[:cap]
|
||||
)
|
||||
assert TRUNCATION_MARKER in body
|
||||
assert (
|
||||
READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content)) in body
|
||||
@@ -749,6 +762,7 @@ marker in the model's context."""
|
||||
title="Fits Doc",
|
||||
content=content,
|
||||
content_hash="2" * 64,
|
||||
created_at=_FIXTURE_CREATED_AT,
|
||||
)
|
||||
db.add(doc)
|
||||
db.commit()
|
||||
@@ -770,14 +784,18 @@ marker in the model's context."""
|
||||
# No ToolResultPiece, no holder entry.
|
||||
assert not any(isinstance(p, ToolResultPiece) for p in pieces)
|
||||
assert holder.read_truncations == []
|
||||
# The model's context is the whole document, byte-identical to
|
||||
# the pre-phase-95 read result (no marker, no notice). (The fake
|
||||
# aliases the mutated messages list, so take the last tool msg.)
|
||||
# The model's context is the whole document, the pre-phase-95
|
||||
# read result plus the phase-106 D5 date line (no marker, no
|
||||
# notice). (The fake aliases the mutated messages list, so
|
||||
# take the last tool msg.)
|
||||
tool_msgs = [
|
||||
m for r in scripted.seen_messages for m in r if m.get("role") == "tool"
|
||||
]
|
||||
assert tool_msgs, "the executed read must be appended as a tool message"
|
||||
assert tool_msgs[-1]["content"] == "Document docs/fits.md:\n" + content
|
||||
assert (
|
||||
tool_msgs[-1]["content"]
|
||||
== "Document docs/fits.md:\ndate: 2024-06-15\n" + content
|
||||
)
|
||||
assert TRUNCATION_MARKER not in tool_msgs[-1]["content"]
|
||||
# Still a successful read.
|
||||
assert holder.tool_calls == 1
|
||||
@@ -800,6 +818,7 @@ def _insert_big_doc(db, content: str) -> Document:
|
||||
title="Big Read Doc",
|
||||
content=content,
|
||||
content_hash="3" * 64,
|
||||
created_at=_FIXTURE_CREATED_AT,
|
||||
)
|
||||
db.add(doc)
|
||||
db.commit()
|
||||
@@ -890,7 +909,8 @@ def test_truncated_read_streams_tool_result_frame_after_tool_frame(
|
||||
]
|
||||
assert tool_msgs
|
||||
body = tool_msgs[-1]["content"]
|
||||
assert body.startswith(f"Document docs/big-read.md:\n{content[:cap]}")
|
||||
# Phase 106, D5: the date rides every read — the SECOND line.
|
||||
assert body.startswith(f"Document docs/big-read.md:\ndate: 2024-06-15\n{content[:cap]}")
|
||||
assert TRUNCATION_MARKER in body
|
||||
assert READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content)) in body
|
||||
# The truncated read is still a SUCCESSFUL call — cited in done.
|
||||
@@ -956,7 +976,10 @@ def test_untruncated_read_streams_no_tool_result_frame(
|
||||
m for r in scripted.seen_messages for m in r if m.get("role") == "tool"
|
||||
]
|
||||
assert tool_msgs
|
||||
assert tool_msgs[-1]["content"] == "Document docs/big-read.md:\n" + content
|
||||
assert (
|
||||
tool_msgs[-1]["content"]
|
||||
== "Document docs/big-read.md:\ndate: 2024-06-15\n" + content
|
||||
)
|
||||
assert TRUNCATION_MARKER not in tool_msgs[-1]["content"]
|
||||
|
||||
|
||||
|
||||
@@ -148,8 +148,14 @@ def test_docs_response_matches_schema_shape(admin_client, db) -> None:
|
||||
body = r.json()
|
||||
assert set(body) == {"documents"}
|
||||
for d in body["documents"]:
|
||||
assert set(d) == {"id", "source", "path", "title", "chunks", "indexed_at"}
|
||||
# Wire-additive (phase 106, task 05): the pre-date keys are all
|
||||
# still there, joined by ``created_at`` (the document's creation
|
||||
# date — the RAG view's ``Created`` column).
|
||||
assert set(d) == {
|
||||
"id", "source", "path", "title", "chunks", "created_at", "indexed_at"
|
||||
}
|
||||
assert isinstance(d["chunks"], int) and d["chunks"] >= 0
|
||||
datetime.fromisoformat(d["created_at"]) # raises if not ISO-8601
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
@@ -198,8 +204,11 @@ def test_docs_tree_populated_shape_order_counts_summaries(admin_client, db) -> N
|
||||
|
||||
homelab, deployments = sources
|
||||
# Wire-additive (phase 98, task 03): the pre-pending keys are all
|
||||
# still there, joined by ``summary_pending``.
|
||||
assert set(homelab) == {"name", "documents", "summary", "summary_pending", "children"}
|
||||
# still there, joined by ``summary_pending`` — and (phase 106,
|
||||
# task 05) by ``updated_at`` (the subtree's max document date, D9).
|
||||
assert set(homelab) == {
|
||||
"name", "documents", "updated_at", "summary", "summary_pending", "children"
|
||||
}
|
||||
assert homelab["documents"] == 4 # the whole recursive count
|
||||
assert homelab["summary"] == "Homelab docs." # the (source, "") row
|
||||
assert homelab["summary_pending"] is False # the stored root row covers it
|
||||
@@ -338,7 +347,10 @@ def test_docs_tree_summary_pending_on_source_and_folder_nodes(admin_client, db)
|
||||
r = admin_client.get("/api/docs/tree")
|
||||
assert r.status_code == 200
|
||||
(homelab,) = r.json()["sources"]
|
||||
assert set(homelab) == {"name", "documents", "summary", "summary_pending", "children"}
|
||||
# Phase 106 (task 05): ``updated_at`` joins the source node keys.
|
||||
assert set(homelab) == {
|
||||
"name", "documents", "updated_at", "summary", "summary_pending", "children"
|
||||
}
|
||||
assert homelab["summary"] == "Homelab docs."
|
||||
assert homelab["summary_pending"] is False
|
||||
# Direct subfolders in path order: k8s < wiki.
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
"""Integration: the phase-106 date API surface (task 05, D7/D8/D9).
|
||||
|
||||
``GET /api/docs`` and ``GET /api/documents/content`` serve the
|
||||
document's ``created_at``; ``GET /api/docs/tree`` serves the file
|
||||
``created_at`` verbatim plus the DERIVED subtree-max ``updated_at`` on
|
||||
folder/source nodes (``null`` for a 0-document registered source); and
|
||||
the admin-only ``PATCH /api/documents/date`` matrix — set (ISO date or
|
||||
full ISO datetime, future folds to today, ``created_at_manual``
|
||||
flagged), clear (null/absent → flag drops, stored date stands),
|
||||
malformed 422, unknown-pair 404, anonymous/token-user 403 — the
|
||||
phase-57 split intact (the viewer stays user-gated, the edit is
|
||||
admin-gated).
|
||||
|
||||
Uses the real compose Postgres (``db`` fixture) and FastAPI's
|
||||
TestClient, mirroring ``test_docs_api.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select, text
|
||||
|
||||
import app.api.docs as docs_api
|
||||
from app.core import tokens as token_service
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Chunk, Document, GitSource
|
||||
|
||||
_TRUNCATE = "chunks, documents, git_sources"
|
||||
|
||||
#: Deliberately distinct creation stamps (the D9 max fixture) — kept
|
||||
#: separate from ``indexed_at`` so a confused-column pin fails loudly.
|
||||
C0 = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC)
|
||||
C1 = datetime(2021, 6, 15, 12, 0, 0, tzinfo=UTC)
|
||||
C2 = datetime(2022, 3, 1, 6, 0, 0, tzinfo=UTC)
|
||||
C3 = datetime(2023, 11, 30, 23, 59, 59, tzinfo=UTC)
|
||||
|
||||
|
||||
def _truncate(db) -> None:
|
||||
db.execute(text(f"TRUNCATE {_TRUNCATE}"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_doc(
|
||||
db,
|
||||
source: str,
|
||||
path: str,
|
||||
title: str,
|
||||
n_chunks: int,
|
||||
indexed_at: datetime,
|
||||
created_at: datetime,
|
||||
) -> Document:
|
||||
"""One indexed document with an explicit creation date (D8)."""
|
||||
doc = Document(
|
||||
source=source,
|
||||
path=path,
|
||||
full_path=f"/tmp/{source}/{path}",
|
||||
title=title,
|
||||
content=f"# {title}\n\nBody.",
|
||||
content_hash=uuid.uuid4().hex, # unique per row (no real sha needed)
|
||||
indexed_at=indexed_at,
|
||||
created_at=created_at,
|
||||
)
|
||||
db.add(doc)
|
||||
db.flush()
|
||||
if n_chunks:
|
||||
db.add_all(
|
||||
Chunk(document_id=doc.id, position=i, content=f"chunk {i}", embedding=[0.01] * 768)
|
||||
for i in range(n_chunks)
|
||||
)
|
||||
db.commit()
|
||||
return doc
|
||||
|
||||
|
||||
def _tree_node(sources: list[dict], name: str) -> dict:
|
||||
return next(s for s in sources if s["name"] == name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reads — ``created_at`` on the two flat surfaces + the tree (D8/D9).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_docs_list_reports_created_at_per_row(admin_client, db) -> None:
|
||||
"""``GET /api/docs`` gains ``created_at`` per row (ISO-8601,
|
||||
verbatim from the row) — ``indexed_at`` untouched (a different
|
||||
concept: the index time)."""
|
||||
_truncate(db)
|
||||
base = datetime.now(UTC)
|
||||
_seed_doc(db, "Homelab", "a/top.md", "Top", 1, base, C2)
|
||||
_seed_doc(db, "Homelab", "a/b/deep.md", "Deep", 2, base + timedelta(hours=1), C3)
|
||||
try:
|
||||
r = admin_client.get("/api/docs")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# (source, path) order is unchanged by the new column
|
||||
# ("a/b/deep.md" < "a/top.md" — the slash sorts before the 't').
|
||||
assert [d["path"] for d in body["documents"]] == ["a/b/deep.md", "a/top.md"]
|
||||
by_path = {d["path"]: d for d in body["documents"]}
|
||||
assert by_path["a/top.md"]["created_at"] == C2.isoformat()
|
||||
assert by_path["a/b/deep.md"]["created_at"] == C3.isoformat()
|
||||
# ``indexed_at`` still reports the (different) index stamp —
|
||||
# the two concepts never get confused on the wire.
|
||||
assert abs(
|
||||
datetime.fromisoformat(by_path["a/top.md"]["indexed_at"]) - base
|
||||
).total_seconds() < 5
|
||||
assert by_path["a/top.md"]["indexed_at"] != by_path["a/top.md"]["created_at"]
|
||||
finally:
|
||||
_truncate(db)
|
||||
|
||||
|
||||
def test_document_content_carries_created_at(admin_client, db) -> None:
|
||||
"""``GET /api/documents/content`` gains ``created_at`` (the viewer's
|
||||
top meta row renders the ``Created`` badge from it, task 08)."""
|
||||
_truncate(db)
|
||||
base = datetime.now(UTC)
|
||||
_seed_doc(db, "Homelab", "k8s.md", "K8s", 1, base, C1)
|
||||
try:
|
||||
r = admin_client.get(
|
||||
"/api/documents/content", params={"source": "Homelab", "path": "k8s.md"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "created_at" in body
|
||||
assert body["created_at"] == C1.isoformat()
|
||||
# The two stamps are distinct concepts and both ride the shape.
|
||||
assert body["indexed_at"] != body["created_at"]
|
||||
finally:
|
||||
_truncate(db)
|
||||
|
||||
|
||||
def test_tree_file_dates_and_derived_subtree_max(admin_client, db) -> None:
|
||||
"""``GET /api/docs/tree``: file ``created_at`` verbatim; folder and
|
||||
source ``updated_at`` = the subtree's MAX document ``created_at``
|
||||
(D9 — derived in the pure builder, never stored); a registered
|
||||
0-document source reports ``updated_at: null`` with no children."""
|
||||
_truncate(db)
|
||||
base = datetime.now(UTC)
|
||||
db.add(GitSource(url="https://github.com/reese/Homelab.git", kind="git", added_at=base))
|
||||
db.add(
|
||||
GitSource(
|
||||
url="https://github.com/reese/Empty.git",
|
||||
kind="git",
|
||||
added_at=base + timedelta(hours=1),
|
||||
)
|
||||
)
|
||||
# The D9 max fixture: the deepest file holds the newest date, so a
|
||||
# folder that only LOOKS recent at its own level still reports the
|
||||
# deeper date (deeper beats shallower at every level here).
|
||||
_seed_doc(db, "Homelab", "a/b/c/deep.md", "Deep", 1, base, C3)
|
||||
_seed_doc(db, "Homelab", "a/b/shallow.md", "Shallow", 0, base, C1)
|
||||
_seed_doc(db, "Homelab", "a/top.md", "Top", 1, base, C2)
|
||||
_seed_doc(db, "Homelab", "root.md", "Root", 0, base, C0)
|
||||
try:
|
||||
r = admin_client.get("/api/docs/tree")
|
||||
assert r.status_code == 200
|
||||
sources = r.json()["sources"]
|
||||
# Registry order leads; the 0-document registered source lists.
|
||||
assert [s["name"] for s in sources] == ["Homelab", "Empty"]
|
||||
homelab, empty = sources
|
||||
assert (empty["documents"], empty["children"], empty["updated_at"]) == (0, [], None)
|
||||
|
||||
assert homelab["documents"] == 4
|
||||
assert homelab["updated_at"] == C3.isoformat()
|
||||
a = next(c for c in homelab["children"] if c["kind"] == "folder")
|
||||
root_md = next(c for c in homelab["children"] if c["kind"] == "file")
|
||||
assert a["path"] == "a"
|
||||
assert a["documents"] == 3
|
||||
assert a["updated_at"] == C3.isoformat() # deep C3 beats the direct C2
|
||||
assert root_md["created_at"] == C0.isoformat()
|
||||
a_b = next(c for c in a["children"] if c["kind"] == "folder")
|
||||
top = next(c for c in a["children"] if c["kind"] == "file")
|
||||
assert a_b["path"] == "a/b"
|
||||
assert a_b["documents"] == 2
|
||||
assert a_b["updated_at"] == C3.isoformat() # deep C3 beats the direct C1
|
||||
assert top["created_at"] == C2.isoformat()
|
||||
# a/b's children: the subfolder first, then its ONE direct file.
|
||||
a_b_c, shallow = a_b["children"]
|
||||
assert (a_b_c["kind"], a_b_c["path"], a_b_c["documents"]) == ("folder", "a/b/c", 1)
|
||||
assert a_b_c["updated_at"] == C3.isoformat()
|
||||
assert (shallow["kind"], shallow["path"], shallow["created_at"]) == (
|
||||
"file",
|
||||
"a/b/shallow.md",
|
||||
C1.isoformat(),
|
||||
)
|
||||
(deep,) = a_b_c["children"]
|
||||
assert (deep["kind"], deep["path"], deep["created_at"]) == (
|
||||
"file",
|
||||
"a/b/c/deep.md",
|
||||
C3.isoformat(),
|
||||
)
|
||||
# File nodes carry their own date only — no ``updated_at`` key.
|
||||
for node in (root_md, top, deep, shallow):
|
||||
assert "updated_at" not in node
|
||||
finally:
|
||||
_truncate(db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /api/documents/date (D7) — the admin document-date editor.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_patch_date_set_round_trips_and_flags_manual(admin_client, db) -> None:
|
||||
"""Set a bare ``YYYY-MM-DD``: the parse (midnight UTC) is stored
|
||||
verbatim, ``created_at_manual`` flips to true, the response echoes
|
||||
the stored state, and BOTH read surfaces confirm on re-GET."""
|
||||
_truncate(db)
|
||||
base = datetime.now(UTC)
|
||||
_seed_doc(db, "Homelab", "k8s.md", "K8s", 0, base, C0)
|
||||
try:
|
||||
r = admin_client.patch(
|
||||
"/api/documents/date",
|
||||
json={"source": "Homelab", "path": "k8s.md", "date": "2020-01-02"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert set(r.json()) == {"source", "path", "created_at", "created_at_manual"}
|
||||
assert r.json() == {
|
||||
"source": "Homelab",
|
||||
"path": "k8s.md",
|
||||
"created_at": "2020-01-02T00:00:00+00:00",
|
||||
"created_at_manual": True,
|
||||
}
|
||||
# The viewer's re-render source (the echo) and both GETs agree.
|
||||
content = admin_client.get(
|
||||
"/api/documents/content", params={"source": "Homelab", "path": "k8s.md"}
|
||||
).json()
|
||||
assert content["created_at"] == "2020-01-02T00:00:00+00:00"
|
||||
list_row = next(
|
||||
d
|
||||
for d in admin_client.get("/api/docs").json()["documents"]
|
||||
if d["path"] == "k8s.md"
|
||||
)
|
||||
assert list_row["created_at"] == "2020-01-02T00:00:00+00:00"
|
||||
# DB state: the flag is set (the sync-time importer then skips
|
||||
# this row — D1/D4).
|
||||
db.expire_all()
|
||||
row = db.scalar(
|
||||
select(Document).where(Document.source == "Homelab", Document.path == "k8s.md")
|
||||
)
|
||||
assert row is not None
|
||||
assert row.created_at == datetime(2020, 1, 2, tzinfo=UTC)
|
||||
assert row.created_at_manual is True
|
||||
finally:
|
||||
_truncate(db)
|
||||
|
||||
|
||||
def test_patch_date_full_iso_datetime_is_converted_to_utc(admin_client, db) -> None:
|
||||
"""A full ISO datetime with a NON-UTC offset is converted to UTC
|
||||
before storing (D3 — the single normalization choke point)."""
|
||||
_truncate(db)
|
||||
base = datetime.now(UTC)
|
||||
_seed_doc(db, "Homelab", "k8s.md", "K8s", 0, base, C0)
|
||||
try:
|
||||
r = admin_client.patch(
|
||||
"/api/documents/date",
|
||||
json={"source": "Homelab", "path": "k8s.md", "date": "2021-06-15T12:30:00+02:00"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["created_at"] == "2021-06-15T10:30:00+00:00"
|
||||
assert r.json()["created_at_manual"] is True
|
||||
finally:
|
||||
_truncate(db)
|
||||
|
||||
|
||||
def test_patch_date_malformed_422_and_leaves_row_untouched(admin_client, db) -> None:
|
||||
"""A malformed non-null value 422s in the HANDLER (the model field
|
||||
is an unconstrained ``str | None`` on purpose, so the detail can
|
||||
name the field) — and the row is left untouched."""
|
||||
_truncate(db)
|
||||
base = datetime.now(UTC)
|
||||
_seed_doc(db, "Homelab", "k8s.md", "K8s", 0, base, C0)
|
||||
try:
|
||||
r = admin_client.patch(
|
||||
"/api/documents/date",
|
||||
json={"source": "Homelab", "path": "k8s.md", "date": "not-a-date"},
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert r.json() == {
|
||||
"detail": "date must be an ISO date or datetime (e.g. 2024-06-15)"
|
||||
}
|
||||
db.expire_all()
|
||||
row = db.scalar(
|
||||
select(Document).where(Document.source == "Homelab", Document.path == "k8s.md")
|
||||
)
|
||||
assert row is not None
|
||||
assert row.created_at == C0 # untouched
|
||||
assert row.created_at_manual is False # untouched
|
||||
finally:
|
||||
_truncate(db)
|
||||
|
||||
|
||||
def test_patch_date_null_clear_drops_flag_and_keeps_date(admin_client, db) -> None:
|
||||
"""The CLEAR (D7): ``date: null`` drops ``created_at_manual`` ONLY —
|
||||
the stored date stands until the next sync refreshes it (the API is
|
||||
DB-only). An ABSENT ``date`` key is the same operation."""
|
||||
_truncate(db)
|
||||
base = datetime.now(UTC)
|
||||
_seed_doc(db, "Homelab", "k8s.md", "K8s", 0, base, C0)
|
||||
try:
|
||||
# Set first, so there is a correction to clear.
|
||||
r = admin_client.patch(
|
||||
"/api/documents/date",
|
||||
json={"source": "Homelab", "path": "k8s.md", "date": "2020-01-02"},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["created_at_manual"] is True
|
||||
|
||||
r = admin_client.patch(
|
||||
"/api/documents/date",
|
||||
json={"source": "Homelab", "path": "k8s.md", "date": None},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {
|
||||
"source": "Homelab",
|
||||
"path": "k8s.md",
|
||||
"created_at": "2020-01-02T00:00:00+00:00", # the date STOOD
|
||||
"created_at_manual": False,
|
||||
}
|
||||
|
||||
# Re-set, then clear with the key ABSENT — same operation.
|
||||
r = admin_client.patch(
|
||||
"/api/documents/date",
|
||||
json={"source": "Homelab", "path": "k8s.md", "date": "2020-01-02"},
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["created_at_manual"] is True
|
||||
r = admin_client.patch(
|
||||
"/api/documents/date",
|
||||
json={"source": "Homelab", "path": "k8s.md"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["created_at_manual"] is False
|
||||
assert r.json()["created_at"] == "2020-01-02T00:00:00+00:00"
|
||||
|
||||
db.expire_all()
|
||||
row = db.scalar(
|
||||
select(Document).where(Document.source == "Homelab", Document.path == "k8s.md")
|
||||
)
|
||||
assert row is not None
|
||||
assert row.created_at == datetime(2020, 1, 2, tzinfo=UTC) # still standing
|
||||
assert row.created_at_manual is False
|
||||
finally:
|
||||
_truncate(db)
|
||||
|
||||
|
||||
def test_patch_date_future_folds_to_today(admin_client, db) -> None:
|
||||
"""A manually set FUTURE date also folds to today (D3 — the same
|
||||
normalization choke point as the sourced path), and it is STILL
|
||||
flagged manual (the owner's correction survives syncs until
|
||||
cleared)."""
|
||||
_truncate(db)
|
||||
base = datetime.now(UTC)
|
||||
_seed_doc(db, "Homelab", "k8s.md", "K8s", 0, base, C0)
|
||||
try:
|
||||
r = admin_client.patch(
|
||||
"/api/documents/date",
|
||||
json={"source": "Homelab", "path": "k8s.md", "date": "2999-01-01"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
stored = datetime.fromisoformat(r.json()["created_at"])
|
||||
assert abs((stored - datetime.now(UTC)).total_seconds()) < 300 # ≈ today
|
||||
assert r.json()["created_at_manual"] is True
|
||||
db.expire_all()
|
||||
row = db.scalar(
|
||||
select(Document).where(Document.source == "Homelab", Document.path == "k8s.md")
|
||||
)
|
||||
assert row is not None
|
||||
assert row.created_at_manual is True
|
||||
assert row.created_at != datetime(2999, 1, 1, tzinfo=UTC)
|
||||
finally:
|
||||
_truncate(db)
|
||||
|
||||
|
||||
def test_patch_date_404_unknown_pair_and_traversal(admin_client, db) -> None:
|
||||
"""Row-lookup semantics (the ``/documents/content`` rule): unknown
|
||||
pairs — including traversal strings — are simply not rows → 404
|
||||
``document not found``; nothing is written."""
|
||||
_truncate(db)
|
||||
base = datetime.now(UTC)
|
||||
_seed_doc(db, "Homelab", "real.md", "Real", 0, base, C0)
|
||||
try:
|
||||
for source, path in (
|
||||
("Ghost", "x.md"), # unknown source
|
||||
("Homelab", "nope.md"), # known source, unknown path
|
||||
("Homelab", "../../etc/passwd"), # traversal: not a row
|
||||
):
|
||||
r = admin_client.patch(
|
||||
"/api/documents/date",
|
||||
json={"source": source, "path": path, "date": "2020-01-02"},
|
||||
)
|
||||
assert r.status_code == 404, (source, path, r.status_code)
|
||||
assert r.json() == {"detail": "document not found"}, (source, path)
|
||||
db.expire_all()
|
||||
row = db.scalar(
|
||||
select(Document).where(Document.source == "Homelab", Document.path == "real.md")
|
||||
)
|
||||
assert row is not None
|
||||
assert row.created_at_manual is False # nothing written
|
||||
finally:
|
||||
_truncate(db)
|
||||
|
||||
|
||||
def test_patch_date_403_anonymous_and_token_user(client, db) -> None:
|
||||
"""The ``require_admin`` gate (the phase-57 split): an anonymous
|
||||
caller AND a live access-token user who is not the admin both get
|
||||
403 ``admin only`` — while the viewer content itself stays
|
||||
user-gated (a token holder could read the document, just not edit
|
||||
its date)."""
|
||||
_truncate(db)
|
||||
base = datetime.now(UTC)
|
||||
_seed_doc(db, "Homelab", "k8s.md", "K8s", 0, base, C0)
|
||||
db.execute(text("TRUNCATE api_tokens"))
|
||||
db.commit()
|
||||
body = {"source": "Homelab", "path": "k8s.md", "date": "2020-01-02"}
|
||||
try:
|
||||
# Anonymous (the shared ``client`` is unsigned in this module).
|
||||
r = client.patch("/api/documents/date", json=body)
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
# A live access-token user who is not the admin (phase 79).
|
||||
_row, plaintext = token_service.create_token(db, "pin-holder")
|
||||
db.commit()
|
||||
holder = TestClient(fastapi_app)
|
||||
s = holder.post("/api/token-auth", json={"token": plaintext})
|
||||
assert s.status_code == 204, s.text
|
||||
r = holder.patch("/api/documents/date", json=body)
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
# …and the SPLIT: the same holder CAN read the document (the
|
||||
# viewer stays user-gated, phase 79).
|
||||
got = holder.get(
|
||||
"/api/documents/content", params={"source": "Homelab", "path": "k8s.md"}
|
||||
)
|
||||
assert got.status_code == 200
|
||||
db.expire_all()
|
||||
row = db.scalar(
|
||||
select(Document).where(Document.source == "Homelab", Document.path == "k8s.md")
|
||||
)
|
||||
assert row is not None
|
||||
assert row.created_at_manual is False # the rejected edits wrote nothing
|
||||
finally:
|
||||
db.execute(text("TRUNCATE api_tokens"))
|
||||
db.commit()
|
||||
_truncate(db)
|
||||
|
||||
|
||||
def test_patch_date_handler_is_db_only_and_never_constructs_an_llm_client() -> None:
|
||||
"""Source pin (the house pattern of the phase-97 folder-summary
|
||||
editor): a date is NEVER embedded — no chunk, no retrieval role —
|
||||
so the handler must never touch the LLM client (the deliberate
|
||||
contrast with the phase-57 ``is_summary`` re-embed)."""
|
||||
src = inspect.getsource(docs_api.update_document_date)
|
||||
assert "LLMClient" not in src
|
||||
@@ -79,6 +79,10 @@ def _seed_doc(
|
||||
content=content,
|
||||
content_hash="a" * 64,
|
||||
indexed_at=datetime.now(UTC),
|
||||
# Phase 106: pin the creation date explicitly — the endpoint
|
||||
# serves it verbatim (the column is NOT NULL; the server
|
||||
# default would make the pin time-dependent).
|
||||
created_at=datetime(2020, 5, 4, 8, 30, 0, tzinfo=UTC),
|
||||
summary=summary,
|
||||
)
|
||||
db.add(doc)
|
||||
@@ -417,9 +421,13 @@ def test_content_200_all_fields(client, db) -> None:
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# Wire-additive (phase 106, task 05): ``created_at`` joins the
|
||||
# content shape (after ``summary``, before ``content``).
|
||||
assert set(body) == {
|
||||
"source", "path", "title", "format", "summary", "content", "indexed_at", "chunks"
|
||||
"source", "path", "title", "format", "summary", "created_at",
|
||||
"content", "indexed_at", "chunks",
|
||||
}
|
||||
datetime.fromisoformat(body["created_at"]) # raises if not ISO-8601
|
||||
assert body["source"] == "Homelab"
|
||||
assert body["path"] == "kubernetes.md"
|
||||
assert body["title"] == "Kubernetes Homelab Cluster"
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Integration: phase 106 task 03 — ``file_commit_dates`` against real
|
||||
git scratch repos (D2/D10).
|
||||
|
||||
Builds scratch repositories with controlled ``GIT_COMMITTER_DATE``s
|
||||
(the 2026-09-13 verification recipe: file ``a.md`` committed once in
|
||||
2020, file ``b.md`` committed in 2020 and touched again in 2024, a
|
||||
``docs/deep.md`` subdirectory file committed once in 2020) and pins
|
||||
the VERIFIED checkout behavior:
|
||||
|
||||
* a LOCAL-PATH ``clone_or_pull`` keeps FULL history (git's own
|
||||
"--depth is ignored in local clones" warning — the ``--depth 1``
|
||||
flag stays, D10) → TRUE per-file last-commit dates (first-sighting
|
||||
wins: ``a.md`` 2020, ``b.md`` 2024, ``docs/deep.md`` 2020);
|
||||
* a shallow URL-transport clone (``file://``, made directly in this
|
||||
test — the test harness, not ``clone_or_pull``, makes this one) →
|
||||
the TIP commit's date for EVERY working-tree file (the
|
||||
shallow-boundary property, D10: uniform per repo, real across
|
||||
repos);
|
||||
* fail-soft: a directory without ``.git``, an empty repo (no
|
||||
commits), a git failure, and a malformed log output all yield
|
||||
``{}`` — a date walk must never break a sync (the importer, task
|
||||
04, falls back to file mtimes).
|
||||
|
||||
DB-free by design: ``file_commit_dates`` takes a path, no session.
|
||||
Skipped (not failed) on a machine without the git CLI (the
|
||||
``test_doc_drafts_api.py`` guard).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.git_sync import (
|
||||
GitSyncError,
|
||||
_parse_commit_dates, # pyright: ignore[reportPrivateUsage]
|
||||
clone_or_pull,
|
||||
file_commit_dates,
|
||||
)
|
||||
|
||||
|
||||
def _git_available() -> bool:
|
||||
try:
|
||||
proc = subprocess.run(["git", "--version"], capture_output=True, check=False)
|
||||
return proc.returncode == 0
|
||||
except (FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
#: Real ``git`` in the test environment — skipped cleanly without it
|
||||
#: (the ``test_doc_drafts_api.py`` house pattern).
|
||||
GIT = _git_available()
|
||||
|
||||
pytestmark = pytest.mark.skipif(not GIT, reason="git CLI not available")
|
||||
|
||||
#: The two controlled commit dates (the 2026-09-13 verification recipe).
|
||||
DATE_A = datetime(2020, 1, 2, 3, 4, 6, tzinfo=UTC) # commit one (2020)
|
||||
DATE_B = datetime(2024, 6, 15, 10, 0, 0, tzinfo=UTC) # commit two = the tip (2024)
|
||||
|
||||
|
||||
def _git(cwd: Path, *argv: str, when: datetime | None = None) -> None:
|
||||
"""Run one git command for the test harness (fixture setup); a
|
||||
non-zero exit fails the fixture, not the test under test."""
|
||||
env = os.environ.copy()
|
||||
if when is not None:
|
||||
iso = when.isoformat()
|
||||
env["GIT_AUTHOR_DATE"] = iso
|
||||
env["GIT_COMMITTER_DATE"] = iso
|
||||
env["GIT_AUTHOR_NAME"] = "T"
|
||||
env["GIT_AUTHOR_EMAIL"] = "t@example.com"
|
||||
env["GIT_COMMITTER_NAME"] = "T"
|
||||
env["GIT_COMMITTER_EMAIL"] = "t@example.com"
|
||||
proc = subprocess.run(
|
||||
["git", *argv], cwd=cwd, env=env, capture_output=True, text=True, check=False
|
||||
)
|
||||
assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scratch_repo(tmp_path: Path) -> Path:
|
||||
"""The 2026-09-13 recipe: commit one (2020-01-02) adds ``a.md``,
|
||||
``b.md``, ``docs/deep.md``; commit two (2024-06-15, the tip)
|
||||
touches ONLY ``b.md``."""
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-q")
|
||||
_git(repo, "config", "user.email", "t@example.com")
|
||||
_git(repo, "config", "user.name", "T")
|
||||
_git(repo, "config", "commit.gpgsign", "false")
|
||||
(repo / "docs").mkdir()
|
||||
(repo / "a.md").write_text("# A\nstable since 2020\n", encoding="utf-8")
|
||||
(repo / "b.md").write_text("# B\nfirst version\n", encoding="utf-8")
|
||||
(repo / "docs" / "deep.md").write_text("# Deep\nalso 2020\n", encoding="utf-8")
|
||||
_git(repo, "add", "-A", when=DATE_A)
|
||||
_git(repo, "commit", "-qm", "one", when=DATE_A)
|
||||
(repo / "b.md").write_text("# B\nupdated 2024\n", encoding="utf-8")
|
||||
_git(repo, "add", "-A", when=DATE_B)
|
||||
_git(repo, "commit", "-qm", "two", when=DATE_B)
|
||||
return repo
|
||||
|
||||
|
||||
def test_local_clone_yields_true_per_file_dates(scratch_repo: Path, tmp_path: Path) -> None:
|
||||
"""(a) LOCAL-PATH ``clone_or_pull`` → full history (git warns
|
||||
"--depth is ignored in local clones" and does not shallow) → TRUE
|
||||
per-file last-commit dates: the first (newest) sighting of each
|
||||
path wins — ``b.md`` the 2024 touch, the rest the 2020 commit."""
|
||||
dest = tmp_path / "local"
|
||||
clone_or_pull(str(scratch_repo), dest)
|
||||
assert (dest / ".git").exists() # a real checkout
|
||||
assert file_commit_dates(dest) == {
|
||||
"a.md": DATE_A,
|
||||
"b.md": DATE_B, # touched again by the tip commit
|
||||
"docs/deep.md": DATE_A,
|
||||
}
|
||||
|
||||
|
||||
def test_shallow_file_clone_yields_tip_date_for_every_file(
|
||||
scratch_repo: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""(b) SHALLOW URL-TRANSPORT clone (``file://``, made directly in
|
||||
the test — D10): in a shallow clone git reports the TIP commit as
|
||||
every existing file's last commit (the shallow boundary is each
|
||||
file's history root) → EVERY working-tree file carries the tip
|
||||
date, uniform within the repo."""
|
||||
dest = tmp_path / "shallow"
|
||||
_git(tmp_path, "clone", "-q", "--depth", "1", f"file://{scratch_repo}", str(dest))
|
||||
assert file_commit_dates(dest) == {
|
||||
"a.md": DATE_B,
|
||||
"b.md": DATE_B,
|
||||
"docs/deep.md": DATE_B,
|
||||
}
|
||||
|
||||
|
||||
def test_directory_without_dotgit_fails_soft(tmp_path: Path) -> None:
|
||||
"""(c) a plain directory (no ``.git``) → ``git log`` exits
|
||||
non-zero → ``{}`` (fail-soft, no raise) — the importer falls back
|
||||
to file mtimes."""
|
||||
plain = tmp_path / "notarepo"
|
||||
plain.mkdir()
|
||||
(plain / "a.md").write_text("# A\nnot a git repo\n", encoding="utf-8")
|
||||
assert file_commit_dates(plain) == {}
|
||||
|
||||
|
||||
def test_nonexistent_directory_fails_soft(tmp_path: Path) -> None:
|
||||
"""(c) a missing checkout directory → ``{}`` without even
|
||||
invoking git (no raise)."""
|
||||
assert file_commit_dates(tmp_path / "gone") == {}
|
||||
|
||||
|
||||
def test_empty_repo_fails_soft(tmp_path: Path) -> None:
|
||||
"""(c) an initialized repo with NO commits → ``git log`` fails
|
||||
(nothing to log) → ``{}`` (a cloned-but-empty source must not
|
||||
break the sync)."""
|
||||
empty = tmp_path / "emptyrepo"
|
||||
empty.mkdir()
|
||||
_git(empty, "init", "-q")
|
||||
_git(empty, "config", "commit.gpgsign", "false")
|
||||
assert file_commit_dates(empty) == {}
|
||||
|
||||
|
||||
def test_git_error_fails_soft(
|
||||
scratch_repo: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""(c) a ``GitSyncError`` from the walk (git missing/failed) →
|
||||
``{}`` + a logged warning naming the fallback — the fail-soft
|
||||
contract (pinned)."""
|
||||
|
||||
def boom(argv: list[str], cwd: Path) -> str:
|
||||
raise GitSyncError("git log failed (exit 128): fatal: bad object")
|
||||
|
||||
monkeypatch.setattr("scripts.git_sync.run_git", boom)
|
||||
with caplog.at_level("WARNING"):
|
||||
assert file_commit_dates(scratch_repo) == {}
|
||||
assert any("file_commit_dates" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
def test_malformed_log_output_fails_soft(
|
||||
scratch_repo: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""(c) ANY parse anomaly (a commit date ``fromisoformat`` cannot
|
||||
read) → ``{}`` (fail-soft) — the walk is all-or-nothing: a
|
||||
partially parsed date map would be worse than none."""
|
||||
monkeypatch.setattr(
|
||||
"scripts.git_sync.run_git",
|
||||
lambda argv, cwd: "@@not-a-date\nb.md\n",
|
||||
)
|
||||
assert file_commit_dates(scratch_repo) == {}
|
||||
|
||||
|
||||
# --- the pure parser (canned git output — no git, no DB) -------------------
|
||||
|
||||
|
||||
def test_parser_first_sighting_wins_newest_first() -> None:
|
||||
"""The walk is newest-first, so the FIRST sighting of a path is
|
||||
its last-commit date: ``b.md`` appears under both commits and keeps
|
||||
the 2024 (newest) date; the 2020 ``a.md`` keeps 2020. Blank lines
|
||||
(git's commit separators) are skipped."""
|
||||
output = "\n".join(
|
||||
[
|
||||
"@@2024-06-15T10:00:00+00:00",
|
||||
"",
|
||||
"b.md",
|
||||
"@@2020-01-02T03:04:06+00:00",
|
||||
"",
|
||||
"a.md",
|
||||
"b.md",
|
||||
]
|
||||
)
|
||||
assert _parse_commit_dates(output) == {"a.md": DATE_A, "b.md": DATE_B}
|
||||
|
||||
|
||||
def test_parser_normalizes_paths() -> None:
|
||||
"""Path lines are whitespace-split (defensively - git's name-only
|
||||
output is one path per line), backslash-normalized to ``/``, and a
|
||||
leading ``/`` is stripped (repo-relative POSIX keys); the line is
|
||||
stripped first."""
|
||||
output = "\n".join(
|
||||
[
|
||||
"@@2024-06-15T10:00:00+00:00",
|
||||
"",
|
||||
"docs\\deep.md",
|
||||
"/rooted.md",
|
||||
" padded.md ",
|
||||
]
|
||||
)
|
||||
assert _parse_commit_dates(output) == {
|
||||
"docs/deep.md": DATE_B,
|
||||
"rooted.md": DATE_B,
|
||||
"padded.md": DATE_B,
|
||||
}
|
||||
|
||||
|
||||
def test_parser_rejects_path_before_header() -> None:
|
||||
"""A path line before ANY commit header is a malformed walk →
|
||||
``ValueError`` (the caller's fail-soft path turns it into
|
||||
``{}``)."""
|
||||
with pytest.raises(ValueError, match="before any commit header"):
|
||||
_parse_commit_dates("stray.md\n@@2024-06-15T10:00:00+00:00\n")
|
||||
|
||||
|
||||
def test_parser_rejects_bad_date() -> None:
|
||||
"""A commit date ``fromisoformat`` cannot read → ``ValueError``
|
||||
(ISO-strict ``%cI`` always parses — this is the anomaly guard)."""
|
||||
with pytest.raises(ValueError):
|
||||
_parse_commit_dates("@@yesterday\nb.md\n")
|
||||
@@ -28,6 +28,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -93,11 +94,13 @@ class FakeImportSources:
|
||||
limit: int | None = None,
|
||||
ignore_by_root: dict[str, list[str]] | None = None, # phase 89
|
||||
include_hidden_by_root: dict[str, bool] | None = None, # phase 105
|
||||
doc_dates_by_root: dict[str, dict[str, datetime]] | None = None, # phase 106
|
||||
) -> ImportSummary:
|
||||
self.calls.append(
|
||||
{"sources": list(sources), "prune": prune, "limit": limit,
|
||||
"ignore_by_root": ignore_by_root,
|
||||
"include_hidden_by_root": include_hidden_by_root}
|
||||
"include_hidden_by_root": include_hidden_by_root,
|
||||
"doc_dates_by_root": doc_dates_by_root}
|
||||
)
|
||||
return ImportSummary(files=1, added=1)
|
||||
|
||||
@@ -194,7 +197,7 @@ def test_resolve_sources_git_urls_cloned_into_sources_dir(
|
||||
sources_dir=str(tmp_path / "bor"),
|
||||
)
|
||||
|
||||
sources, ignore_map, hidden_map = import_docs._resolve_sources(None, settings)
|
||||
sources, ignore_map, hidden_map, date_map = import_docs._resolve_sources(None, settings)
|
||||
|
||||
assert sources == [tmp_path / "bor" / "homelab", tmp_path / "bor" / "deploy"]
|
||||
assert ignore_map == {} # phase 89: no row carries a list → empty map
|
||||
@@ -204,6 +207,13 @@ def test_resolve_sources_git_urls_cloned_into_sources_dir(
|
||||
str(tmp_path / "bor" / "homelab"): False,
|
||||
str(tmp_path / "bor" / "deploy"): False,
|
||||
}
|
||||
# Phase 106: git rows are listed with their checkout's date walk —
|
||||
# the fake checkouts are not git repos, so the walk fails soft to
|
||||
# ``{}`` (the importer would then take the mtime fallback).
|
||||
assert date_map == {
|
||||
str(tmp_path / "bor" / "homelab"): {},
|
||||
str(tmp_path / "bor" / "deploy"): {},
|
||||
}
|
||||
assert calls == [
|
||||
("https://host/a/homelab.git", tmp_path / "bor" / "homelab"),
|
||||
("git@host:user/deploy.git", tmp_path / "bor" / "deploy"),
|
||||
@@ -218,11 +228,14 @@ def test_resolve_sources_cli_source_wins(
|
||||
settings = _settings(git_sources="https://host/a/repo.git")
|
||||
manual = tmp_path / "Manual"
|
||||
|
||||
sources, ignore_map, hidden_map = import_docs._resolve_sources([manual], settings)
|
||||
sources, ignore_map, hidden_map, date_map = import_docs._resolve_sources([manual], settings)
|
||||
|
||||
assert sources == [manual]
|
||||
assert ignore_map == {} # phase 89: manual dirs have no rows → no ignore
|
||||
assert hidden_map == {} # phase 105: manual dirs have no rows → hidden skipped
|
||||
# Phase 106: manual dirs have no rows (no clone) → no date map
|
||||
# entries (the importer's mtime fallback applies).
|
||||
assert date_map == {}
|
||||
assert calls == [] # git is never touched when --source is given
|
||||
|
||||
|
||||
@@ -243,12 +256,14 @@ def test_resolve_sources_db_rows_win_over_env(
|
||||
sources_dir=str(tmp_path / "bor"),
|
||||
)
|
||||
|
||||
sources, ignore_map, hidden_map = import_docs._resolve_sources(None, settings)
|
||||
sources, ignore_map, hidden_map, date_map = import_docs._resolve_sources(None, settings)
|
||||
|
||||
assert sources == [tmp_path / "bor" / "only"]
|
||||
assert ignore_map == {} # phase 89: no row carries a list → empty map
|
||||
# Phase 105: the default-flag row contributes its root with False (A4).
|
||||
assert hidden_map == {str(tmp_path / "bor" / "only"): False}
|
||||
# Phase 106: the git row's (fake, non-repo) checkout fails soft → {}.
|
||||
assert date_map == {str(tmp_path / "bor" / "only"): {}}
|
||||
assert calls == [("https://db.example/only.git", tmp_path / "bor" / "only")]
|
||||
|
||||
|
||||
@@ -257,10 +272,13 @@ def test_resolve_sources_defaults_when_nothing_configured(
|
||||
) -> None:
|
||||
# Both origins empty (the resolver's ``([], "env")``) → legacy dirs.
|
||||
monkeypatch.setattr(import_docs, "effective_sources", lambda db: ([], "env"))
|
||||
sources, ignore_map, hidden_map = import_docs._resolve_sources(None, _settings())
|
||||
sources, ignore_map, hidden_map, date_map = import_docs._resolve_sources(None, _settings())
|
||||
assert sources == [p.expanduser() for p in import_docs.DEFAULT_SOURCES]
|
||||
assert ignore_map == {} # phase 89: the legacy fallback has no rows
|
||||
assert hidden_map == {} # phase 105: the legacy fallback has no rows
|
||||
# Phase 106: the legacy fallback has no rows (no clone) → mtime
|
||||
# fallback for every file.
|
||||
assert date_map == {}
|
||||
|
||||
|
||||
def test_resolve_sources_rows_branch_builds_ignore_map(
|
||||
@@ -286,13 +304,16 @@ def test_resolve_sources_rows_branch_builds_ignore_map(
|
||||
)
|
||||
settings = _settings(sources_dir=str(tmp_path / "bor"))
|
||||
|
||||
sources, ignore_map, hidden_map = import_docs._resolve_sources(None, settings)
|
||||
sources, ignore_map, hidden_map, date_map = import_docs._resolve_sources(None, settings)
|
||||
|
||||
assert sources == [tmp_path / "bor" / "only", local_dir]
|
||||
# Keyed by the SAME string the importer sees (the root, not the name).
|
||||
assert ignore_map == {str(local_dir): ["ignore/"]}
|
||||
# Phase 105: both rows are flag-off → per-root False entries (A4).
|
||||
assert hidden_map == {str(tmp_path / "bor" / "only"): False, str(local_dir): False}
|
||||
# Phase 106: ONLY the git row is listed (local rows take the mtime
|
||||
# fallback); the fake checkout's date walk fails soft to ``{}``.
|
||||
assert date_map == {str(tmp_path / "bor" / "only"): {}}
|
||||
|
||||
|
||||
def test_resolve_sources_two_rows_sharing_root_string_extend(
|
||||
@@ -317,7 +338,7 @@ def test_resolve_sources_two_rows_sharing_root_string_extend(
|
||||
)
|
||||
settings = _settings(sources_dir=str(tmp_path / "bor"))
|
||||
|
||||
sources, ignore_map, hidden_map = import_docs._resolve_sources(None, settings)
|
||||
sources, ignore_map, hidden_map, date_map = import_docs._resolve_sources(None, settings)
|
||||
|
||||
shared = str(tmp_path / "bor" / "shared")
|
||||
assert sources == [tmp_path / "bor" / "shared", tmp_path / "bor" / "shared"]
|
||||
@@ -325,6 +346,10 @@ def test_resolve_sources_two_rows_sharing_root_string_extend(
|
||||
# Phase 105 collision: the shared root gets the OR of the flags —
|
||||
# both rows off here, so one False entry for the one root string.
|
||||
assert hidden_map == {shared: False}
|
||||
# Phase 106 collision: both git rows resolve to the SAME root — one
|
||||
# date walk for the one root string (last row's walk wins, both
|
||||
# fail soft to ``{}`` for the fake checkout).
|
||||
assert date_map == {shared: {}}
|
||||
|
||||
|
||||
def test_main_rows_branch_passes_ignore_map_to_import(
|
||||
@@ -362,6 +387,9 @@ def test_main_rows_branch_passes_ignore_map_to_import(
|
||||
# Phase 105: the default-flag row passes the per-root map too — a
|
||||
# False entry, not an absent key (the importer reads it per root).
|
||||
assert call["include_hidden_by_root"] == {str(local_dir): False}
|
||||
# Phase 106: the local-only resolution contributes no date map
|
||||
# (no clone — the importer's mtime fallback applies).
|
||||
assert call["doc_dates_by_root"] == {}
|
||||
assert call["prune"] is False # the CLI's no-prune default is unchanged
|
||||
|
||||
|
||||
@@ -539,6 +567,12 @@ def test_main_git_sources_clone_then_import(
|
||||
tmp_path / "bor" / "homelab",
|
||||
tmp_path / "bor" / "deploy",
|
||||
]
|
||||
# Phase 106: the git rows' (fake, non-repo) checkouts fail soft to
|
||||
# ``{}`` — but the roots ARE listed (the CLI feeds the map).
|
||||
assert fake_import.calls[0]["doc_dates_by_root"] == {
|
||||
str(tmp_path / "bor" / "homelab"): {},
|
||||
str(tmp_path / "bor" / "deploy"): {},
|
||||
}
|
||||
for dest in (tmp_path / "bor" / "homelab", tmp_path / "bor" / "deploy"):
|
||||
assert (dest / "notes.md").is_file()
|
||||
# The final summary print reflects the import (added > 0).
|
||||
@@ -574,6 +608,9 @@ def test_main_cli_source_still_imports_manual_dir(
|
||||
assert rc == 0
|
||||
assert calls == []
|
||||
assert fake_import.calls[0]["sources"] == [manual]
|
||||
# Phase 106: manual --source has no rows (no clone) → no date map
|
||||
# (the importer's mtime fallback applies).
|
||||
assert fake_import.calls[0]["doc_dates_by_root"] == {}
|
||||
assert fake_import.calls[0]["prune"] is False
|
||||
# Phase 53: a manual --source run that changes the KB bumps exactly
|
||||
# once (the CLI is the other canonical sync path).
|
||||
@@ -605,12 +642,15 @@ def test_resolve_sources_mixed_git_and_local(
|
||||
sources_dir=str(tmp_path / "bor"),
|
||||
)
|
||||
|
||||
sources, ignore_map, hidden_map = import_docs._resolve_sources(None, settings)
|
||||
sources, ignore_map, hidden_map, date_map = import_docs._resolve_sources(None, settings)
|
||||
|
||||
assert sources == [tmp_path / "bor" / "only", local_dir]
|
||||
assert ignore_map == {} # phase 89: neither row carries a list
|
||||
# Phase 105: both rows default-flag → per-root False entries (A4).
|
||||
assert hidden_map == {str(tmp_path / "bor" / "only"): False, str(local_dir): False}
|
||||
# Phase 106: only the git row is listed (local takes the mtime
|
||||
# fallback); the fake checkout's date walk fails soft to ``{}``.
|
||||
assert date_map == {str(tmp_path / "bor" / "only"): {}}
|
||||
assert calls == [("https://db.example/only.git", tmp_path / "bor" / "only")]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Integration: phase 106 (task 04) — the importer's date semantics on
|
||||
real Postgres.
|
||||
|
||||
The backfill-correction case (D4, the owner's "on sync, update the
|
||||
date"): a row first imported with a "today" mtime (the shape every
|
||||
pre-phase-106 deployment has after the migration's
|
||||
``server_default=now()`` backfill) gets its REAL — older — date on the
|
||||
next sync even though the content did not change; the refresh is a
|
||||
date-only ``unchanged``, so the ``sources_meta`` generation the sync
|
||||
paths gate their bump on (``added + updated + pruned > 0``) stays put
|
||||
(seeded before, read after). The D1 manual lock and the prune
|
||||
interaction are pinned across the real DB boundary too.
|
||||
|
||||
Deterministic in-process :class:`~tests.fakes.FakeEmbedder` — no
|
||||
network, no live model (the ``test_importer_e2e.py`` pattern).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Document
|
||||
from app.rag.importer import import_sources
|
||||
from app.rag.sources_meta import current_sources_version
|
||||
from tests.fakes import FakeEmbedder
|
||||
|
||||
#: mtime granularity tolerance (os.utime + stat round-trip).
|
||||
_TOL = timedelta(milliseconds=50)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_kb(db: Session) -> Iterator[None]:
|
||||
"""Global KB state — truncated around every test (the
|
||||
``test_importer_e2e.py`` shape)."""
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pin_sources_version(db: Session) -> Iterator[None]:
|
||||
"""The single-row ``sources_meta`` generation is global mutable
|
||||
state — pin it to a known, non-zero value around every test so the
|
||||
no-bump assertion proves the gate, not the seed."""
|
||||
db.execute(text("UPDATE sources_meta SET version = 7 WHERE id = 1"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _doc(db: Session, source: str, rel: str) -> Document:
|
||||
doc = db.scalar(select(Document).where(Document.source == source, Document.path == rel))
|
||||
assert doc is not None, f"no documents row for ({source!r}, {rel!r})"
|
||||
return doc
|
||||
|
||||
|
||||
def test_unchanged_reimport_refreshes_backfilled_date_without_version_bump(
|
||||
db: Session, tmp_path: Path
|
||||
) -> None:
|
||||
"""The backfill-correction case (D4), end to end on real Postgres.
|
||||
|
||||
Run 1: the file's mtime is "now" (the migration backfill shape) →
|
||||
the row stores a today-date. The file is then ``os.utime``'d back
|
||||
to 2019 with IDENTICAL content. Run 2: the row stores the 2019
|
||||
date — ``added/updated/pruned`` all 0 (it is still ``unchanged``)
|
||||
and ``dates_updated == 1``. Because the content counts did not
|
||||
move, the ``sources_meta`` generation the sync paths bump on a
|
||||
KB change stays exactly where it was seeded (7).
|
||||
"""
|
||||
root = tmp_path / "Backfill"
|
||||
root.mkdir()
|
||||
file = root / "note.md"
|
||||
file.write_text("# Note\n\nthe content never changes\n", encoding="utf-8")
|
||||
assert current_sources_version(db) == 7 # the seeded generation
|
||||
|
||||
llm = FakeEmbedder()
|
||||
first = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (first.added, first.unchanged, first.dates_updated) == (1, 0, 0)
|
||||
# The backfill shape: the stored date is the "today" mtime.
|
||||
stored = _doc(db, root.name, "note.md").created_at
|
||||
assert abs(stored - datetime.now(UTC)) < timedelta(seconds=60)
|
||||
|
||||
real = datetime(2019, 3, 4, 8, 0, 0, tzinfo=UTC)
|
||||
os.utime(file, (real.timestamp(), real.timestamp())) # content identical
|
||||
|
||||
second = asyncio.run(import_sources([root], llm, session=db))
|
||||
# A date-only refresh: unchanged for every content gate.
|
||||
assert (second.added, second.updated, second.pruned, second.unchanged) == (0, 0, 0, 1)
|
||||
assert second.dates_updated == 1
|
||||
db.expire_all()
|
||||
doc = _doc(db, root.name, "note.md")
|
||||
assert abs(doc.created_at - real) <= _TOL # the real (OLDER) date stored
|
||||
assert doc.created_at_manual is False
|
||||
# The date-only refresh left the generation untouched — the
|
||||
# ``added + updated + pruned > 0`` gate the sync paths use never
|
||||
# fired (D4: no sources_meta bump, no regeneration).
|
||||
assert current_sources_version(db) == 7
|
||||
|
||||
|
||||
def test_manual_date_survives_unchanged_reimport_on_real_db(
|
||||
db: Session, tmp_path: Path
|
||||
) -> None:
|
||||
"""D1 across the DB boundary: the owner's correction
|
||||
(``created_at_manual`` — task 05's API writes it) survives an
|
||||
unchanged re-sync whose source date moved; the generation stays
|
||||
put too (no write happened at all on that row)."""
|
||||
root = tmp_path / "ManualKeep"
|
||||
root.mkdir()
|
||||
file = root / "note.md"
|
||||
file.write_text("# Note\n\ncorrected by the owner\n", encoding="utf-8")
|
||||
llm = FakeEmbedder()
|
||||
first = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert first.added == 1
|
||||
correction = datetime(2024, 11, 30, 15, 45, 0, tzinfo=UTC)
|
||||
doc = _doc(db, root.name, "note.md")
|
||||
doc.created_at = correction
|
||||
doc.created_at_manual = True
|
||||
db.commit()
|
||||
ts = datetime(2018, 1, 1, 0, 0, 0, tzinfo=UTC).timestamp()
|
||||
os.utime(file, (ts, ts))
|
||||
|
||||
second = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (second.added, second.updated, second.unchanged) == (0, 0, 1)
|
||||
assert second.dates_updated == 0 # the correction was NOT refreshed
|
||||
db.expire_all()
|
||||
doc = _doc(db, root.name, "note.md")
|
||||
assert doc.created_at == correction # byte-identical (no rewrite)
|
||||
assert doc.created_at_manual is True
|
||||
assert current_sources_version(db) == 7
|
||||
|
||||
|
||||
def test_date_only_refresh_coexists_with_prune_on_real_db(
|
||||
db: Session, tmp_path: Path
|
||||
) -> None:
|
||||
"""The matrix in one ``prune=True`` run (the sync button's shape):
|
||||
a manual row survives untouched, a non-manual unchanged row gets
|
||||
its date refreshed (counted in ``dates_updated`` only), and a
|
||||
deleted file is still pruned — the content gates and the date
|
||||
refresh compose without interfering."""
|
||||
root = tmp_path / "Matrix"
|
||||
root.mkdir()
|
||||
kept_manual = root / "manual.md"
|
||||
kept_manual.write_text("# Manual\n\nowner-corrected\n", encoding="utf-8")
|
||||
kept_plain = root / "plain.md"
|
||||
kept_plain.write_text("# Plain\n\nrefreshes\n", encoding="utf-8")
|
||||
gone = root / "gone.md"
|
||||
gone.write_text("# Gone\n\ndeleted upstream\n", encoding="utf-8")
|
||||
llm = FakeEmbedder()
|
||||
first = asyncio.run(import_sources([root], llm, session=db, prune=True))
|
||||
assert first.added == 3
|
||||
|
||||
correction = datetime(2022, 7, 1, 10, 0, 0, tzinfo=UTC)
|
||||
doc = _doc(db, root.name, "manual.md")
|
||||
doc.created_at = correction
|
||||
doc.created_at_manual = True
|
||||
db.commit()
|
||||
moved = datetime(2017, 9, 9, 9, 9, 9, tzinfo=UTC)
|
||||
os.utime(kept_plain, (moved.timestamp(), moved.timestamp()))
|
||||
gone.unlink() # deleted upstream
|
||||
|
||||
second = asyncio.run(import_sources([root], llm, session=db, prune=True))
|
||||
# The refresh counts ONLY in dates_updated; the prune is a content
|
||||
# count (so this run DOES advance the generation — the gate is on
|
||||
# pruned, not on dates_updated).
|
||||
assert (second.added, second.updated, second.unchanged, second.pruned) == (0, 0, 2, 1)
|
||||
assert second.dates_updated == 1
|
||||
db.expire_all()
|
||||
manual = _doc(db, root.name, "manual.md")
|
||||
assert manual.created_at == correction and manual.created_at_manual is True
|
||||
plain = _doc(db, root.name, "plain.md")
|
||||
assert abs(plain.created_at - moved) <= _TOL and plain.created_at_manual is False
|
||||
# The pruned row is gone (the content gate did its job alongside the
|
||||
# date refresh).
|
||||
assert (
|
||||
db.scalar(select(Document).where(Document.source == root.name, Document.path == "gone.md"))
|
||||
is None
|
||||
)
|
||||
# The version the sync paths gate on was seeded, not advanced — this
|
||||
# suite only runs ``import_sources`` (the bump lives in the entry
|
||||
# points, which this run's ``pruned=1`` would trigger).
|
||||
assert current_sources_version(db) == 7
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Integration: migration 0020 (documents.created_at / created_at_manual)
|
||||
schema contract (phase 106, task 01).
|
||||
|
||||
Drives the **real Alembic engine** against the live dev database
|
||||
(``podman compose up -d db``), mirroring the house pattern of
|
||||
``test_migration_0019.py`` (information_schema assertions on the state the
|
||||
migration must leave). The tests target the 0019 → 0020 step explicitly so
|
||||
later migrations cannot break the pins:
|
||||
|
||||
* upgrade 0019 → 0020 → both columns exist with the full contract —
|
||||
``created_at`` TIMESTAMP WITH TIME ZONE NOT NULL, server default
|
||||
``now()``; ``created_at_manual`` BOOLEAN NOT NULL, server default
|
||||
``false`` — while the 0019 ``documents`` schema (incl. ``indexed_at``,
|
||||
``summary``) survives;
|
||||
* a ``documents`` row inserted while the DB is at 0019 backfills
|
||||
``created_at ≈ now()`` (the D1 backfill-to-today, within a few seconds
|
||||
of the upgrade moment) and ``created_at_manual is False``; a row written
|
||||
after the upgrade without the columns takes both server defaults;
|
||||
* the ORM contract agrees: a freshly inserted ``Document`` (nothing passed)
|
||||
reads ``created_at_manual is False`` + non-null ``created_at``, and an
|
||||
explicit ``created_at`` + ``created_at_manual=True`` round-trips through
|
||||
a fresh session;
|
||||
* downgrade to 0019 → both columns GONE (A13) while the row + its content
|
||||
survive; upgrade back to 0020 → both columns back (round-trip).
|
||||
|
||||
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
||||
fails or the process is interrupted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.db import SessionLocal, db_available
|
||||
from app.models import Document
|
||||
|
||||
SOURCE = "mig0020"
|
||||
CONTENT = "content here"
|
||||
CONTENT_HASH = hashlib.sha256(CONTENT.encode()).hexdigest()
|
||||
# The backfill is evaluated by the ALTER at the upgrade moment; the 5 s
|
||||
# slack each side absorbs test-process scheduling without weakening the
|
||||
# "≈ now()" pin (the DB and the test share the host clock).
|
||||
SLACK = timedelta(seconds=5)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def alembic(db: Session) -> Iterator[Config]:
|
||||
"""Real Alembic config bound to the dev DB (URL from app settings).
|
||||
|
||||
Starts at head (repairs an interrupted earlier run); teardown upgrades
|
||||
to head no matter what happened, so the dev DB is never left below
|
||||
head.
|
||||
"""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
command.upgrade(cfg, "head")
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
# Release the test session's open transaction BEFORE the repair
|
||||
# DDL: an idle-in-transaction SELECT holds an ACCESS SHARE lock
|
||||
# on ``documents``, which would deadlock the repair's
|
||||
# ``ALTER TABLE`` (0020) forever.
|
||||
db.rollback()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def _version(db: Session) -> str | None:
|
||||
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
|
||||
"""(data_type, is_nullable, column_default) for one documents column."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT data_type, is_nullable, column_default"
|
||||
" FROM information_schema.columns"
|
||||
" WHERE table_name = 'documents' AND column_name = :c"
|
||||
),
|
||||
{"c": column},
|
||||
).fetchone()
|
||||
return tuple(row) if row is not None else None
|
||||
|
||||
|
||||
def _row_dates(db: Session, path: str) -> tuple[Any, Any]:
|
||||
"""(created_at, created_at_manual) for one documents row."""
|
||||
row = db.execute(
|
||||
text("SELECT created_at, created_at_manual FROM documents WHERE path = :p"),
|
||||
{"p": path},
|
||||
).fetchone()
|
||||
assert row is not None, f"documents row {path!r} must exist"
|
||||
return row[0], row[1]
|
||||
|
||||
|
||||
def _insert_sql(db: Session, path: str) -> uuid.UUID:
|
||||
"""Insert one documents row (the pre-0020 column shape — the new
|
||||
columns, when present, are omitted so the server defaults apply)."""
|
||||
doc_id = uuid.uuid4()
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO documents (id, source, path, full_path, title, content,"
|
||||
" content_hash) VALUES (:id, :s, :p, :fp, :t, :c, :h)"
|
||||
),
|
||||
{
|
||||
"id": doc_id,
|
||||
"s": SOURCE,
|
||||
"p": path,
|
||||
"fp": f"/tmp/{path}",
|
||||
"t": f"Doc {path}",
|
||||
"c": CONTENT,
|
||||
"h": CONTENT_HASH,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
return doc_id
|
||||
|
||||
|
||||
def _delete_by_path(db: Session, path: str) -> None:
|
||||
db.execute(text("DELETE FROM documents WHERE path = :p"), {"p": path})
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_upgrade_to_0020_adds_created_at(db: Session, alembic: Config) -> None:
|
||||
"""Upgrade 0019 → 0020: both columns exist with the full contract
|
||||
(``created_at`` TIMESTAMP WITH TIME ZONE NOT NULL default ``now()``;
|
||||
``created_at_manual`` BOOLEAN NOT NULL default ``false``), are ABSENT
|
||||
at 0019, a pre-0020 row backfills ``created_at ≈ now()`` + the
|
||||
manual flag ``false`` (D1), a new row without the columns takes both
|
||||
server defaults, and an explicit ``true`` round-trips — while the
|
||||
0019 table contract (``indexed_at``, ``summary``) survives."""
|
||||
command.downgrade(alembic, "0019") # start from the pre-0020 state
|
||||
assert _version(db) == "0019"
|
||||
assert _column(db, "created_at") is None, "created_at must be absent at 0019"
|
||||
assert _column(db, "created_at_manual") is None, (
|
||||
"created_at_manual must be absent at 0019"
|
||||
)
|
||||
|
||||
path_pre = "pre-existing.md"
|
||||
_insert_sql(db, path_pre) # no created_at* columns exist at 0019
|
||||
try:
|
||||
window_start = datetime.now(UTC)
|
||||
command.upgrade(alembic, "0020")
|
||||
window_end = datetime.now(UTC)
|
||||
assert _version(db) == "0020", "alembic_version must be at 0020"
|
||||
|
||||
created = _column(db, "created_at")
|
||||
assert created is not None, "documents.created_at is missing"
|
||||
assert created[0] == "timestamp with time zone", (
|
||||
"created_at must be TIMESTAMP WITH TIME ZONE"
|
||||
)
|
||||
assert created[1] == "NO", "created_at must be NOT NULL"
|
||||
assert created[2] is not None and "now()" in str(created[2]), (
|
||||
"created_at must carry the `now()` server default"
|
||||
)
|
||||
|
||||
manual = _column(db, "created_at_manual")
|
||||
assert manual is not None, "documents.created_at_manual is missing"
|
||||
assert manual[0] == "boolean", "created_at_manual must be BOOLEAN"
|
||||
assert manual[1] == "NO", "created_at_manual must be NOT NULL"
|
||||
assert manual[2] is not None and "false" in str(manual[2]), (
|
||||
"created_at_manual must carry the `false` server default"
|
||||
)
|
||||
|
||||
# The pre-0020 row backfilled created_at ≈ now() (D1 — the owner's
|
||||
# "set it to today's date during the migration") + flag false.
|
||||
backfilled, manual_pre = _row_dates(db, path_pre)
|
||||
assert backfilled is not None, "the backfilled created_at must be non-null"
|
||||
assert backfilled.tzinfo is not None, "created_at must be tz-aware"
|
||||
backfilled_utc = backfilled.astimezone(UTC)
|
||||
assert window_start - SLACK <= backfilled_utc <= window_end + SLACK, (
|
||||
f"the backfill must be ≈ the upgrade moment (got {backfilled_utc})"
|
||||
)
|
||||
assert manual_pre is False, (
|
||||
"the backfilled row must read created_at_manual is False"
|
||||
)
|
||||
|
||||
# A row written without the columns takes both server defaults.
|
||||
path_new = "new-row.md"
|
||||
_insert_sql(db, path_new)
|
||||
try:
|
||||
created_new, manual_new = _row_dates(db, path_new)
|
||||
assert created_new is not None
|
||||
created_new_utc = created_new.astimezone(UTC)
|
||||
assert window_end - SLACK <= created_new_utc <= datetime.now(UTC) + SLACK, (
|
||||
f"an omitted created_at takes the `now()` server default"
|
||||
f" (got {created_new_utc})"
|
||||
)
|
||||
assert manual_new is False, (
|
||||
"an omitted flag takes the `false` server default"
|
||||
)
|
||||
|
||||
# The flag round-trips through an explicit ``true``.
|
||||
db.execute(
|
||||
text("UPDATE documents SET created_at_manual = true WHERE path = :p"),
|
||||
{"p": path_new},
|
||||
)
|
||||
db.commit()
|
||||
assert _row_dates(db, path_new)[1] is True, (
|
||||
"created_at_manual = true must round-trip"
|
||||
)
|
||||
finally:
|
||||
_delete_by_path(db, path_new)
|
||||
|
||||
# The 0019 schema survives the additive upgrade.
|
||||
indexed = _column(db, "indexed_at")
|
||||
assert indexed is not None, "documents.indexed_at (0001) must survive the upgrade"
|
||||
assert indexed[0] == "timestamp with time zone" and indexed[1] == "NO", (
|
||||
"documents.indexed_at (0001) must keep its 0019 contract after the upgrade"
|
||||
)
|
||||
summary = _column(db, "summary")
|
||||
assert summary is not None and summary[0] == "text" and summary[1] == "YES", (
|
||||
"documents.summary (0004) must survive the upgrade"
|
||||
)
|
||||
finally:
|
||||
_delete_by_path(db, path_pre)
|
||||
|
||||
|
||||
def test_orm_fresh_row_defaults_and_explicit_round_trips(
|
||||
db: Session, alembic: Config
|
||||
) -> None:
|
||||
"""The ORM contract agrees with the column contract: a freshly
|
||||
inserted ``Document`` (nothing passed for the new columns) reads
|
||||
``created_at_manual is False`` + non-null ``created_at`` (the server
|
||||
default took effect — D1), and an explicit ``created_at`` +
|
||||
``created_at_manual=True`` round-trips through a fresh session."""
|
||||
command.upgrade(alembic, "head")
|
||||
path_default = "orm-default.md"
|
||||
path_explicit = "orm-explicit.md"
|
||||
try:
|
||||
# Fresh row, both new columns omitted → server/Python defaults.
|
||||
row_default = Document(
|
||||
source=SOURCE,
|
||||
path=path_default,
|
||||
full_path=f"/tmp/{path_default}",
|
||||
title="Default",
|
||||
content=CONTENT,
|
||||
content_hash=CONTENT_HASH,
|
||||
)
|
||||
db.add(row_default)
|
||||
db.commit()
|
||||
db.expire_all()
|
||||
reloaded_default = db.get(Document, row_default.id)
|
||||
assert reloaded_default is not None, "the fresh row must be readable"
|
||||
assert reloaded_default.created_at is not None, (
|
||||
"a fresh row must read a non-null created_at (server default)"
|
||||
)
|
||||
assert reloaded_default.created_at_manual is False, (
|
||||
"a fresh row must read created_at_manual is False"
|
||||
)
|
||||
|
||||
# Explicit created_at + created_at_manual=True round-trip through
|
||||
# a FRESH session.
|
||||
explicit = datetime(2020, 6, 15, 12, 30, 45, 123456, tzinfo=UTC)
|
||||
row_explicit = Document(
|
||||
source=SOURCE,
|
||||
path=path_explicit,
|
||||
full_path=f"/tmp/{path_explicit}",
|
||||
title="Explicit",
|
||||
content=CONTENT,
|
||||
content_hash=CONTENT_HASH,
|
||||
created_at=explicit,
|
||||
created_at_manual=True,
|
||||
)
|
||||
db.add(row_explicit)
|
||||
db.commit()
|
||||
with SessionLocal() as fresh:
|
||||
reloaded = fresh.get(Document, row_explicit.id)
|
||||
assert reloaded is not None, "the row must exist in a fresh session"
|
||||
assert reloaded.created_at is not None
|
||||
assert reloaded.created_at.astimezone(UTC) == explicit, (
|
||||
"the explicit created_at must round-trip through the DB"
|
||||
)
|
||||
assert reloaded.created_at_manual is True, (
|
||||
"created_at_manual=True must round-trip through the DB"
|
||||
)
|
||||
finally:
|
||||
_delete_by_path(db, path_default)
|
||||
_delete_by_path(db, path_explicit)
|
||||
|
||||
|
||||
def test_downgrade_to_0019_drops_the_columns(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade 0020 → 0019: both columns are gone (A13 — fully
|
||||
reversible) while the row + its content survive, and the rest of the
|
||||
0019 table contract (``indexed_at``) is intact."""
|
||||
command.upgrade(alembic, "head")
|
||||
path = "survivor.md"
|
||||
_insert_sql(db, path)
|
||||
try:
|
||||
command.downgrade(alembic, "0019")
|
||||
assert _version(db) == "0019"
|
||||
assert _column(db, "created_at") is None, "created_at must be dropped"
|
||||
assert _column(db, "created_at_manual") is None, (
|
||||
"created_at_manual must be dropped"
|
||||
)
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT path, title, content, content_hash, indexed_at"
|
||||
" FROM documents WHERE path = :p"
|
||||
),
|
||||
{"p": path},
|
||||
).fetchone()
|
||||
assert row is not None and row[0] == path, (
|
||||
"the row must survive the column drops"
|
||||
)
|
||||
assert row[1] == "Doc survivor.md" and row[2] == CONTENT, (
|
||||
"title + content must survive the column drops"
|
||||
)
|
||||
assert row[3] == CONTENT_HASH, "the content hash must survive the drop"
|
||||
assert row[4] is not None, "indexed_at must survive the column drops"
|
||||
|
||||
indexed = _column(db, "indexed_at")
|
||||
assert indexed is not None and indexed[0] == "timestamp with time zone", (
|
||||
"documents.indexed_at must survive the downgrade"
|
||||
)
|
||||
finally:
|
||||
_delete_by_path(db, path)
|
||||
# Repair: the fixture teardown re-upgrades to head.
|
||||
|
||||
|
||||
def test_upgrade_round_trip_restores_the_columns(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0019, then upgrade back to 0020: both columns are
|
||||
back with the full contract (TIMESTAMPTZ NOT NULL default ``now()``;
|
||||
BOOLEAN NOT NULL default ``false``)."""
|
||||
command.downgrade(alembic, "0019")
|
||||
command.upgrade(alembic, "0020")
|
||||
assert _version(db) == "0020", "round-trip upgrade must land at 0020"
|
||||
|
||||
created = _column(db, "created_at")
|
||||
assert created is not None, "documents.created_at must be back"
|
||||
assert created[0] == "timestamp with time zone", (
|
||||
"created_at must be TIMESTAMP WITH TIME ZONE after the round-trip"
|
||||
)
|
||||
assert created[1] == "NO", "created_at must be NOT NULL after the round-trip"
|
||||
assert created[2] is not None and "now()" in str(created[2]), (
|
||||
"the `now()` server default must survive the round-trip"
|
||||
)
|
||||
|
||||
manual = _column(db, "created_at_manual")
|
||||
assert manual is not None, "documents.created_at_manual must be back"
|
||||
assert manual[0] == "boolean", (
|
||||
"created_at_manual must be BOOLEAN after the round-trip"
|
||||
)
|
||||
assert manual[1] == "NO", (
|
||||
"created_at_manual must be NOT NULL after the round-trip"
|
||||
)
|
||||
assert manual[2] is not None and "false" in str(manual[2]), (
|
||||
"the `false` server default must survive the round-trip"
|
||||
)
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Integration: the D6 recency boost against real Postgres (phase 106,
|
||||
task 07 — the "fine line" battery, the owner's warning pinned
|
||||
permanently).
|
||||
|
||||
The owner's scenario (2026-09-13): "make sure to test with documents
|
||||
that have the correct answer but are older against documents that are
|
||||
similar and newer but don't quit correctly answer the question."
|
||||
Deterministic axis vectors (the ``test_name_hit_lexical.py`` idiom —
|
||||
exact cosines) pin every fused score to a known rank pair, so the
|
||||
margins below are exact floats, not flaky measurements.
|
||||
|
||||
**Measured geometry (recorded per task step 4/5):**
|
||||
|
||||
* Owner scenario — A (``backups/retention.md``, created 2020-01-01,
|
||||
the exact answer, cosine 1.0) lands at vector rank 1 + FTS rank 1
|
||||
(fused 0.03278689); B (``backups/retention-draft.md``, created
|
||||
yesterday, the "under review, no decision yet" draft, cosine
|
||||
0.707107) lands at vector rank 10 + FTS rank 3 (fused 0.03015873 —
|
||||
a solid FTS hit at rank 3, as the task describes). Pre-boost fused
|
||||
margin **A−B = 0.00262816** (asserted ≥ 3× the zero-age boost =
|
||||
0.002100 at the default → ratio 1.25, the "comfortable margin").
|
||||
* Twin near-tie — C (``twin/c-older.md``, 2019) and D
|
||||
(``twin/d-newer.md``, yesterday) with IDENTICAL chunk text and
|
||||
near-identical vectors (cosine 1.0 vs 0.9999 — a literal identical
|
||||
vector ties the vector list's ``ORDER BY distance``, which Postgres
|
||||
resolves arbitrarily, and a permanent pin may not depend on that)
|
||||
sit one adjacent rank step apart in BOTH lists: base gap
|
||||
**C−D = 2/61 − 2/62 = 0.00052882** — a true near-tie on the RRF
|
||||
scale.
|
||||
* The DEFAULT was tuned from the design starting point (0.001) down to
|
||||
**0.0007** (task step 5: "tune the DEFAULTS … until old-correct wins
|
||||
comfortably"): on the k=60 scale the owner scenario's margin is
|
||||
0.00262816 < 3×0.001, and a 0.001 zero-age boost (+0.000997 for a
|
||||
yesterday doc) would have FLIPPED the pinned scenario. 0.0007 keeps
|
||||
the flip margin comfortable (0.000698 > 0.00052882, lead
|
||||
+0.000169) while staying 1.25× under the 3×-boost margin bar. The
|
||||
owner re-tunes live via ``BOR_RECENCY_BOOST``.
|
||||
|
||||
Requires: ``podman compose up -d db``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.models import Chunk, Document
|
||||
from app.rag import retriever
|
||||
from app.rag.retriever import (
|
||||
_lexical_candidates,
|
||||
_vector_candidates,
|
||||
fuse,
|
||||
retrieve,
|
||||
select_documents,
|
||||
)
|
||||
|
||||
QUESTION = "How did I configure the backup retention policy?"
|
||||
|
||||
#: 768-dim test vectors (the pgvector column's dimension) — axis unit
|
||||
#: vectors so the cosines are exact (1.0 parallel, 0.7071 half-parallel,
|
||||
#: and constructed unit vectors with exact cosine ``q``).
|
||||
D = 768
|
||||
|
||||
def _vec(axis: int, second: bool = False) -> list[float]:
|
||||
v = [0.0] * D
|
||||
v[axis] = 1.0
|
||||
if second:
|
||||
v[axis + 1] = 1.0
|
||||
return v
|
||||
|
||||
|
||||
def _cos_vec(axis: int, q: float, side: int | None = None) -> list[float]:
|
||||
"""A unit vector with EXACT cosine ``q`` against the axis unit vector."""
|
||||
v = [0.0] * D
|
||||
v[axis] = q
|
||||
v[axis + (side if side is not None else 1)] = math.sqrt(max(0.0, 1.0 - q * q))
|
||||
return v
|
||||
|
||||
|
||||
A_TEXT = (
|
||||
"The backup retention policy: I configured restic on the homelab NAS "
|
||||
"with 35 daily, 12 weekly and 12 monthly backups kept. The backup "
|
||||
"retention policy was configured in /etc/retention.conf and the "
|
||||
"configured schedule is reviewed every quarter."
|
||||
)
|
||||
#: Similar-but-wrong: shares the topic tokens, NO answer (no "configured").
|
||||
B_TEXT = "Draft: the backup retention policy is under review, no decision yet."
|
||||
#: The FTS rank-2 decoy: the topic tokens at a higher ts_rank than B.
|
||||
REVIEW_TEXT = (
|
||||
"backup retention policy review: the backup retention policy needs a "
|
||||
"refresh, backup retention policy discussion notes, backup retention "
|
||||
"policy follow-up planned."
|
||||
)
|
||||
NOTE_TEXT = "backup note {i}: a single word of shared vocabulary."
|
||||
F2_TEXT = "nfs snapshot notes: the policy for nfs shares is to snapshot nightly."
|
||||
|
||||
#: The twins' IDENTICAL chunk body (both match the question's tsquery).
|
||||
TWIN_TEXT = (
|
||||
"Twin document for the recency battery: the backup retention policy is "
|
||||
"configured the same way here."
|
||||
)
|
||||
|
||||
|
||||
def _seed(
|
||||
db: Session,
|
||||
path: str,
|
||||
title: str,
|
||||
content: str,
|
||||
created_at: datetime,
|
||||
embedding: list[float],
|
||||
source: str = "Homelab",
|
||||
) -> None:
|
||||
doc = Document(
|
||||
id=uuid.uuid4(),
|
||||
source=source,
|
||||
path=path,
|
||||
full_path=f"/tmp/{path}",
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash="0" * 64,
|
||||
indexed_at=datetime.now(UTC),
|
||||
created_at=created_at,
|
||||
)
|
||||
db.add(doc)
|
||||
db.flush()
|
||||
chunk = Chunk(
|
||||
id=uuid.uuid4(), document_id=doc.id, position=0, content=content
|
||||
)
|
||||
db.add(chunk)
|
||||
db.flush()
|
||||
chunk.embedding = embedding
|
||||
|
||||
|
||||
#: The question's vector (synthetic — ``retrieve`` takes it as an arg):
|
||||
#: the axis unit vector, so the seeded cosines are exact.
|
||||
QUESTION_VEC = _vec(5)
|
||||
|
||||
|
||||
def _boost_settings(**overrides: Any) -> Settings:
|
||||
"""The live settings with the recency knobs overridden (the house
|
||||
settings-override pattern — ``Settings(_env_file=None, …)``)."""
|
||||
live = get_settings()
|
||||
kwargs: dict[str, Any] = {
|
||||
"recency_boost": live.recency_boost,
|
||||
"recency_half_life_days": live.recency_half_life_days,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return Settings(_env_file=None, **kwargs) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
def _boost_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Patch the retriever's settings to the kill switch (``0`` = off)."""
|
||||
monkeypatch.setattr(
|
||||
retriever, "get_settings", lambda: _boost_settings(recency_boost=0.0)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def owner_kb(db) -> Iterator[None]:
|
||||
"""THE owner scenario: the older doc that ANSWERS (A, 2020) vs the
|
||||
newer doc that merely resembles the topic (B, yesterday) — plus the
|
||||
KB of similar-but-not-answering backup docs that push B to vector
|
||||
rank 10 while keeping it a solid FTS hit at rank 3 (the task's
|
||||
described shape)."""
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
_seed(
|
||||
db, "backups/retention.md", "backup retention", A_TEXT,
|
||||
datetime(2020, 1, 1, tzinfo=UTC), _vec(5),
|
||||
)
|
||||
for i, q in enumerate((0.99, 0.98, 0.97, 0.96, 0.95, 0.94, 0.93, 0.92)):
|
||||
_seed(
|
||||
db, f"backups/notes/n{i:02d}.md", f"backup note {i}",
|
||||
NOTE_TEXT.format(i=i), datetime(2021 + i % 3, 1, 1 + i, tzinfo=UTC),
|
||||
_cos_vec(5, q),
|
||||
)
|
||||
_seed(
|
||||
db, "backups/retention-review.md", "retention review", REVIEW_TEXT,
|
||||
datetime(2021, 3, 5, tzinfo=UTC), _cos_vec(5, 0.5),
|
||||
)
|
||||
_seed(
|
||||
db, "backups/retention-draft.md", "retention draft", B_TEXT,
|
||||
datetime.now(UTC) - timedelta(days=1), _vec(5, second=True),
|
||||
)
|
||||
_seed(
|
||||
db, "backups/nfs-snapshots.md", "nfs snapshots", F2_TEXT,
|
||||
datetime(2022, 6, 10, tzinfo=UTC), _cos_vec(5, 0.4),
|
||||
)
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _seed_twins(db: Session, d_created_at: datetime) -> None:
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
_seed(
|
||||
db, "twin/c-older.md", "twin c", TWIN_TEXT,
|
||||
datetime(2019, 6, 1, tzinfo=UTC), _vec(5), source="twin",
|
||||
)
|
||||
_seed(
|
||||
db, "twin/d-newer.md", "twin d", TWIN_TEXT, d_created_at,
|
||||
_cos_vec(5, 0.9999), source="twin",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def twins(db) -> Iterator[None]:
|
||||
"""The near-tie pair: IDENTICAL text, near-identical vectors, C
|
||||
(2019) older and base-ranked first, D (yesterday) newer."""
|
||||
_seed_twins(db, datetime.now(UTC) - timedelta(days=1))
|
||||
yield
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def twins_aged(db) -> Iterator[None]:
|
||||
"""The same pair with D aged to ``half_life + 365`` days (730 at the
|
||||
default — two half-lives, the boost decayed to ``e**-2`` ≈ 0.135 of
|
||||
the full weight)."""
|
||||
half_life = get_settings().recency_half_life_days
|
||||
_seed_twins(db, datetime.now(UTC) - timedelta(days=half_life + 365))
|
||||
yield
|
||||
db.execute(text("TRUNCATE chunks, documents"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_owner_scenario_old_correct_beats_new_similar(
|
||||
owner_kb, db, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""THE owner scenario, pinned at the DEFAULTS: the older doc that
|
||||
answers ranks above the newer similar one — AND the pre-boost fused
|
||||
margin is ≥ 3× the zero-age boost (the "comfortable margin"; the
|
||||
measured 0.00262816 vs the 0.0021 bar is recorded in the module
|
||||
docstring). Re-pinned with the boost OFF: relevance alone already
|
||||
ordered them (no regression — the boost is not what makes A win)."""
|
||||
chunks = retrieve(db, QUESTION, QUESTION_VEC)
|
||||
assert select_documents(chunks, n=2)[0].path == "backups/retention.md"
|
||||
|
||||
# The pre-boost fused scores, computed via ``fuse`` directly.
|
||||
s = get_settings()
|
||||
vector = _vector_candidates(db, QUESTION_VEC, s.hybrid_vector_candidates)
|
||||
lexical = _lexical_candidates(db, QUESTION, s.hybrid_lexical_candidates)
|
||||
fused = fuse(vector, lexical, s.rrf_k)
|
||||
by_path = {rc.document.path: rc.score for rc in fused}
|
||||
margin = by_path["backups/retention.md"] - by_path["backups/retention-draft.md"]
|
||||
assert margin >= 3 * s.recency_boost
|
||||
|
||||
# The kill switch: A still first (relevance alone), and the
|
||||
# weight-0 scores are the pre-phase fused scores byte-identical.
|
||||
_boost_off(monkeypatch)
|
||||
chunks_off = retrieve(db, QUESTION, QUESTION_VEC)
|
||||
assert select_documents(chunks_off, n=2)[0].path == "backups/retention.md"
|
||||
assert {rc.chunk_id: rc.score for rc in chunks_off} == {
|
||||
rc.chunk_id: rc.score for rc in fused
|
||||
}
|
||||
|
||||
|
||||
def test_near_tie_flips_toward_the_newer_with_the_boost(
|
||||
twins, db, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The boost is REAL: a near-tie (one RRF rank step apart in both
|
||||
lists, base gap 0.00052882 favoring the OLDER document) flips
|
||||
toward the NEWER one with the boost on (D's yesterday boost
|
||||
0.000698 > the gap), and the pre-phase order (C first) stands with
|
||||
the boost off — proving the boost, not drift, is the
|
||||
differentiator."""
|
||||
chunks = retrieve(db, QUESTION, QUESTION_VEC)
|
||||
assert [d.path for d in select_documents(chunks, n=2)] == [
|
||||
"twin/d-newer.md",
|
||||
"twin/c-older.md",
|
||||
]
|
||||
_boost_off(monkeypatch)
|
||||
chunks_off = retrieve(db, QUESTION, QUESTION_VEC)
|
||||
assert [d.path for d in select_documents(chunks_off, n=2)] == [
|
||||
"twin/c-older.md",
|
||||
"twin/d-newer.md",
|
||||
]
|
||||
|
||||
|
||||
def test_the_boost_fades_with_age_end_to_end(twins_aged, db) -> None:
|
||||
"""Decay end to end: the same pair with D aged to two half-lives
|
||||
(730 days) — D's boost decays to ``weight·e**-2`` ≈ 0.135×weight
|
||||
(the task's e^-3 figure assumed three half-lives; 730/365 = 2),
|
||||
which is BELOW the base gap — C (older) is first again. Recency is
|
||||
an age signal, not a binary: the faded boost still shows in D's
|
||||
effective score (pinned to the analytic decay), it just no longer
|
||||
overcomes a real (near-)tie."""
|
||||
chunks = retrieve(db, QUESTION, QUESTION_VEC)
|
||||
assert [d.path for d in select_documents(chunks, n=2)] == [
|
||||
"twin/c-older.md",
|
||||
"twin/d-newer.md",
|
||||
]
|
||||
# Magnitude pin: D's observed boost == the analytic decayed weight
|
||||
# (the fixture ages D by exactly half_life + 365 days; the
|
||||
# retrieve()-time drift is microseconds, far inside the tolerance).
|
||||
s = get_settings()
|
||||
vector = _vector_candidates(db, QUESTION_VEC, s.hybrid_vector_candidates)
|
||||
lexical = _lexical_candidates(db, QUESTION, s.hybrid_lexical_candidates)
|
||||
fused = {rc.document.path: rc.score for rc in fuse(vector, lexical, s.rrf_k)}
|
||||
boosted = {rc.document.path: rc.score for rc in chunks}
|
||||
age_days = s.recency_half_life_days + 365
|
||||
observed = boosted["twin/d-newer.md"] - fused["twin/d-newer.md"]
|
||||
assert observed == pytest.approx(
|
||||
s.recency_boost * math.exp(-age_days / s.recency_half_life_days),
|
||||
rel=1e-3,
|
||||
)
|
||||
# And the faded boost is far below the full weight (e^-2 ≈ 0.135).
|
||||
assert observed < 0.2 * s.recency_boost
|
||||
|
||||
|
||||
def test_the_a8_cosine_gate_input_is_untouched_by_the_boost(
|
||||
owner_kb, db, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The boost is score-side ONLY: every chunk's ``cosine`` — the A8
|
||||
honesty-gate input and the ``query_log.top_score`` source — is
|
||||
byte-identical with the boost on vs off (asserted per chunk)."""
|
||||
chunks_on = retrieve(db, QUESTION, QUESTION_VEC)
|
||||
cosines_on = {rc.chunk_id: rc.cosine for rc in chunks_on}
|
||||
_boost_off(monkeypatch)
|
||||
chunks_off = retrieve(db, QUESTION, QUESTION_VEC)
|
||||
cosines_off = {rc.chunk_id: rc.cosine for rc in chunks_off}
|
||||
assert cosines_on == cosines_off
|
||||
# The gate input for this question: the answering document's exact
|
||||
# axis (cosine 1.0) — unchanged by the re-rank.
|
||||
assert max(cosines_on.values()) == 1.0
|
||||
@@ -268,6 +268,11 @@ class FakeImportSources:
|
||||
# keying; a shared-root collision ORs — if either row says
|
||||
# "index hidden", the root does).
|
||||
self.include_hidden_maps: list[dict[str, bool]] = []
|
||||
# Phase 106: the per-root source-date map the runner builds
|
||||
# from ``file_commit_dates`` after each git clone (git rows
|
||||
# only, same root-string keying; local rows contribute
|
||||
# nothing).
|
||||
self.doc_dates_maps: list[dict[str, dict[str, datetime]]] = []
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
@@ -280,6 +285,7 @@ class FakeImportSources:
|
||||
progress: Callable[[str, str, int, int], None] | None = None,
|
||||
ignore_by_root: dict[str, list[str]] | None = None,
|
||||
include_hidden_by_root: dict[str, bool] | None = None,
|
||||
doc_dates_by_root: dict[str, dict[str, datetime]] | None = None, # phase 106
|
||||
) -> ImportSummary:
|
||||
self.sources.append(list(sources))
|
||||
self.llms.append(llm)
|
||||
@@ -287,6 +293,7 @@ class FakeImportSources:
|
||||
self.progress_hooks.append(progress)
|
||||
self.ignore_maps.append(ignore_by_root or {})
|
||||
self.include_hidden_maps.append(include_hidden_by_root or {})
|
||||
self.doc_dates_maps.append(doc_dates_by_root or {})
|
||||
if self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
return self.summary
|
||||
@@ -453,9 +460,14 @@ def test_admin_sync_success_reports_full_detail(
|
||||
assert body["detail"] == {
|
||||
"files": 5, "added": 1, "updated": 2, "unchanged": 2, "pruned": 3,
|
||||
"errors": 0, "chunks": 11, "summaries": 1, "summary_errors": 0,
|
||||
"dates_updated": 0, # phase 106: additive key, after summary_errors
|
||||
"overview": True,
|
||||
"sources_version": 1, # phase 53: changed KB → exactly one bump (0 → 1)
|
||||
}
|
||||
# Phase 106: the runner feeds the per-root date map — the fake
|
||||
# checkout is not a git repo, so ``file_commit_dates`` fails soft
|
||||
# to ``{}`` for the one git row (root-keyed).
|
||||
assert fake_import.doc_dates_maps == [{str(tmp_path / "bor" / "repo"): {}}]
|
||||
# The bump committed: the counter advanced exactly once, not twice.
|
||||
assert current_sources_version(db) == 1
|
||||
# Git: the configured repo was cloned into BOR_SOURCES_DIR/<repo-name>/.
|
||||
@@ -607,6 +619,7 @@ class _GatedImport:
|
||||
progress: Callable[[str, str, int, int], None] | None = None,
|
||||
ignore_by_root: dict[str, list[str]] | None = None,
|
||||
include_hidden_by_root: dict[str, bool] | None = None,
|
||||
doc_dates_by_root: dict[str, dict[str, datetime]] | None = None, # phase 106
|
||||
) -> ImportSummary:
|
||||
if progress is not None:
|
||||
progress("repo", "notes/deep.md", 1, 3)
|
||||
@@ -1148,6 +1161,7 @@ def test_import_error_is_reported_with_credentials_masked(
|
||||
progress: Callable[[str, str, int, int], None] | None = None,
|
||||
ignore_by_root: dict[str, list[str]] | None = None,
|
||||
include_hidden_by_root: dict[str, bool] | None = None,
|
||||
doc_dates_by_root: dict[str, dict[str, datetime]] | None = None, # phase 106
|
||||
) -> ImportSummary:
|
||||
raise EmbeddingError(
|
||||
"embeddings request to https://user:secret@aipi.reeseapps.com/v1 "
|
||||
|
||||
@@ -722,7 +722,7 @@ def test_api_changed_sync_generates_folder_rows(
|
||||
# No new sync-status surface: the detail keeps its exact key set.
|
||||
assert set(body["detail"]) == {
|
||||
"files", "added", "updated", "unchanged", "pruned", "errors",
|
||||
"chunks", "summaries", "summary_errors", "overview",
|
||||
"chunks", "summaries", "summary_errors", "dates_updated", "overview",
|
||||
"sources_version",
|
||||
}
|
||||
assert body["detail"]["files"] == 2
|
||||
@@ -862,7 +862,7 @@ def test_api_unchanged_resync_with_gap_fills_only_the_missing_row(
|
||||
# No new sync-status surface: the detail keeps its exact key set.
|
||||
assert set(body["detail"]) == {
|
||||
"files", "added", "updated", "unchanged", "pruned", "errors",
|
||||
"chunks", "summaries", "summary_errors", "overview",
|
||||
"chunks", "summaries", "summary_errors", "dates_updated", "overview",
|
||||
"sources_version",
|
||||
}
|
||||
# Phase 98 (task 01): the progress hook fired on the UNCHANGED-KB
|
||||
|
||||
+85
-51
@@ -37,6 +37,7 @@ import logging
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Sequence
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import pytest
|
||||
@@ -72,6 +73,13 @@ def _settings(**kwargs: Any) -> Settings:
|
||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
#: The fixture documents' fixed creation date (phase 106, D5): the
|
||||
#: agent formats ``doc.created_at`` on the ``read`` result's second
|
||||
#: line — the detached fixture rows carry it exactly as the NOT NULL
|
||||
#: DB column guarantees it for real rows.
|
||||
_FIXTURE_CREATED_AT = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _doc(source: str, path: str, title: str = "Title", content: str = "CONTENT") -> Document:
|
||||
return Document(
|
||||
id=uuid.uuid4(),
|
||||
@@ -81,6 +89,7 @@ def _doc(source: str, path: str, title: str = "Title", content: str = "CONTENT")
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash="0" * 64,
|
||||
created_at=_FIXTURE_CREATED_AT,
|
||||
)
|
||||
|
||||
|
||||
@@ -438,7 +447,9 @@ def test_ls_then_read_then_answer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# lookup, no self-correction candidates (phase 70).
|
||||
assert calls == [("Homelab", "aws-route53.md")]
|
||||
|
||||
# The follow-up request carries the assistant tool-call + tool result.
|
||||
# The follow-up request carries the assistant tool-call + tool
|
||||
# result (phase 106, D5: the ``date:`` second line rides every
|
||||
# read — the first line is byte-identical).
|
||||
msgs = llm.requests[1][0]
|
||||
assert msgs[0] == {"role": "system", "content": "SYSTEM_PROMPT"}
|
||||
assert msgs[1] == {"role": "user", "content": "QUESTION"}
|
||||
@@ -468,7 +479,11 @@ def test_ls_then_read_then_answer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert msgs[5] == {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_2",
|
||||
"content": "Document Homelab/aws-route53.md:\nR53-CONTENT", # full text, no cap
|
||||
"content": (
|
||||
"Document Homelab/aws-route53.md:\n"
|
||||
"date: 2024-06-15\n"
|
||||
"R53-CONTENT"
|
||||
), # full text, no cap (date: the D5 second line)
|
||||
}
|
||||
|
||||
|
||||
@@ -641,14 +656,14 @@ def test_ls_source_scope_lists_root_folder(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
"""A registered source name (no ``/``): the source's ROOT folder —
|
||||
subfolders (2-space-indented, path order, ``: {summary}`` only when
|
||||
stored) + the root's own file lines in EXACTLY the
|
||||
``source: X | path: Y | title: Z`` format — the pinned template,
|
||||
counted."""
|
||||
``source: X | path: Y | title: Z | date: YYYY-MM-DD`` format (the
|
||||
phase-106 D5 appended date field) — the pinned template, counted."""
|
||||
monkeypatch.setattr(
|
||||
agent,
|
||||
"ls_folder",
|
||||
lambda db, source, folder: (
|
||||
[("backups", 2, "Backup notes."), ("networking", 1, None)],
|
||||
[("Homelab", "readme.md", "Readme")],
|
||||
[("Homelab", "readme.md", "Readme", "2024-06-15")],
|
||||
1,
|
||||
),
|
||||
)
|
||||
@@ -665,7 +680,7 @@ def test_ls_source_scope_lists_root_folder(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
" backups/ — 2 documents: Backup notes.\n"
|
||||
" networking/ — 1 documents\n"
|
||||
"\n"
|
||||
"source: Homelab | path: readme.md | title: Readme"
|
||||
"source: Homelab | path: readme.md | title: Readme | date: 2024-06-15"
|
||||
)
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
@@ -677,11 +692,11 @@ def test_ls_nested_folder_scope_lists_one_level_deeper(
|
||||
lines, identity = ``source/folder`` (the same template as the
|
||||
root), counted; the fetchers are the source-scoped ones."""
|
||||
|
||||
def _rows(db: Any, source: str) -> list[tuple[str, str]]:
|
||||
def _rows(db: Any, source: str) -> list[tuple[str, str, str]]:
|
||||
assert (source, db) == ("Homelab", None)
|
||||
return [
|
||||
("networking/lan.md", "LAN"),
|
||||
("networking/vpn.md", "VPN"),
|
||||
("networking/lan.md", "LAN", "2024-06-15"),
|
||||
("networking/vpn.md", "VPN", "2024-06-15"),
|
||||
]
|
||||
|
||||
monkeypatch.setattr(agent, "_source_document_rows", _rows)
|
||||
@@ -702,8 +717,8 @@ def test_ls_nested_folder_scope_lists_one_level_deeper(
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"Homelab/networking — 2 documents, 0 folders:\n"
|
||||
"\n"
|
||||
"source: Homelab | path: networking/lan.md | title: LAN\n"
|
||||
"source: Homelab | path: networking/vpn.md | title: VPN"
|
||||
"source: Homelab | path: networking/lan.md | title: LAN | date: 2024-06-15\n"
|
||||
"source: Homelab | path: networking/vpn.md | title: VPN | date: 2024-06-15"
|
||||
)
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
@@ -812,9 +827,9 @@ def test_ls_unknown_top_level_folder_gets_not_a_folder_teaching(
|
||||
agent,
|
||||
"_source_document_rows",
|
||||
lambda db, source: [
|
||||
("backups/cron.md", "Cron"),
|
||||
("containers/caddy.md", "Caddy"),
|
||||
("networking/lan.md", "LAN"),
|
||||
("backups/cron.md", "Cron", "2024-06-15"),
|
||||
("containers/caddy.md", "Caddy", "2024-06-15"),
|
||||
("networking/lan.md", "LAN", "2024-06-15"),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
|
||||
@@ -847,9 +862,9 @@ def test_ls_unknown_nested_folder_gets_not_a_folder_with_nested_parent(
|
||||
agent,
|
||||
"_source_document_rows",
|
||||
lambda db, source: [
|
||||
("networking/lan/a.md", "A"),
|
||||
("networking/vpn/b.md", "B"),
|
||||
("readme.md", "Readme"),
|
||||
("networking/lan/a.md", "A", "2024-06-15"),
|
||||
("networking/vpn/b.md", "B", "2024-06-15"),
|
||||
("readme.md", "Readme", "2024-06-15"),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
|
||||
@@ -878,7 +893,10 @@ def test_ls_file_path_scope_gets_not_a_folder(monkeypatch: pytest.MonkeyPatch) -
|
||||
monkeypatch.setattr(
|
||||
agent,
|
||||
"_source_document_rows",
|
||||
lambda db, source: [("notes.md", "Notes"), ("a/b.md", "B")],
|
||||
lambda db, source: [
|
||||
("notes.md", "Notes", "2024-06-15"),
|
||||
("a/b.md", "B", "2024-06-15"),
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(agent, "_source_folder_summaries", lambda db, source: {})
|
||||
monkeypatch.setattr(agent, "list_source_names", lambda db: ["S"])
|
||||
@@ -937,9 +955,9 @@ def test_ls_folder_composes_the_fetchers(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
monkeypatch surface)."""
|
||||
seen: list[tuple[str, str, str]] = []
|
||||
|
||||
def _rows(db: Any, source: str) -> list[tuple[str, str]]:
|
||||
def _rows(db: Any, source: str) -> list[tuple[str, str, str]]:
|
||||
seen.append(("rows", source, ""))
|
||||
return [("a/b.md", "B"), ("a.md", "A")]
|
||||
return [("a/b.md", "B", "2024-06-15"), ("a.md", "A", "2024-06-15")]
|
||||
|
||||
def _summaries(db: Any, source: str) -> dict[str, str]:
|
||||
seen.append(("summaries", source, ""))
|
||||
@@ -949,7 +967,7 @@ def test_ls_folder_composes_the_fetchers(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
monkeypatch.setattr(agent, "_source_folder_summaries", _summaries)
|
||||
assert agent.ls_folder(cast("Session", object()), "S", "") == (
|
||||
[("a", 1, "A stuff.")],
|
||||
[("S", "a.md", "A")],
|
||||
[("S", "a.md", "A", "2024-06-15")],
|
||||
1,
|
||||
)
|
||||
assert seen == [("rows", "S", ""), ("summaries", "S", "")]
|
||||
@@ -961,10 +979,10 @@ def test_group_folder_listing_subfolder_recursion_and_counts() -> None:
|
||||
counts for BOTH ``a`` and ``a/b``), path order, the stored summary
|
||||
attached or ``None``."""
|
||||
rows = [
|
||||
("a/b/c.md", "C"),
|
||||
("a/b/d.md", "D"),
|
||||
("a/e.md", "E"),
|
||||
("f.md", "F"),
|
||||
("a/b/c.md", "C", "2024-06-15"),
|
||||
("a/b/d.md", "D", "2024-06-15"),
|
||||
("a/e.md", "E", "2024-06-15"),
|
||||
("f.md", "F", "2024-06-15"),
|
||||
]
|
||||
sub, files, total = agent.group_folder_listing(
|
||||
"S", "", rows, {"a": "A subtree.", "a/b": "B subtree."}
|
||||
@@ -974,7 +992,7 @@ def test_group_folder_listing_subfolder_recursion_and_counts() -> None:
|
||||
# recursive subtree (a/e.md + a/b/c.md + a/b/d.md), the stored
|
||||
# summary attached.
|
||||
assert sub == [("a", 3, "A subtree.")]
|
||||
assert files == [("S", "f.md", "F")]
|
||||
assert files == [("S", "f.md", "F", "2024-06-15")]
|
||||
assert total == 1
|
||||
# One level down: a/b is a's direct subfolder with its own count.
|
||||
sub2, _files2, _total2 = agent.group_folder_listing("S", "a", rows, {"a/b": "B subtree."})
|
||||
@@ -987,13 +1005,13 @@ def test_group_folder_listing_nested_level_counts_and_membership() -> None:
|
||||
file of ``a``) — membership is the folder_of rule, order is path
|
||||
order."""
|
||||
rows = [
|
||||
("a/b/c.md", "C"),
|
||||
("a/b/d.md", "D"),
|
||||
("a/e.md", "E"),
|
||||
("a/b/c.md", "C", "2024-06-15"),
|
||||
("a/b/d.md", "D", "2024-06-15"),
|
||||
("a/e.md", "E", "2024-06-15"),
|
||||
]
|
||||
sub, files, total = agent.group_folder_listing("S", "a", rows, {})
|
||||
assert sub == [("a/b", 2, None)]
|
||||
assert files == [("S", "a/e.md", "E")]
|
||||
assert files == [("S", "a/e.md", "E", "2024-06-15")]
|
||||
assert total == 1
|
||||
|
||||
|
||||
@@ -1004,18 +1022,18 @@ def test_group_folder_listing_file_path_is_not_a_folder() -> None:
|
||||
sharing a real folder's name counts for that folder, the existence
|
||||
rule intact)."""
|
||||
rows = [
|
||||
("a.md", "A"), # a file at the root, and a folder name? NO —
|
||||
("b/x.md", "X"), # nothing starts with "a.md/"
|
||||
("a.md", "A", "2024-06-15"), # a file at the root, and a folder name? NO —
|
||||
("b/x.md", "X", "2024-06-15"), # nothing starts with "a.md/"
|
||||
]
|
||||
sub, files, total = agent.group_folder_listing("S", "", rows, {})
|
||||
assert sub == [("b", 1, None)] # "a.md" is NOT a subfolder
|
||||
assert files == [("S", "a.md", "A")] # b/x.md is NOT a direct root file
|
||||
assert files == [("S", "a.md", "A", "2024-06-15")] # b/x.md is NOT a direct root file
|
||||
assert total == 1
|
||||
# The path == folder arm: a doc named "a" under a real folder "a/".
|
||||
rows2 = [("a", "FileA"), ("a/c.md", "C")]
|
||||
rows2 = [("a", "FileA", "2024-06-15"), ("a/c.md", "C", "2024-06-15")]
|
||||
sub2, files2, total2 = agent.group_folder_listing("S", "", rows2, {})
|
||||
assert sub2 == [("a", 2, None)] # the file "a" counts for folder "a"
|
||||
assert files2 == [("S", "a", "FileA")] # …and is a direct ROOT file
|
||||
assert files2 == [("S", "a", "FileA", "2024-06-15")] # …and is a direct ROOT file
|
||||
assert total2 == 1
|
||||
|
||||
|
||||
@@ -1023,14 +1041,14 @@ def test_group_folder_listing_caps_files_at_fifty_keeps_the_total() -> None:
|
||||
"""The cap: 51 direct files → 50 file lines + the PRE-cap total (51)
|
||||
for the renderer's note; 50 files → 50 lines, no note material.
|
||||
A 500-file folder costs 50 lines, never 500."""
|
||||
rows51 = [(f"big/f{i:03d}.md", f"T{i}") for i in range(51)]
|
||||
rows51 = [(f"big/f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(51)]
|
||||
sub, files, total = agent.group_folder_listing("S", "big", rows51, {})
|
||||
assert sub == []
|
||||
assert total == 51
|
||||
assert len(files) == 50
|
||||
assert files[0] == ("S", "big/f000.md", "T0")
|
||||
assert files[-1] == ("S", "big/f049.md", "T49")
|
||||
rows50 = [(f"big/f{i:03d}.md", f"T{i}") for i in range(50)]
|
||||
assert files[0] == ("S", "big/f000.md", "T0", "2024-06-15")
|
||||
assert files[-1] == ("S", "big/f049.md", "T49", "2024-06-15")
|
||||
rows50 = [(f"big/f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(50)]
|
||||
_sub, files50, total50 = agent.group_folder_listing("S", "big", rows50, {})
|
||||
assert total50 == 50 and len(files50) == 50
|
||||
|
||||
@@ -1056,7 +1074,7 @@ def test_render_folder_listing_root_template() -> None:
|
||||
agent.render_folder_listing(
|
||||
"Homelab",
|
||||
[("backups", 2, "Backup notes."), ("networking", 1, None)],
|
||||
[("Homelab", "readme.md", "Readme")],
|
||||
[("Homelab", "readme.md", "Readme", "2024-06-15")],
|
||||
1,
|
||||
)
|
||||
== "Homelab — 1 documents, 2 folders:\n"
|
||||
@@ -1064,7 +1082,7 @@ def test_render_folder_listing_root_template() -> None:
|
||||
" backups/ — 2 documents: Backup notes.\n"
|
||||
" networking/ — 1 documents\n"
|
||||
"\n"
|
||||
"source: Homelab | path: readme.md | title: Readme"
|
||||
"source: Homelab | path: readme.md | title: Readme | date: 2024-06-15"
|
||||
)
|
||||
|
||||
|
||||
@@ -1090,7 +1108,7 @@ def test_render_folder_listing_cap_note_only_past_fifty() -> None:
|
||||
cap: 51 → 50 lines + the deterministic grep-pointer note (the
|
||||
``…and 1 more…`` shape — unpluralized, the house pin); 50 → no
|
||||
note."""
|
||||
files51 = [("S", f"f{i:03d}.md", f"T{i}") for i in range(51)]
|
||||
files51 = [("S", f"f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(51)]
|
||||
capped = files51[:50]
|
||||
rendered = agent.render_folder_listing("S/big", [], capped, 51)
|
||||
lines = rendered.splitlines()
|
||||
@@ -1100,9 +1118,11 @@ def test_render_folder_listing_cap_note_only_past_fifty() -> None:
|
||||
"…and 1 more documents in this folder — use grep (pattern) to "
|
||||
"find a specific one."
|
||||
)
|
||||
files50 = [("S", f"f{i:03d}.md", f"T{i}") for i in range(50)]
|
||||
files50 = [("S", f"f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(50)]
|
||||
rendered50 = agent.render_folder_listing("S/big", [], files50, 50)
|
||||
assert rendered50.splitlines()[-1] == "source: S | path: f049.md | title: T49"
|
||||
assert rendered50.splitlines()[-1] == (
|
||||
"source: S | path: f049.md | title: T49 | date: 2024-06-15"
|
||||
)
|
||||
assert "more documents" not in rendered50
|
||||
|
||||
|
||||
@@ -1145,7 +1165,9 @@ def test_read_combined_path_resolves_and_returns_full_content(
|
||||
assert holder.read_docs == [doc]
|
||||
assert holder.tool_calls == 1
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"Document Homelab/active/container_caddy/caddy.md:\nCADDY-CONTENT"
|
||||
"Document Homelab/active/container_caddy/caddy.md:\n"
|
||||
"date: 2024-06-15\n"
|
||||
"CADDY-CONTENT"
|
||||
)
|
||||
|
||||
|
||||
@@ -1533,7 +1555,9 @@ def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert holder.read_docs == [doc] # appended exactly once
|
||||
assert holder.tool_calls == 1 # the re-read counts nothing
|
||||
assert llm.requests[1][0][3]["content"] == "Document S/a.md:\nA-CONTENT"
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"Document S/a.md:\ndate: 2024-06-15\nA-CONTENT"
|
||||
)
|
||||
assert llm.requests[2][0][5]["content"] == agent.ALREADY_IN_CONTEXT
|
||||
# Rejected → the tools are still offered on the next request…
|
||||
assert llm.requests[2][1] == AGENT_TOOLS
|
||||
@@ -1558,8 +1582,11 @@ def test_read_exactly_at_cap_is_byte_identical_and_untruncated(
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||||
# Byte-identical to today's read result (no marker, no notice).
|
||||
assert llm.requests[1][0][3]["content"] == "Document S/big.md:\n" + content
|
||||
# The read result plus the phase-106 D5 date line (no marker, no
|
||||
# notice).
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"Document S/big.md:\ndate: 2024-06-15\n" + content
|
||||
)
|
||||
assert TRUNCATION_MARKER not in llm.requests[1][0][3]["content"]
|
||||
# No truncation recorded, none surfaced to the loop.
|
||||
assert holder.read_truncations == []
|
||||
@@ -1589,6 +1616,7 @@ def test_read_at_cap_plus_one_truncates_with_marker_and_notice(
|
||||
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||||
expected = (
|
||||
"Document S/big.md:\n"
|
||||
"date: 2024-06-15\n"
|
||||
+ content[:cap]
|
||||
+ "\n"
|
||||
+ TRUNCATION_MARKER
|
||||
@@ -2247,7 +2275,9 @@ def test_grep_counts_but_never_adds_context(monkeypatch: pytest.MonkeyPatch) ->
|
||||
assert holder.tool_calls == 2 # grep + read, both executed
|
||||
assert holder.read_docs == [doc] # only the read added context (A5)
|
||||
assert llm.requests[1][0][3]["content"] == "S/a.md:1: needle here"
|
||||
assert llm.requests[2][0][5]["content"] == "Document S/a.md:\nneedle here"
|
||||
assert llm.requests[2][0][5]["content"] == (
|
||||
"Document S/a.md:\ndate: 2024-06-15\nneedle here"
|
||||
)
|
||||
|
||||
|
||||
# ---------- unlimited calls: re-lists and multi-reads (phase 45) ----------
|
||||
@@ -2292,8 +2322,12 @@ def test_multi_read_executes_without_budgets(monkeypatch: pytest.MonkeyPatch) ->
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert holder.read_docs == [a, b] # both reads appended, in order
|
||||
assert holder.tool_calls == 2
|
||||
assert llm.requests[1][0][3]["content"] == "Document S/a.md:\nA-CONTENT"
|
||||
assert llm.requests[2][0][5]["content"] == "Document S/b.md:\nB-CONTENT"
|
||||
assert llm.requests[1][0][3]["content"] == (
|
||||
"Document S/a.md:\ndate: 2024-06-15\nA-CONTENT"
|
||||
)
|
||||
assert llm.requests[2][0][5]["content"] == (
|
||||
"Document S/b.md:\ndate: 2024-06-15\nB-CONTENT"
|
||||
)
|
||||
assert llm.requests[2][1] == AGENT_TOOLS # the second read was still offered
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Unit: phase 106 task 03 — mtime-preserving archive unpack (D2).
|
||||
|
||||
Pins the date source of the upload path: a zip member's DOS
|
||||
``date_time`` and a tar member's ``mtime`` survive the unpack as the
|
||||
extracted file's atime+mtime, so the importer (task 04) reads the
|
||||
archive's ORIGINAL file dates — the owner's "file metadata (hopefully)
|
||||
preserved in the tar or zip archive process" made real.
|
||||
|
||||
Regular files only: directories/symlinks/hardlinks are untouched (they
|
||||
carry the extraction-time values, never the member's). The safety
|
||||
behavior (zip-slip, absolute members, link targets, the extraction
|
||||
cap) is unchanged and pinned by ``tests/unit/test_archive_upload.py``
|
||||
— this file adds only the date pins.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tarfile
|
||||
import zipfile
|
||||
from datetime import UTC, datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from app.rag.archive_upload import unpack_archive
|
||||
|
||||
#: The old member timestamp both fixtures carry: 2020-01-02 03:04:06
|
||||
#: UTC — the zip as the DOS tuple ``(2020, 1, 2, 3, 4, 6)``, the tar
|
||||
#: as the epoch seconds.
|
||||
OLD_MTIME = datetime(2020, 1, 2, 3, 4, 6, tzinfo=UTC)
|
||||
OLD_EPOCH = OLD_MTIME.timestamp() # 1577934246.0
|
||||
|
||||
#: mtime granularity tolerance (the task pin: ±1 s).
|
||||
_TOLERANCE_S = 1.0
|
||||
|
||||
_PAYLOAD = b"# Old note\ncontent from 2020\n"
|
||||
|
||||
|
||||
def _zip_with_old_mtime(path: Path) -> None:
|
||||
"""A two-member zip (a directory + one file), both carrying the
|
||||
old fixed ``date_time`` tuple."""
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
dir_info = zipfile.ZipInfo("docs/", date_time=(2020, 1, 2, 3, 4, 6))
|
||||
dir_info.external_attr = (0o40755 << 16)
|
||||
zf.writestr(dir_info, b"")
|
||||
file_info = zipfile.ZipInfo("docs/note.md", date_time=(2020, 1, 2, 3, 4, 6))
|
||||
file_info.external_attr = (0o100644 << 16)
|
||||
zf.writestr(file_info, _PAYLOAD)
|
||||
|
||||
|
||||
def _tar_with_old_mtime(path: Path) -> None:
|
||||
"""A two-member tar (a directory + one file), both carrying the
|
||||
old epoch ``mtime``."""
|
||||
with tarfile.open(path, "w") as tf:
|
||||
dir_ti = tarfile.TarInfo("docs/")
|
||||
dir_ti.type = tarfile.DIRTYPE
|
||||
dir_ti.mode = 0o755
|
||||
dir_ti.mtime = OLD_EPOCH
|
||||
tf.addfile(dir_ti)
|
||||
file_ti = tarfile.TarInfo("docs/note.md")
|
||||
file_ti.size = len(_PAYLOAD)
|
||||
file_ti.mode = 0o644
|
||||
file_ti.mtime = OLD_EPOCH
|
||||
tf.addfile(file_ti, BytesIO(_PAYLOAD))
|
||||
|
||||
|
||||
def _extract(archive: Path, tmp_path: Path) -> tuple[Path, os.stat_result]:
|
||||
"""Unpack ``archive``; return (file path, pre-read stat). The stat
|
||||
happens BEFORE the content read (a read refreshes atime under
|
||||
relatime — the atime pin needs the unpacked value); the content is
|
||||
asserted byte-identical (the mtime work moved no bytes)."""
|
||||
target = tmp_path / "out"
|
||||
unpack_archive(archive, target, max_extract_bytes=1 << 20)
|
||||
dest = target / "docs" / "note.md"
|
||||
st = dest.stat() # before the read — reading would refresh atime
|
||||
assert dest.read_bytes() == _PAYLOAD
|
||||
return dest, st
|
||||
|
||||
|
||||
def test_zip_member_mtime_is_restored(tmp_path: Path) -> None:
|
||||
"""A zip member's DOS ``date_time`` lands as the extracted file's
|
||||
mtime (and atime — ``os.utime(ns=(t, t))`` sets both), within the
|
||||
mtime-granularity tolerance."""
|
||||
archive = tmp_path / "old.zip"
|
||||
_zip_with_old_mtime(archive)
|
||||
_dest, st = _extract(archive, tmp_path)
|
||||
assert abs(st.st_mtime - OLD_EPOCH) <= _TOLERANCE_S
|
||||
assert abs(st.st_atime - OLD_EPOCH) <= _TOLERANCE_S
|
||||
|
||||
|
||||
def test_tar_member_mtime_is_restored(tmp_path: Path) -> None:
|
||||
"""A tar member's epoch ``mtime`` lands as the extracted file's
|
||||
mtime (and atime), within the mtime-granularity tolerance."""
|
||||
archive = tmp_path / "old.tar"
|
||||
_tar_with_old_mtime(archive)
|
||||
_dest, st = _extract(archive, tmp_path)
|
||||
assert abs(st.st_mtime - OLD_EPOCH) <= _TOLERANCE_S
|
||||
assert abs(st.st_atime - OLD_EPOCH) <= _TOLERANCE_S
|
||||
|
||||
|
||||
def test_zip_directory_member_mtime_is_not_restored(tmp_path: Path) -> None:
|
||||
"""Regular files ONLY: a zip directory member carrying the old
|
||||
``date_time`` keeps the EXTRACTION-time mtime (≈ now, long after
|
||||
the old 2020 value) — directories are never indexed, so their
|
||||
dates don't matter; the pin is that the unpacker doesn't utime
|
||||
them (the task contract)."""
|
||||
archive = tmp_path / "old.zip"
|
||||
_zip_with_old_mtime(archive)
|
||||
target = tmp_path / "out"
|
||||
unpack_archive(archive, target, max_extract_bytes=1 << 20)
|
||||
assert (target / "docs").stat().st_mtime > OLD_EPOCH + _TOLERANCE_S
|
||||
|
||||
|
||||
def test_tar_directory_member_mtime_is_not_restored(tmp_path: Path) -> None:
|
||||
"""The tar twin of the zip directory pin: a directory member with
|
||||
the old ``mtime`` is not utime'd (regular files only)."""
|
||||
archive = tmp_path / "old.tar"
|
||||
_tar_with_old_mtime(archive)
|
||||
target = tmp_path / "out"
|
||||
unpack_archive(archive, target, max_extract_bytes=1 << 20)
|
||||
assert (target / "docs").stat().st_mtime > OLD_EPOCH + _TOLERANCE_S
|
||||
@@ -29,6 +29,7 @@ import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator, MutableMapping
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@@ -52,6 +53,10 @@ def _doc(title: str, content: str) -> Document:
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash="0" * 64,
|
||||
# Phase 106, D5: the HIGH block formats the row's created_at
|
||||
# UTC date part — the detached fixture carries it (the NOT NULL
|
||||
# DB column guarantees it for real rows).
|
||||
created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
@@ -51,6 +52,10 @@ def _doc(title: str, content: str) -> Document:
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash="0" * 64,
|
||||
# Phase 106, D5: the HIGH block formats the row's created_at
|
||||
# UTC date part — the detached fixture carries it (the NOT NULL
|
||||
# DB column guarantees it for real rows).
|
||||
created_at=datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ def test_defaults_match_locked_decisions(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
assert s.hybrid_lexical_candidates >= 1
|
||||
assert s.rrf_k >= 1
|
||||
assert s.top_n_docs >= 1
|
||||
# Phase 106, D6: the recency boost is ON by default (0.0007 — the
|
||||
# fine-line-tuned value, task 07) with a 365-day decay timescale.
|
||||
assert s.recency_boost == 0.0007
|
||||
assert s.recency_half_life_days == 365
|
||||
# Owner instruction 2026-08-22: answers may run up to 32 768 tokens.
|
||||
assert s.max_output_tokens == 32_768
|
||||
# Phase 17: the model's thinking streams by default (kill-switch off).
|
||||
@@ -235,6 +239,47 @@ def test_read_max_chars_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
_settings()
|
||||
|
||||
|
||||
def test_recency_boost_default_and_env_override(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 106, D6: the recency boost on the RRF-fused score is ON by
|
||||
default (0.0007 — the fine-line-tuned value, task 07; ``0`` is the
|
||||
byte-identical kill switch) with a 365-day decay timescale; both
|
||||
env-tunable so the owner re-tunes live."""
|
||||
monkeypatch.delenv("BOR_RECENCY_BOOST", raising=False)
|
||||
monkeypatch.delenv("BOR_RECENCY_HALF_LIFE_DAYS", raising=False)
|
||||
s = _settings()
|
||||
assert s.recency_boost == 0.0007
|
||||
assert s.recency_half_life_days == 365
|
||||
monkeypatch.setenv("BOR_RECENCY_BOOST", "0")
|
||||
monkeypatch.setenv("BOR_RECENCY_HALF_LIFE_DAYS", "90")
|
||||
s = _settings()
|
||||
assert s.recency_boost == 0.0
|
||||
assert s.recency_half_life_days == 90
|
||||
monkeypatch.setenv("BOR_RECENCY_BOOST", "0.002")
|
||||
assert _settings().recency_boost == 0.002
|
||||
|
||||
|
||||
def test_recency_boost_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``0`` is the kill switch — a NEGATIVE boost would demote fresh
|
||||
documents (the exact opposite of D6), so the validator fails loudly
|
||||
at startup naming the field (the ``agent_max_rounds`` pattern)."""
|
||||
monkeypatch.setenv("BOR_RECENCY_BOOST", "-0.001")
|
||||
with pytest.raises(ValidationError, match="recency_boost"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_recency_half_life_rejects_non_positive(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A ``0``/negative decay timescale would divide the exponent by
|
||||
zero — the validator fails loudly at startup naming the field."""
|
||||
for bad in ("0", "-365"):
|
||||
monkeypatch.setenv("BOR_RECENCY_HALF_LIFE_DAYS", bad)
|
||||
with pytest.raises(ValidationError, match="recency_half_life_days"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_stream_thinking_default_true_and_env_parse(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Phase 17 kill-switch (``BOR_STREAM_THINKING``): on by default,
|
||||
``0``/``false`` turn the ``thinking`` SSE frames off."""
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
"""Unit: the admin-only date editor in the viewer (phase 106, task 09,
|
||||
D7).
|
||||
|
||||
The owner asked for the creation date to be editable "so users can
|
||||
correct for errors" — admin-only, in the shared viewer core (modal +
|
||||
``/document.html`` through the ONE ``renderDocument`` — no per-surface
|
||||
copy), on the phase-57 ``wireSummaryEdit`` idiom (the same
|
||||
``docAdminReady()`` gate on the module-cached whoami promise — no
|
||||
second request per page; the public badge row stays byte-for-byte the
|
||||
task-08 shape for non-admins). The task-05 endpoint
|
||||
(``PATCH /api/documents/date`` — set + flag manual; null = the D7
|
||||
CLEAR, the flag drops) is the single source this file cross-checks
|
||||
against.
|
||||
|
||||
The browser behavior itself is E2E-gated by the phase's dedicated
|
||||
suite (``tests/e2e/test_document_dates.py``, task 10); like the other
|
||||
frontend-adjacent unit files (the ``test_summary_edit_ui.py`` /
|
||||
``test_sources_dates.py`` house pattern), this module pins the
|
||||
source-level contract a silent regression would break:
|
||||
|
||||
* the ``docAdminReady()`` gate — ``wireDateEdit`` is called ONLY in
|
||||
the gate's ``if (admin)`` branch (one call site, after the badge
|
||||
row is built for everyone — the anonymous DOM is never touched);
|
||||
* the ``Edit date`` affordance — a real ``type="button"`` with the
|
||||
``aria-label`` ``Edit creation date: <source>/<path>``
|
||||
(setAttribute, never innerHTML), inserted AFTER the task-08 Created
|
||||
badge;
|
||||
* the editor construction — the button swaps in-place for a box with
|
||||
a native ``<input type="date">`` (``aria-label="Document
|
||||
creation date"``, prefilled with the stored date's UTC date part
|
||||
via ``.value`` — never innerHTML), Save / Cancel text buttons, the
|
||||
muted "Revert to sync" clear affordance (the phase-57 "clear =
|
||||
explicit" contrast), a ``role="status"`` live line and a
|
||||
``role="alert"`` error line;
|
||||
* the exact PATCH — ``/api/documents/date`` with method PATCH and the
|
||||
``{source, path, date}`` body; the endpoint string appears exactly
|
||||
ONCE in the JS (the single-source cross-file check against
|
||||
``app/api/docs.py``'s route); Revert sends ``date: null``;
|
||||
* the response-driven re-render — the badge is re-rendered from the
|
||||
RESPONSE's ``created_at`` (``res.created_at`` — never the
|
||||
input's optimistic value);
|
||||
* the §7.4 never-stale lifecycle — the controls disable IMMEDIATELY
|
||||
on Save/Revert (one PATCH at a time), an EMPTY input disables Save
|
||||
(the explicit Revert is the only clear path — no accidental
|
||||
wipes), a failure (non-2xx or network) lands the server detail
|
||||
(or the canned retry copy) in the ``role="alert"`` line, reverts
|
||||
the input to the stored date, keeps the editor open, and
|
||||
re-enables in the ``finally``; the success confirmation lands
|
||||
AFTER the badge update (the phase-89 last-announce order);
|
||||
* styles.css — the eight editor classes next to the summary-editor
|
||||
family, the phase-106 D7 provenance comment with the recorded WCAG
|
||||
pairs, the ``[hidden]`` override, the ``cursor: wait`` disabled
|
||||
idiom, the global 3px ``:focus-visible`` ring (no per-control
|
||||
rule), no CDN.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
DOCS_PY = Path(__file__).resolve().parents[2] / "app" / "api" / "docs.py"
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return DOCUMENT_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a (possibly async, possibly nested) function via
|
||||
balanced-brace counting. The brace count starts AFTER the
|
||||
parameter list (a destructured parameter may carry braces of its
|
||||
own — renderDocument's target object)."""
|
||||
for prefix in ("async function ", "function "):
|
||||
start = js.find(f"{prefix}{name}(")
|
||||
if start != -1:
|
||||
depth = 0
|
||||
i = js.find("(", start)
|
||||
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}()")
|
||||
raise AssertionError(f"{name}() must exist in document.js")
|
||||
|
||||
|
||||
# ---------- the admin gate (the phase-57 split) ----------
|
||||
|
||||
|
||||
def test_wire_date_edit_is_called_only_behind_doc_admin_ready() -> None:
|
||||
"""renderDocument (the ONE shared core — modal + page): the badge
|
||||
row is built for EVERYONE first (task-08 shape), and the wiring
|
||||
runs ONLY in the gate's ``if (admin)`` branch on the
|
||||
module-cached ``docAdminReady()`` promise — the string sequence
|
||||
``docAdminReady().then`` … ``wireDateEdit``, one call site in
|
||||
the whole file (definition + call), so a non-admin / token
|
||||
holder / failed whoami keeps exactly the task-08 badge row (no
|
||||
button, no wiring, no admin-only network call)."""
|
||||
js = _js()
|
||||
render = _fn(js, "renderDocument")
|
||||
row = render.find("metaEl.replaceChildren(")
|
||||
gate = render.find("void docAdminReady().then(")
|
||||
wiring = render.find("if (admin) wireDateEdit(metaEl, doc);")
|
||||
assert -1 < row < gate < wiring, (
|
||||
"the badge row is built for everyone BEFORE the admin gate; "
|
||||
"the wiring is only in the gate's success branch"
|
||||
)
|
||||
# The gate's .then lands wireDateEdit (not the summary edit).
|
||||
assert "wireDateEdit(metaEl, doc)" in render[gate : gate + 120]
|
||||
# Exactly two occurrences in the whole file: the definition and
|
||||
# the gated call — no second wiring site (no per-surface copy).
|
||||
assert js.count("wireDateEdit(") == 2, (
|
||||
"wireDateEdit has one definition and one (gated) call site"
|
||||
)
|
||||
|
||||
|
||||
def test_gate_reuses_the_cached_whoami_promise() -> None:
|
||||
"""docAdminReady() resolves header.js's fetchIsAdmin (the SAME
|
||||
single request per page the shared header makes — no second
|
||||
whoami call site in document.js, no admin-only network call for
|
||||
anonymous visitors)."""
|
||||
js = _js()
|
||||
body = _fn(js, "docAdminReady")
|
||||
assert "await fetchIsAdmin()" in body
|
||||
assert "=== true" in body
|
||||
assert 'fetch("/api/whoami")' not in js, (
|
||||
"whoami must come from the header.js cached promise"
|
||||
)
|
||||
|
||||
|
||||
# ---------- the Edit date affordance ----------
|
||||
|
||||
|
||||
def test_edit_date_button_is_a_real_button_after_the_created_badge() -> None:
|
||||
"""The affordance: a real ``type="button"`` with the visible
|
||||
text "Edit date", the .doc-date-edit class, and the aria-label
|
||||
``Edit creation date: <source>/<path>`` (setAttribute — the
|
||||
document-derived pair is user-storable, never innerHTML),
|
||||
inserted AFTER the task-08 Created badge (the insertion point).
|
||||
A meta row without the Created badge (should not happen — the
|
||||
core always builds it) is a no-op, not a crash."""
|
||||
body = _fn(_js(), "wireDateEdit")
|
||||
assert 'metaEl.querySelector(".doc-created")' in body
|
||||
assert "if (!createdBadge) return;" in body
|
||||
assert 'editBtn.type = "button"' in body
|
||||
assert 'editBtn.className = "doc-date-edit"' in body
|
||||
assert 'editBtn.textContent = "Edit date"' in body
|
||||
assert "editBtn.setAttribute(" in body
|
||||
assert "Edit creation date: ${doc.source}/${doc.path}" in body, (
|
||||
"the aria-label is the 'Edit creation date: <source>/<path>' template"
|
||||
)
|
||||
assert 'createdBadge.insertAdjacentElement("afterend", editBtn)' in body, (
|
||||
"the button lands AFTER the Created badge"
|
||||
)
|
||||
|
||||
|
||||
# ---------- the editor construction ----------
|
||||
|
||||
|
||||
def test_editor_swaps_in_date_input_save_cancel_revert_and_live_lines() -> None:
|
||||
"""Edit swaps the button for an inline box in the badge row: a
|
||||
native <input type="date"> (aria-label "Document creation
|
||||
date", prefilled via ``.value`` with the stored date's UTC date
|
||||
part — ``new Date(doc.created_at).toISOString().slice(0, 10)`` —
|
||||
NEVER innerHTML), Save / Cancel real type=buttons, the muted
|
||||
"Revert to sync" clear affordance, a role=status/aria-live=polite
|
||||
live line, and a role=alert error line. The order is hide the
|
||||
button → insert the box after the Created badge → focus the
|
||||
input (the phase-57 swap pattern)."""
|
||||
body = _fn(_js(), "wireDateEdit")
|
||||
assert 'input.type = "date"' in body
|
||||
assert 'input.className = "doc-date-input"' in body
|
||||
assert 'input.setAttribute("aria-label", "Document creation date")' in body
|
||||
assert "new Date(doc.created_at).toISOString().slice(0, 10)" in body, (
|
||||
"the prefill is the stored date's UTC date part (D3 stores UTC)"
|
||||
)
|
||||
assert 'input.value = storedValue()' in body, "prefill via .value (XSS contract)"
|
||||
assert 'saveBtn.type = "button"' in body
|
||||
assert 'saveBtn.className = "doc-date-save"' in body
|
||||
assert 'saveBtn.textContent = "Save"' in body
|
||||
assert 'cancelBtn.type = "button"' in body
|
||||
assert 'cancelBtn.className = "doc-date-cancel"' in body
|
||||
assert 'cancelBtn.textContent = "Cancel"' in body
|
||||
assert 'revertBtn.type = "button"' in body
|
||||
assert 'revertBtn.className = "doc-date-revert"' in body
|
||||
assert 'revertBtn.textContent = "Revert to sync"' in body
|
||||
assert 'status.className = "doc-date-status"' in body
|
||||
assert 'status.setAttribute("role", "status")' in body
|
||||
assert 'status.setAttribute("aria-live", "polite")' in body
|
||||
assert 'errorLine.className = "doc-date-error"' in body
|
||||
assert 'errorLine.setAttribute("role", "alert")' in body
|
||||
hide = body.find("editBtn.hidden = true")
|
||||
swap = body.find('createdBadge.insertAdjacentElement("afterend", box)')
|
||||
focus = body.find("input.focus()")
|
||||
assert -1 < hide < swap < focus, "hide Edit → insert box → focus the input"
|
||||
# The swap keeps all six editor parts in the box.
|
||||
assert (
|
||||
"box.replaceChildren(input, saveBtn, cancelBtn, revertBtn, status, errorLine)"
|
||||
in body
|
||||
)
|
||||
# XSS contract: the whole wiring is textContent/.value only
|
||||
# (comments stripped — the word may appear in a note, never in code).
|
||||
code = re.sub(r"//.*?$|/\*.*?\*/", "", body, flags=re.S | re.M)
|
||||
assert "innerHTML" not in code, "XSS contract: no innerHTML in the wiring"
|
||||
|
||||
|
||||
# ---------- the PATCH round-trip (the single source) ----------
|
||||
|
||||
|
||||
def test_save_patches_the_single_date_endpoint() -> None:
|
||||
"""Save → PATCH /api/documents/date (the task-05 admin endpoint)
|
||||
with the EXACT body shape {source, path, date} — the pair from
|
||||
the doc object, JSON content type. The endpoint string appears
|
||||
exactly ONCE in the JS (the single-source cross-file check — the
|
||||
one call site inside the wired editor matches app/api/docs.py's
|
||||
route; anonymous visitors never have it)."""
|
||||
js = _js()
|
||||
assert js.count('"/api/documents/date"') == 1, (
|
||||
"exactly one occurrence of the endpoint string in document.js"
|
||||
)
|
||||
body = _fn(js, "wireDateEdit")
|
||||
fetch_i = body.find('fetch("/api/documents/date"')
|
||||
assert fetch_i != -1, "the PATCH must live in the wired editor"
|
||||
assert 'method: "PATCH"' in body[fetch_i:]
|
||||
assert '"Content-Type": "application/json"' in body[fetch_i:]
|
||||
assert "date: dateValue" in body, "the body shape: {source, path, date}"
|
||||
assert "source: doc.source" in body and "path: doc.path" in body
|
||||
# The cross-file check: the JS endpoint matches the Python route.
|
||||
docs = DOCS_PY.read_text(encoding="utf-8")
|
||||
assert '@router.patch("/documents/date"' in docs, (
|
||||
"app/api/docs.py registers the route the JS PATCHes"
|
||||
)
|
||||
|
||||
|
||||
def test_revert_sends_date_null_the_clear_path() -> None:
|
||||
"""The "Revert to sync" affordance (the D7 CLEAR — the manual
|
||||
flag drops, the stored date stands until the next sync) sends
|
||||
``{source, path, date: null}``: the revert binding calls
|
||||
saveDate(null) — the ONLY null call site."""
|
||||
body = _fn(_js(), "wireDateEdit")
|
||||
assert body.count("void saveDate(null)") == 1, (
|
||||
"exactly one null (clear) call site"
|
||||
)
|
||||
revert_i = body.find('revertBtn.addEventListener("click"')
|
||||
null_i = body.find("void saveDate(null)", revert_i)
|
||||
assert -1 < revert_i < null_i, "the null call is the revert binding"
|
||||
save_i = body.find('saveBtn.addEventListener("click"')
|
||||
value_i = body.find("void saveDate(input.value)", save_i)
|
||||
assert -1 < save_i < value_i, "Save sends the input's (non-empty) value"
|
||||
|
||||
|
||||
def test_badge_rerenders_from_the_response_not_the_input() -> None:
|
||||
"""The UI shows exactly what the server stored: on 200 the doc
|
||||
object syncs from the RESPONSE (``res.created_at``) and the
|
||||
badge's text re-renders from ``res.created_at`` (plus the ISO
|
||||
title — the ellipsis-precision idiom). The input's optimistic
|
||||
value never feeds the badge anywhere in the file."""
|
||||
js = _js()
|
||||
body = _fn(js, "wireDateEdit")
|
||||
json_i = body.find("const res = await r.json();")
|
||||
sync_i = body.find("doc.created_at = res.created_at;")
|
||||
badge_i = body.find("createdBadge.textContent = `Created ${fmtDate(res.created_at)}`")
|
||||
title_i = body.find('createdBadge.setAttribute("title", res.created_at);')
|
||||
assert -1 < json_i < sync_i < badge_i, "JSON → doc sync → badge re-render"
|
||||
assert -1 < badge_i < title_i, "the title follows the same response"
|
||||
assert "fmtDate(input.value)" not in js, (
|
||||
"the badge is response-driven — never the input's optimistic value"
|
||||
)
|
||||
|
||||
|
||||
def test_status_announces_after_the_badge_update() -> None:
|
||||
"""The success confirmations (the role=status live line) land
|
||||
AFTER the badge re-render (the phase-89 last-announce order):
|
||||
'Date saved for <source>/<path>.' for a set, 'Reverted to
|
||||
sync-managed date.' for the clear."""
|
||||
body = _fn(_js(), "wireDateEdit")
|
||||
badge_i = body.find("createdBadge.textContent = `Created ${fmtDate(res.created_at)}`")
|
||||
saved_i = body.find("`Date saved for ${doc.source}/${doc.path}.`")
|
||||
reverted_i = body.find('"Reverted to sync-managed date."')
|
||||
assert -1 < badge_i < reverted_i < saved_i, (
|
||||
"the badge updates BEFORE either confirmation (last-announce order)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- §7.4 never-stale lifecycle ----------
|
||||
|
||||
|
||||
def test_submit_disables_controls_before_the_fetch() -> None:
|
||||
"""One PATCH at a time: Save/Revert lock ALL the editor controls
|
||||
(input + Save + Cancel + Revert) IMMEDIATELY — before the fetch
|
||||
— so a double-submit is impossible (PLAN §7.4)."""
|
||||
body = _fn(_js(), "wireDateEdit")
|
||||
lock_fn = _fn(body, "setControlsLocked")
|
||||
assert "input.disabled = locked" in lock_fn
|
||||
assert "cancelBtn.disabled = locked" in lock_fn
|
||||
assert "revertBtn.disabled = locked" in lock_fn
|
||||
lock_i = body.find("setControlsLocked(true)")
|
||||
fetch_i = body.find('fetch("/api/documents/date"')
|
||||
assert -1 < lock_i < fetch_i, "the controls lock BEFORE the fetch"
|
||||
|
||||
|
||||
def test_empty_input_disables_save() -> None:
|
||||
"""An empty type=date input is NOT the clear path: Save disables
|
||||
itself on an empty input (the explicit Revert below handles the
|
||||
clear — no accidental wipes). The locked state owns the controls
|
||||
while a PATCH is in flight (the input is disabled then)."""
|
||||
body = _fn(_js(), "wireDateEdit")
|
||||
lock_fn = _fn(body, "setControlsLocked")
|
||||
assert 'saveBtn.disabled = locked || input.value === ""' in lock_fn
|
||||
listener_i = body.find('input.addEventListener("input"')
|
||||
assert listener_i != -1, "the input event keeps Save in sync"
|
||||
assert 'if (!input.disabled) saveBtn.disabled = input.value === "";' in body
|
||||
|
||||
|
||||
def test_failure_reverts_input_announces_alert_and_reenables() -> None:
|
||||
"""A failed Save/Revert (non-2xx OR network) lands the server
|
||||
detail (the git-sources.js apiDetail shape) — or the canned
|
||||
'Couldn't save the date — try again.' on a non-JSON body — into
|
||||
the role=alert line, reverts the input to the stored date, and
|
||||
re-enables the controls in the ``finally`` (every outcome, never
|
||||
stale). The UI never claims a state the server didn't save."""
|
||||
body = _fn(_js(), "wireDateEdit")
|
||||
nonok = body.find("if (!r.ok)")
|
||||
detail = body.find("errorLine.textContent = await apiDetail(")
|
||||
canned = body.find("Couldn't save the date — try again.")
|
||||
revert_nonok = body.find("input.value = storedValue()", nonok)
|
||||
assert -1 < nonok < detail < canned < revert_nonok, (
|
||||
"non-ok: server detail (canned fallback) → alert line → input reverts"
|
||||
)
|
||||
catch_i = body.find("} catch {")
|
||||
canned2 = body.find("Couldn't save the date — try again.", catch_i)
|
||||
revert_catch = body.find("input.value = storedValue()", catch_i)
|
||||
assert -1 < catch_i < canned2 < revert_catch, (
|
||||
"network failure: canned retry copy → input reverts"
|
||||
)
|
||||
finally_i = body.find("finally {")
|
||||
unlock = body.find("setControlsLocked(false)", finally_i)
|
||||
assert -1 < finally_i < unlock, "the controls re-enable in the finally"
|
||||
|
||||
|
||||
def test_failure_keeps_the_editor_open() -> None:
|
||||
"""Neither failure branch (the non-ok early return, the network
|
||||
catch) collapses the editor or clears the user's view — the
|
||||
alert line + the reverted stored value are visible (the editor
|
||||
stays open; only Cancel and the success beat collapse it)."""
|
||||
body = _fn(_js(), "wireDateEdit")
|
||||
nonok_slice = body[body.find("if (!r.ok)") : body.find("const res = await r.json();")]
|
||||
assert "closeEditor" not in nonok_slice, "non-ok keeps the editor open"
|
||||
catch_slice = body[body.find("} catch {") : body.find("finally {")]
|
||||
assert "closeEditor" not in catch_slice, "the network catch keeps the editor open"
|
||||
|
||||
|
||||
def test_cancel_closes_without_a_patch() -> None:
|
||||
"""Cancel collapses back to the badge row + the Edit button
|
||||
(focus returns to the opener) and sends NO PATCH (the stored
|
||||
value is untouched — the badge was never mutated)."""
|
||||
body = _fn(_js(), "wireDateEdit")
|
||||
cancel_i = body.find('cancelBtn.addEventListener("click"')
|
||||
close_i = body.find("closeEditor()", cancel_i)
|
||||
assert -1 < cancel_i < close_i, "Cancel closes the editor"
|
||||
assert "fetch" not in body[cancel_i : close_i], "Cancel sends no PATCH"
|
||||
close_fn = _fn(body, "closeEditor")
|
||||
assert 'createdBadge.insertAdjacentElement("afterend", editBtn)' in close_fn
|
||||
assert "box.remove()" in close_fn
|
||||
assert "editBtn.focus()" in close_fn, "focus returns to the opener"
|
||||
|
||||
|
||||
# ---------- styles.css ----------
|
||||
|
||||
|
||||
def test_date_editor_classes_present_next_to_the_summary_family() -> None:
|
||||
"""styles.css carries the eight editor classes (the button, the
|
||||
box, the input, Save / Cancel, the muted revert, the status and
|
||||
the alert line) placed NEXT to the summary-editor rule family
|
||||
(after .doc-summary-status, before the raw-format block), with
|
||||
the house palette (phase-08 tokens) and no CDN."""
|
||||
css = _css()
|
||||
for cls in (
|
||||
".doc-date-edit",
|
||||
".doc-date-editor",
|
||||
".doc-date-input",
|
||||
".doc-date-save",
|
||||
".doc-date-cancel",
|
||||
".doc-date-revert",
|
||||
".doc-date-status",
|
||||
".doc-date-error",
|
||||
):
|
||||
assert f"{cls} " in css, f"styles.css must style {cls}"
|
||||
assert (
|
||||
css.find(".doc-summary-status:empty")
|
||||
< css.find(".doc-date-edit {")
|
||||
< css.find(".doc-raw {")
|
||||
), "the editor family sits next to the summary-editor rules"
|
||||
assert "url(http" not in css and "@import url(" not in css, (
|
||||
"no CDN (AGENTS.md rule 6)"
|
||||
)
|
||||
|
||||
|
||||
def test_date_editor_css_provenance_and_contrast() -> None:
|
||||
"""The phase-106 D7 provenance comment sits directly above the
|
||||
button rule with the verified WCAG pairs recorded (house
|
||||
style): the ink-soft row family (5.1:1 on --surface), the brand
|
||||
pill Save (--bg on --brand = 5.2:1), the input ink on --bg
|
||||
(16.7:1), and the alert line's err pair (9.1:1 on --err-bg) —
|
||||
all ≥4.5:1. The [hidden] override beats the button's display,
|
||||
and :focus-visible rides the global 3px outline rule (no
|
||||
per-control rule — the phase-105 checkbox idiom)."""
|
||||
css = _css()
|
||||
rule_i = css.find(".doc-date-edit {")
|
||||
comment_start = css.rfind("/*", 0, rule_i)
|
||||
comment_end = css.find("*/", comment_start)
|
||||
assert -1 < comment_start < rule_i and comment_end < rule_i, (
|
||||
"a comment block must sit directly above the button rule"
|
||||
)
|
||||
header = css[comment_start:comment_end]
|
||||
assert "phase 106" in header.lower() and "D7" in header, (
|
||||
"the provenance comment cites phase 106 + D7"
|
||||
)
|
||||
for pair in ("5.1:1", "5.2:1", "9.1:1", "16.7:1"):
|
||||
assert pair in header, f"the verified contrast pair {pair} is recorded"
|
||||
hidden = css.find(".doc-date-edit[hidden]")
|
||||
assert hidden != -1 and "display: none" in css[hidden : hidden + 80], (
|
||||
"the hidden attr must beat the button's display rule"
|
||||
)
|
||||
assert ":focus-visible {" in css
|
||||
assert "outline: 3px solid var(--brand)" in css
|
||||
|
||||
|
||||
def test_date_editor_disabled_uses_the_wait_idiom() -> None:
|
||||
"""The :disabled state on the editor controls is the
|
||||
.git-source-remove:disabled idiom (opacity + cursor: wait — one
|
||||
PATCH at a time), and the empty live lines take no space
|
||||
(display: none on :empty)."""
|
||||
css = _css()
|
||||
dis = css.find(".doc-date-save:disabled")
|
||||
assert dis != -1, "the disabled rule names the editor controls"
|
||||
block = css[dis : css.find("}", dis)]
|
||||
for cls in (
|
||||
".doc-date-save:disabled",
|
||||
".doc-date-cancel:disabled",
|
||||
".doc-date-revert:disabled",
|
||||
".doc-date-input:disabled",
|
||||
):
|
||||
assert cls in block, f"the disabled idiom covers {cls}"
|
||||
assert "opacity: 0.5" in block and "cursor: wait" in block
|
||||
empty = css.find(".doc-date-status:empty")
|
||||
assert empty != -1, "the empty live lines hide themselves"
|
||||
empty_block = css[empty : css.find("}", empty)]
|
||||
assert "display: none" in empty_block
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Unit tests: ``app.rag.doc_dates`` — the date normalization choke point.
|
||||
|
||||
Phase 106, task 02 (D2/D3). The owner's rules, pinned as a boundary
|
||||
matrix on the pure function (no database): an UNDETERMINED date
|
||||
(``None``) and a FUTURE date (beyond the 1-day clock-skew tolerance)
|
||||
both assume "created today"; naive source timestamps are tz-agnostic
|
||||
epoch values rendered as UTC (never local-converted); aware ones are
|
||||
converted to UTC; the stored value keeps full precision. The
|
||||
strict-greater 1-day boundary is pinned on both sides.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import app.rag.doc_dates as doc_dates
|
||||
from app.rag.doc_dates import (
|
||||
FUTURE_SKEW_TOLERANCE,
|
||||
file_mtime_datetime,
|
||||
normalize_doc_date,
|
||||
)
|
||||
|
||||
#: A fixed "today" — every relative case in the matrix hangs off this.
|
||||
NOW = datetime(2026, 9, 13, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the matrix
|
||||
|
||||
|
||||
def test_none_is_undetermined_returns_exactly_now() -> None:
|
||||
assert normalize_doc_date(None, now=NOW) is NOW
|
||||
|
||||
|
||||
def test_naive_raw_is_utc_attached_not_local_converted() -> None:
|
||||
# The homelab host TZ is irrelevant: a naive 12:00 is a UTC 12:00.
|
||||
out = normalize_doc_date(datetime(2020, 5, 1, 12, 0), now=NOW)
|
||||
assert out == datetime(2020, 5, 1, 12, 0, tzinfo=UTC)
|
||||
assert out.utcoffset() == timedelta(0)
|
||||
|
||||
|
||||
def test_aware_raw_is_converted_to_utc() -> None:
|
||||
raw = datetime(2020, 5, 1, 8, 0, tzinfo=timezone(timedelta(hours=-4)))
|
||||
out = normalize_doc_date(raw, now=NOW)
|
||||
assert out == datetime(2020, 5, 1, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_past_date_kept_verbatim() -> None:
|
||||
raw = datetime(2024, 6, 15, 7, 30, 12, 123456, tzinfo=UTC)
|
||||
assert normalize_doc_date(raw, now=NOW) is raw
|
||||
|
||||
|
||||
def test_future_inside_tolerance_keeps_its_date() -> None:
|
||||
# 23 h ahead — a drifting clock, not a future document.
|
||||
raw = NOW + timedelta(hours=23)
|
||||
assert normalize_doc_date(raw, now=NOW) is raw
|
||||
|
||||
|
||||
def test_future_beyond_tolerance_folds_to_today() -> None:
|
||||
# 25 h ahead — beyond the 1-day tolerance → today.
|
||||
assert normalize_doc_date(NOW + timedelta(hours=25), now=NOW) is NOW
|
||||
|
||||
|
||||
def test_exactly_at_tolerance_boundary_keeps_its_date() -> None:
|
||||
# The check is strict-greater: exactly now + tolerance survives.
|
||||
raw = NOW + FUTURE_SKEW_TOLERANCE
|
||||
assert normalize_doc_date(raw, now=NOW) is raw
|
||||
|
||||
|
||||
def test_one_second_past_tolerance_folds_to_today() -> None:
|
||||
assert normalize_doc_date(NOW + FUTURE_SKEW_TOLERANCE + timedelta(seconds=1), now=NOW) is NOW
|
||||
|
||||
|
||||
def test_result_keeps_full_precision() -> None:
|
||||
# No date-truncation — the display formats, the storage doesn't.
|
||||
out = normalize_doc_date(datetime(2020, 5, 1, 12, 0, 0, 987654), now=NOW)
|
||||
assert out.microsecond == 987654
|
||||
|
||||
|
||||
def test_default_now_is_utc_now_for_none() -> None:
|
||||
before = datetime.now(UTC)
|
||||
out = normalize_doc_date(None)
|
||||
after = datetime.now(UTC)
|
||||
assert before <= out <= after
|
||||
assert out.tzinfo is not None
|
||||
|
||||
|
||||
def test_default_now_keeps_old_raw() -> None:
|
||||
out = normalize_doc_date(datetime(1999, 12, 31, 23, 59, tzinfo=UTC))
|
||||
assert out == datetime(1999, 12, 31, 23, 59, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_naive_now_is_treated_as_utc() -> None:
|
||||
# The future check runs in aware space; a naive ``now`` is UTC.
|
||||
naive_now = datetime(2026, 9, 13, 12, 0)
|
||||
assert normalize_doc_date(None, now=naive_now) == datetime(2026, 9, 13, 12, 0, tzinfo=UTC)
|
||||
assert normalize_doc_date(
|
||||
datetime(2026, 9, 15, 12, 1, tzinfo=UTC), now=naive_now
|
||||
) == datetime(2026, 9, 13, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
# ----------------------------------------------------- file_mtime_datetime
|
||||
|
||||
|
||||
def test_file_mtime_datetime_reads_utime_as_utc(tmp_path: Path) -> None:
|
||||
# 1585699200 = 2020-04-01T00:00:00Z (epoch — tz-agnostic).
|
||||
target = 1_585_699_200
|
||||
p = tmp_path / "doc.md"
|
||||
p.write_text("hello\n")
|
||||
os.utime(p, (target, target))
|
||||
out = file_mtime_datetime(p)
|
||||
assert out.tzinfo is not None
|
||||
# ±1 s: mtime granularity varies by filesystem.
|
||||
assert abs((out - datetime(2020, 4, 1, tzinfo=UTC)).total_seconds()) <= 1.0
|
||||
|
||||
|
||||
def test_file_mtime_datetime_future_mtime_stays_future(tmp_path: Path) -> None:
|
||||
# The helper is faithful: the FUTURE folding is normalize's job.
|
||||
p = tmp_path / "future.md"
|
||||
p.write_text("hi\n")
|
||||
future = int((datetime.now(UTC) + timedelta(days=10)).timestamp())
|
||||
os.utime(p, (future, future))
|
||||
out = file_mtime_datetime(p)
|
||||
assert out > datetime.now(UTC)
|
||||
|
||||
|
||||
# ------------------------------------------------- the stdlib-only contract
|
||||
|
||||
|
||||
def test_module_is_pure_stdlib() -> None:
|
||||
"""Source-level pin (D3 choke point): stdlib imports only."""
|
||||
src = Path(doc_dates.__file__).read_text()
|
||||
import_re = re.compile(r"^\s*(?:import|from)\s+([A-Za-z_][A-Za-z0-9_.]*)", re.MULTILINE)
|
||||
modules = [m.split(".")[0] for m in import_re.findall(src)]
|
||||
assert modules # sanity: the regex actually matched the import block
|
||||
non_stdlib = [m for m in modules if m not in sys.stdlib_module_names]
|
||||
assert non_stdlib == []
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user