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:
@@ -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 == []
|
||||
@@ -181,6 +181,7 @@ def test_content_known_pair_maps_to_doc_content() -> None:
|
||||
content_hash="f" * 64,
|
||||
)
|
||||
doc.indexed_at = datetime(2026, 8, 22, 1, 2, 3, tzinfo=UTC)
|
||||
doc.created_at = datetime(2026, 8, 20, 9, 0, 0, tzinfo=UTC) # phase 106
|
||||
with _client_with_row((doc, 3)) as client:
|
||||
r = client.get(
|
||||
"/api/documents/content",
|
||||
@@ -188,8 +189,11 @@ def test_content_known_pair_maps_to_doc_content() -> None:
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# Wire-additive (phase 106, task 05): the pre-date keys are all
|
||||
# still there, joined by ``created_at``.
|
||||
assert set(body) == {
|
||||
"source", "path", "title", "format", "summary", "content", "indexed_at", "chunks"
|
||||
"source", "path", "title", "format", "summary", "created_at",
|
||||
"content", "indexed_at", "chunks",
|
||||
}
|
||||
assert body["source"] == "Homelab"
|
||||
assert body["path"] == "notes/deep mark.md"
|
||||
@@ -197,6 +201,7 @@ def test_content_known_pair_maps_to_doc_content() -> None:
|
||||
assert body["format"] == "md"
|
||||
assert body["summary"] is None # markdown doc → no summary (phase 36)
|
||||
assert body["content"] == "# Deep Mark\n\nbody"
|
||||
assert body["created_at"] == "2026-08-20T09:00:00+00:00" # phase 106
|
||||
assert body["indexed_at"] == "2026-08-22T01:02:03+00:00"
|
||||
assert body["chunks"] == 3
|
||||
|
||||
|
||||
@@ -720,17 +720,20 @@ def test_import_summary_log_line_includes_summary_counters(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""PLAN §9 summary line: the phase-30 counters sit between
|
||||
``embed_batches`` and ``formats``."""
|
||||
``embed_batches`` and ``formats``; the phase-106 date-refresh
|
||||
counter sits between ``summary_errors`` and ``formats``."""
|
||||
s = ImportSummary()
|
||||
s.files, s.added, s.chunks, s.embed_batches = 3, 3, 5, 4
|
||||
s.summaries, s.summary_errors = 2, 1
|
||||
s.dates_updated = 0
|
||||
s.formats = {"md": 1, "yaml": 2}
|
||||
with caplog.at_level(logging.INFO, logger="app.importer"):
|
||||
s.log()
|
||||
line = caplog.records[-1].getMessage()
|
||||
assert line == (
|
||||
"import: summary files=3 added=3 updated=0 unchanged=0 pruned=0 errors=0 "
|
||||
"chunks=5 embed_batches=4 summaries=2 summary_errors=1 formats=yaml:2,md:1"
|
||||
"chunks=5 embed_batches=4 summaries=2 summary_errors=1 dates_updated=0 "
|
||||
"formats=yaml:2,md:1"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Unit: phase 106 (task 04) — the importer's document-date semantics.
|
||||
|
||||
The D2/D4 matrix against the fake LLM + the house ``db`` session
|
||||
(real compose Postgres, the ``tests/unit/test_importer.py`` pattern
|
||||
for importer tests): added files store their source date (the
|
||||
``doc_dates_by_root`` map entry when present, else the file mtime —
|
||||
both normalized by :func:`app.rag.doc_dates.normalize_doc_date`,
|
||||
D3); the unchanged path REFRESHES the stored date from the same
|
||||
source and counts it in ``dates_updated`` (content counts preserved);
|
||||
manual rows (``created_at_manual``) are skipped (the D1 lock); a
|
||||
content change re-sources the date AND clears the manual flag; a
|
||||
future (beyond-skew) mtime folds to today through the importer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Document
|
||||
from app.rag.importer import import_sources
|
||||
from tests.fakes import FakeEmbedder
|
||||
|
||||
#: mtime granularity tolerance — ``os.utime`` + ``stat`` round-trip on
|
||||
#: the test filesystem (the archive-date suite uses ±1 s; 50 ms is far
|
||||
#: tighter and still filesystem-agnostic).
|
||||
_TOL = timedelta(milliseconds=50)
|
||||
|
||||
OLD_2020 = datetime(2020, 1, 2, 3, 4, 5, 123456, tzinfo=UTC)
|
||||
OLD_2021 = datetime(2021, 6, 1, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _utime(path: Path, when: datetime) -> None:
|
||||
ts = when.timestamp()
|
||||
os.utime(path, (ts, ts))
|
||||
|
||||
|
||||
def _doc(db, 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 _cleanup_source(db, source: str) -> None:
|
||||
for doc in db.scalars(select(Document).where(Document.source == source)).all():
|
||||
db.delete(doc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _make_root(tmp_path: Path, name: str) -> tuple[Path, Path]:
|
||||
root = tmp_path / name
|
||||
root.mkdir()
|
||||
file = root / "note.md"
|
||||
file.write_text("# Note\n\ncontent for the KB\n", encoding="utf-8")
|
||||
return root, file
|
||||
|
||||
|
||||
# --- added: the source date lands on the new row -----------------------------
|
||||
|
||||
|
||||
def test_added_file_stores_its_mtime_as_created_at(db, tmp_path: Path) -> None:
|
||||
"""D2 fallback: an unmapped file's mtime IS its source date — a
|
||||
file ``os.utime``'d to 2020-01-02 imports with that ``created_at``
|
||||
(the added branch, D3 normalized), and ``created_at_manual`` stays
|
||||
the column default (False)."""
|
||||
root, file = _make_root(tmp_path, "DateAdded")
|
||||
_utime(file, OLD_2020)
|
||||
llm = FakeEmbedder()
|
||||
try:
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (summary.added, summary.unchanged, summary.dates_updated) == (1, 0, 0)
|
||||
doc = _doc(db, root.name, "note.md")
|
||||
assert abs(doc.created_at - OLD_2020) <= _TOL
|
||||
assert doc.created_at_manual is False
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_doc_dates_map_entry_beats_mtime(db, tmp_path: Path) -> None:
|
||||
"""D2 git case: a ``doc_dates_by_root`` entry (keyed by ``str(root)``
|
||||
— the root string exactly as passed in ``sources``) names the file's
|
||||
raw date and BEATS the file's mtime (the map says 2020, the mtime
|
||||
says now → 2020 stored). A path missing from its root's map takes
|
||||
the mtime fallback (≈ now) in the same run."""
|
||||
root = tmp_path / "DateMap"
|
||||
root.mkdir()
|
||||
mapped = root / "git_file.md"
|
||||
mapped.write_text("# Git\n\nfrom the repo\n", encoding="utf-8")
|
||||
unmapped = root / "local_file.md"
|
||||
unmapped.write_text("# Local\n\nnot in the map\n", encoding="utf-8")
|
||||
now_before = datetime.now(UTC)
|
||||
llm = FakeEmbedder()
|
||||
try:
|
||||
summary = asyncio.run(
|
||||
import_sources(
|
||||
[root], llm, session=db,
|
||||
doc_dates_by_root={str(root): {"git_file.md": OLD_2020}},
|
||||
)
|
||||
)
|
||||
assert summary.added == 2
|
||||
git_doc = _doc(db, root.name, "git_file.md")
|
||||
assert abs(git_doc.created_at - OLD_2020) <= _TOL # the map, not the mtime
|
||||
local_doc = _doc(db, root.name, "local_file.md")
|
||||
# The unmapped file fell back to its mtime (written just now).
|
||||
assert now_before - _TOL <= local_doc.created_at <= datetime.now(UTC) + _TOL
|
||||
# Unchanged re-import with the SAME map: both dates already
|
||||
# stored → no refresh (the map hit is stable, not a rewrite).
|
||||
s2 = asyncio.run(
|
||||
import_sources(
|
||||
[root], llm, session=db,
|
||||
doc_dates_by_root={str(root): {"git_file.md": OLD_2020}},
|
||||
)
|
||||
)
|
||||
assert (s2.added, s2.updated, s2.unchanged) == (0, 0, 2)
|
||||
assert s2.dates_updated == 0
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_future_mtime_folds_to_today_through_importer(db, tmp_path: Path) -> None:
|
||||
"""D3 through the importer: a mtime YEARS in the future (beyond the
|
||||
1-day clock-skew tolerance) folds to the import moment (today), not
|
||||
the raw future value."""
|
||||
root, file = _make_root(tmp_path, "DateFuture")
|
||||
_utime(file, datetime(2030, 1, 1, 0, 0, 0, tzinfo=UTC))
|
||||
llm = FakeEmbedder()
|
||||
try:
|
||||
before = datetime.now(UTC)
|
||||
summary = asyncio.run(import_sources([root], llm, session=db))
|
||||
after = datetime.now(UTC)
|
||||
assert summary.added == 1
|
||||
doc = _doc(db, root.name, "note.md")
|
||||
# The folded date is the normalization moment — between the run's
|
||||
# bounds (a hair of slack on each side).
|
||||
assert before - _TOL <= doc.created_at <= after + _TOL
|
||||
assert doc.created_at.year == before.year # 2030 never stored
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
# --- unchanged: the date refresh (D4) ----------------------------------------
|
||||
|
||||
|
||||
def test_unchanged_reimport_refreshes_date_when_mtime_moves(db, tmp_path: Path) -> None:
|
||||
"""D4: an unchanged file whose source date moved gets the new date
|
||||
(it may go OLDER — no monotonic guard) and is counted in
|
||||
``dates_updated`` — ``added/updated/pruned`` stay 0 (content counts
|
||||
preserved)."""
|
||||
root, file = _make_root(tmp_path, "DateRefresh")
|
||||
llm = FakeEmbedder()
|
||||
try:
|
||||
first = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert first.added == 1 and first.dates_updated == 0
|
||||
_utime(file, OLD_2021) # the source date moved; content identical
|
||||
second = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (second.added, second.updated, second.pruned, second.unchanged) == (0, 0, 0, 1)
|
||||
assert second.dates_updated == 1
|
||||
doc = _doc(db, root.name, "note.md")
|
||||
assert abs(doc.created_at - OLD_2021) <= _TOL
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_unchanged_reimport_same_date_no_refresh(db, tmp_path: Path) -> None:
|
||||
"""D4's no-op case: the source date is unchanged → no write,
|
||||
``dates_updated`` stays 0 (an unchanged re-sync is byte-identical)."""
|
||||
root, _file = _make_root(tmp_path, "DateSame")
|
||||
llm = FakeEmbedder()
|
||||
try:
|
||||
first = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert first.added == 1 and first.dates_updated == 0
|
||||
second = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (second.added, second.updated, second.pruned, second.unchanged) == (0, 0, 0, 1)
|
||||
assert second.dates_updated == 0
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
# --- the D1 manual lock --------------------------------------------------------
|
||||
|
||||
|
||||
def test_manual_row_date_survives_unchanged_reimport(db, tmp_path: Path) -> None:
|
||||
"""D1: a row carrying the owner's correction (``created_at_manual``)
|
||||
is left ENTIRELY alone on the unchanged path — the moved mtime does
|
||||
not refresh it and ``dates_updated`` stays 0 (the sibling of the
|
||||
phase-97 ``manually_edited`` precedent)."""
|
||||
root, file = _make_root(tmp_path, "DateManual")
|
||||
llm = FakeEmbedder()
|
||||
correction = datetime(2023, 5, 5, 9, 30, 0, tzinfo=UTC)
|
||||
try:
|
||||
first = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert first.added == 1
|
||||
doc = _doc(db, root.name, "note.md")
|
||||
doc.created_at = correction # the owner's correction (task 05's API)
|
||||
doc.created_at_manual = True
|
||||
db.commit()
|
||||
_utime(file, OLD_2021) # the source moved to a DIFFERENT date
|
||||
second = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (second.added, second.updated, second.pruned, second.unchanged) == (0, 0, 0, 1)
|
||||
assert second.dates_updated == 0
|
||||
db.expire_all()
|
||||
doc = _doc(db, root.name, "note.md")
|
||||
assert doc.created_at == correction # the correction survived
|
||||
assert doc.created_at_manual is True
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_content_change_resets_date_and_manual_flag(db, tmp_path: Path) -> None:
|
||||
"""D4: a content change is a new document version — the date is
|
||||
re-sourced from the file AND the manual flag is reset (the
|
||||
correction referred to the old content)."""
|
||||
root, file = _make_root(tmp_path, "DateReset")
|
||||
llm = FakeEmbedder()
|
||||
correction = datetime(2023, 5, 5, 9, 30, 0, tzinfo=UTC)
|
||||
try:
|
||||
first = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert first.added == 1
|
||||
doc = _doc(db, root.name, "note.md")
|
||||
doc.created_at = correction
|
||||
doc.created_at_manual = True
|
||||
db.commit()
|
||||
file.write_text("# Note\n\nNEW content — a new version\n", encoding="utf-8")
|
||||
_utime(file, OLD_2021)
|
||||
second = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert (second.added, second.updated, second.unchanged) == (0, 1, 0)
|
||||
assert second.dates_updated == 0 # an update is not a date-only refresh
|
||||
doc = _doc(db, root.name, "note.md")
|
||||
assert abs(doc.created_at - OLD_2021) <= _TOL # re-sourced
|
||||
assert doc.created_at_manual is False # reset
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
@@ -18,6 +18,15 @@ T0 = "2026-09-01T08:00:00+00:00"
|
||||
T1 = "2026-09-02T08:00:00+00:00"
|
||||
T2 = "2026-09-03T08:00:00+00:00"
|
||||
|
||||
# Phase 106 (task 05): the 6th catalogue element — the document's
|
||||
# creation date (``created_at``, ISO-8601). Deliberately DISTINCT from
|
||||
# the ``indexed_at`` stamps so a test that confuses the two columns
|
||||
# fails loudly.
|
||||
C0 = "2020-01-01T00:00:00+00:00"
|
||||
C1 = "2021-06-15T12:00:00+00:00"
|
||||
C2 = "2022-03-01T06:00:00+00:00"
|
||||
C3 = "2023-11-30T23:59:59+00:00"
|
||||
|
||||
|
||||
def _folder_nodes(node) -> list:
|
||||
"""The folder-kind children of a source/folder node, in order."""
|
||||
@@ -34,10 +43,10 @@ def test_multi_source_registry_order_leads_and_indexed_only_appended() -> None:
|
||||
indexed-only sources in alphabetical order (the superset rule)."""
|
||||
names = ["beta", "alpha", "empty"] # registry order — NOT alphabetical
|
||||
doc_rows: list[TreeDocRow] = [
|
||||
("beta", "b.md", "B", 1, T0),
|
||||
("alpha", "a.md", "A", 1, T0),
|
||||
("gamma", "g.md", "G", 1, T0), # indexed-only → appended
|
||||
("delta", "d.md", "D", 1, T0), # indexed-only → appended
|
||||
("beta", "b.md", "B", 1, T0, C0),
|
||||
("alpha", "a.md", "A", 1, T0, C0),
|
||||
("gamma", "g.md", "G", 1, T0, C0), # indexed-only → appended
|
||||
("delta", "d.md", "D", 1, T0, C0), # indexed-only → appended
|
||||
]
|
||||
tree = build_kb_tree(names, doc_rows, {})
|
||||
assert [s.name for s in tree] == ["beta", "alpha", "empty", "delta", "gamma"]
|
||||
@@ -59,6 +68,8 @@ def test_registered_zero_document_source_lists_empty() -> None:
|
||||
assert tree[0].documents == 0
|
||||
assert tree[0].children == []
|
||||
assert tree[0].summary is None
|
||||
# Phase 106 (D9): a 0-document source has no dates at all.
|
||||
assert tree[0].updated_at is None
|
||||
|
||||
|
||||
def test_nested_document_counts_into_source_ancestors_and_own_folder() -> None:
|
||||
@@ -66,10 +77,10 @@ def test_nested_document_counts_into_source_ancestors_and_own_folder() -> None:
|
||||
to ``a/b``, and to ``a/b/c`` (the recursive subtree, the phase-94
|
||||
``ls`` count rule)."""
|
||||
rows = [
|
||||
("S", "a/b/c/deep.md", "Deep", 1, T0),
|
||||
("S", "a/b/shallow.md", "Shallow", 1, T0),
|
||||
("S", "a/top.md", "Top", 1, T0),
|
||||
("S", "root.md", "Root", 1, T0),
|
||||
("S", "a/b/c/deep.md", "Deep", 1, T0, C0),
|
||||
("S", "a/b/shallow.md", "Shallow", 1, T0, C0),
|
||||
("S", "a/top.md", "Top", 1, T0, C0),
|
||||
("S", "root.md", "Root", 1, T0, C0),
|
||||
]
|
||||
(source,) = build_kb_tree(["S"], rows, {})
|
||||
assert source.documents == 4
|
||||
@@ -91,8 +102,8 @@ def test_existence_rule_a_file_path_is_never_a_folder() -> None:
|
||||
starts with ``folder + "/"``); a document's own path — even one
|
||||
with dots — never creates a folder."""
|
||||
rows = [
|
||||
("S", "x.md", "X", 1, T0),
|
||||
("S", "x.y/z.md", "Z", 1, T0),
|
||||
("S", "x.md", "X", 1, T0, C0),
|
||||
("S", "x.y/z.md", "Z", 1, T0, C1),
|
||||
]
|
||||
(source,) = build_kb_tree(["S"], rows, {})
|
||||
folders = _folder_nodes(source)
|
||||
@@ -109,8 +120,8 @@ def test_file_folder_name_collision_both_appear() -> None:
|
||||
colliding file counts into the folder's subtree (the ``ls`` count
|
||||
rule's ``path == folder`` arm)."""
|
||||
rows = [
|
||||
("S", "a", "File A", 1, T0), # a file wearing the folder's name
|
||||
("S", "a/b.md", "B", 1, T0), # makes ``a`` a folder
|
||||
("S", "a", "File A", 1, T0, C0), # a file wearing the folder's name
|
||||
("S", "a/b.md", "B", 1, T0, C1), # makes ``a`` a folder
|
||||
]
|
||||
(source,) = build_kb_tree(["S"], rows, {})
|
||||
folders = _folder_nodes(source)
|
||||
@@ -127,12 +138,12 @@ def test_subfolder_path_order_and_file_catalog_order() -> None:
|
||||
the input (catalog — ``GET /api/docs``) order, independent of the
|
||||
subfolder ordering."""
|
||||
rows = [
|
||||
("S", "zeta/z1.md", "Z1", 1, T0),
|
||||
("S", "alpha/a1.md", "A1", 1, T0),
|
||||
("S", "mike/m1.md", "M1", 1, T0),
|
||||
("S", "beta/b1.md", "B1", 1, T0),
|
||||
("S", "z-file.md", "Z", 1, T0), # file AFTER the folders in input
|
||||
("S", "a-file.md", "A", 1, T0), # file before it in input
|
||||
("S", "zeta/z1.md", "Z1", 1, T0, C0),
|
||||
("S", "alpha/a1.md", "A1", 1, T0, C0),
|
||||
("S", "mike/m1.md", "M1", 1, T0, C0),
|
||||
("S", "beta/b1.md", "B1", 1, T0, C0),
|
||||
("S", "z-file.md", "Z", 1, T0, C0), # file AFTER the folders in input
|
||||
("S", "a-file.md", "A", 1, T0, C0), # file before it in input
|
||||
]
|
||||
(source,) = build_kb_tree(["S"], rows, {})
|
||||
assert [f.path for f in _folder_nodes(source)] == ["alpha", "beta", "mike", "zeta"]
|
||||
@@ -147,9 +158,9 @@ def test_summaries_present_and_absent() -> None:
|
||||
path) or null when absent — any row (AI or manual is indistinguishable
|
||||
here; the builder carries whatever is stored)."""
|
||||
rows = [
|
||||
("S", "one/a.md", "A", 1, T0),
|
||||
("S", "one/b.md", "B", 1, T0),
|
||||
("S", "two/c.md", "C", 1, T0),
|
||||
("S", "one/a.md", "A", 1, T0, C0),
|
||||
("S", "one/b.md", "B", 1, T0, C0),
|
||||
("S", "two/c.md", "C", 1, T0, C0),
|
||||
]
|
||||
summaries = {("S", ""): "Source desc.", ("S", "one"): "One desc."}
|
||||
# ("S", "two") is NOT stored → null.
|
||||
@@ -158,21 +169,28 @@ def test_summaries_present_and_absent() -> None:
|
||||
one, two = _folder_nodes(source)
|
||||
assert one.summary == "One desc."
|
||||
assert two.summary is None
|
||||
# File nodes carry no summary key at all (the 00_phase.md shape).
|
||||
# File nodes carry no summary key at all (the 00_phase.md shape);
|
||||
# since phase 106 they DO carry the creation date (``created_at``
|
||||
# — the RAG view's ``Created`` column).
|
||||
file = _file_nodes(one)[0]
|
||||
assert set(file.model_dump()) == {"kind", "path", "title", "chunks", "indexed_at"}
|
||||
assert set(file.model_dump()) == {
|
||||
"kind", "path", "title", "chunks", "created_at", "indexed_at"
|
||||
}
|
||||
assert "summary" not in file.__class__.model_fields
|
||||
|
||||
|
||||
def test_file_metadata_unchanged_in_tree() -> None:
|
||||
"""File ``title`` / ``chunks`` / ``indexed_at`` ride into the tree
|
||||
verbatim from the catalogue row (no reformatting)."""
|
||||
rows = [("S", "deep/x/y.md", "The Title", 7, T2)]
|
||||
"""File ``title`` / ``chunks`` / ``created_at`` (phase 106) /
|
||||
``indexed_at`` ride into the tree verbatim from the catalogue row
|
||||
(no reformatting) — and the two stamps stay distinct (the
|
||||
``created_at`` date is not confused with the ``indexed_at`` stamp)."""
|
||||
rows = [("S", "deep/x/y.md", "The Title", 7, T2, C2)]
|
||||
(source,) = build_kb_tree(["S"], rows, {})
|
||||
file = _file_nodes(_folder_nodes(_folder_nodes(source)[0])[0])[0]
|
||||
assert file.path == "deep/x/y.md"
|
||||
assert file.title == "The Title"
|
||||
assert file.chunks == 7
|
||||
assert file.created_at == C2 # phase 106: verbatim from the catalogue row
|
||||
assert file.indexed_at == T2
|
||||
|
||||
|
||||
@@ -188,10 +206,10 @@ def test_indexed_document_under_unlisted_source_is_impossible() -> None:
|
||||
is no input where a document is dropped."""
|
||||
names = ["reg-b", "reg-a"]
|
||||
rows: list[TreeDocRow] = [
|
||||
("reg-b", "b.md", "B", 1, T0),
|
||||
("zzz", "z.md", "Z", 1, T0),
|
||||
("aaa", "a.md", "A", 1, T0),
|
||||
("reg-a", "a.md", "A2", 1, T0),
|
||||
("reg-b", "b.md", "B", 1, T0, C0),
|
||||
("zzz", "z.md", "Z", 1, T0, C1),
|
||||
("aaa", "a.md", "A", 1, T0, C2),
|
||||
("reg-a", "a.md", "A2", 1, T0, C3),
|
||||
]
|
||||
tree = build_kb_tree(names, rows, {})
|
||||
listed = [s.name for s in tree]
|
||||
@@ -214,14 +232,14 @@ def test_indexed_document_under_unlisted_source_is_impossible() -> None:
|
||||
#: summary-stored folder and an unstored one. Paths are in (source,
|
||||
#: path) catalog order; titles map 1:1 to paths.
|
||||
CROSS_ROWS: list[TreeDocRow] = [
|
||||
("S", "note", "Note", 1, T0),
|
||||
("S", "one/a.md", "A", 2, T1),
|
||||
("S", "one/b.md", "B", 0, T1),
|
||||
("S", "one/two/c.md", "C", 3, T1),
|
||||
("S", "one/two/d.md", "D", 1, T1),
|
||||
("S", "root.md", "Root", 4, T0),
|
||||
("S", "zz/e.md", "E", 2, T2),
|
||||
("S", "zz/f.md", "F", 2, T2),
|
||||
("S", "note", "Note", 1, T0, C0),
|
||||
("S", "one/a.md", "A", 2, T1, C1),
|
||||
("S", "one/b.md", "B", 0, T1, C1),
|
||||
("S", "one/two/c.md", "C", 3, T1, C2),
|
||||
("S", "one/two/d.md", "D", 1, T1, C2),
|
||||
("S", "root.md", "Root", 4, T0, C3),
|
||||
("S", "zz/e.md", "E", 2, T2, C0),
|
||||
("S", "zz/f.md", "F", 2, T2, C0),
|
||||
]
|
||||
|
||||
CROSS_SUMMARIES = {
|
||||
@@ -236,9 +254,17 @@ def _cross_check(folder: str, builder_node) -> None:
|
||||
"""Assert the builder's level *folder* equals
|
||||
``group_folder_listing("S", folder, ...)`` — same subfolder
|
||||
``(path, count, summary)`` triples in order AND same file
|
||||
``(path, title)`` pairs in order (uncapped — the dataset is well
|
||||
under the ``ls`` 50-line cap, so the cap is inert)."""
|
||||
rows = [(path, title) for _source, path, title, _chunks, _stamp in CROSS_ROWS]
|
||||
``(source, path, title, date)`` 4-tuples in order (uncapped — the
|
||||
dataset is well under the ``ls`` 50-line cap, so the cap is inert).
|
||||
Phase 106 (task 06, D5): the cross-check compares the builder's
|
||||
OUTPUT node projections against the agent's extended file shape —
|
||||
the node's ``created_at`` DATE PART (the same ``YYYY-MM-DD`` the
|
||||
agent's ``ls`` line renders) joins the comparison.
|
||||
"""
|
||||
rows = [
|
||||
(path, title, created[:10])
|
||||
for _source, path, title, _chunks, _stamp, created in CROSS_ROWS
|
||||
]
|
||||
source_summaries = {
|
||||
folder_path: summary
|
||||
for (source, folder_path), summary in CROSS_SUMMARIES.items()
|
||||
@@ -246,8 +272,8 @@ def _cross_check(folder: str, builder_node) -> None:
|
||||
}
|
||||
subs, files, _total = group_folder_listing("S", folder, rows, source_summaries)
|
||||
assert [(f.path, f.documents, f.summary) for f in _folder_nodes(builder_node)] == subs
|
||||
assert [(f.path, f.title) for f in _file_nodes(builder_node)] == [
|
||||
(path, title) for _source, path, title in files
|
||||
assert [(f.path, f.title, f.created_at[:10]) for f in _file_nodes(builder_node)] == [
|
||||
(path, title, date) for _source, path, title, date in files
|
||||
]
|
||||
|
||||
|
||||
@@ -302,8 +328,8 @@ def test_folder_two_docs_no_stored_row_is_pending() -> None:
|
||||
(the marker's "waiting to generate" semantics); file nodes carry
|
||||
no flag at all (the file table has no description column)."""
|
||||
rows = [
|
||||
("S", "one/a.md", "A", 1, T0),
|
||||
("S", "one/b.md", "B", 1, T0),
|
||||
("S", "one/a.md", "A", 1, T0, C0),
|
||||
("S", "one/b.md", "B", 1, T0, C0),
|
||||
]
|
||||
(source,) = build_kb_tree(["S"], rows, {})
|
||||
(one,) = _folder_nodes(source)
|
||||
@@ -321,8 +347,8 @@ def test_folder_with_stored_row_is_not_pending() -> None:
|
||||
A row on the folder does not cover the source root: with no
|
||||
``(source, "")`` row the SOURCE node stays pending."""
|
||||
rows = [
|
||||
("S", "one/a.md", "A", 1, T0),
|
||||
("S", "one/b.md", "B", 1, T0),
|
||||
("S", "one/a.md", "A", 1, T0, C0),
|
||||
("S", "one/b.md", "B", 1, T0, C0),
|
||||
]
|
||||
(source,) = build_kb_tree(["S"], rows, {("S", "one"): "Manual."})
|
||||
(one,) = _folder_nodes(source)
|
||||
@@ -338,8 +364,8 @@ def test_single_document_folder_never_pending() -> None:
|
||||
— its one file line IS its description), even with no stored row
|
||||
— while its ≥ 2-doc source root (no root row) still is."""
|
||||
rows = [
|
||||
("S", "solo/only.md", "Only", 1, T0), # 1-doc folder
|
||||
("S", "top.md", "Top", 1, T0), # source total = 2
|
||||
("S", "solo/only.md", "Only", 1, T0, C0), # 1-doc folder
|
||||
("S", "top.md", "Top", 1, T0, C1), # source total = 2
|
||||
]
|
||||
(source,) = build_kb_tree(["S"], rows, {})
|
||||
(solo,) = _folder_nodes(source)
|
||||
@@ -357,8 +383,8 @@ def test_name_collision_pending_follows_recursive_count() -> None:
|
||||
the number of direct children. A stored row on the NESTED folder
|
||||
alone clears only that marker (the rule is per node)."""
|
||||
rows = [
|
||||
("S", "one/a", "File A", 1, T0), # a file wearing the folder's name
|
||||
("S", "one/a/b.md", "B", 1, T0), # makes ``one/a`` a folder
|
||||
("S", "one/a", "File A", 1, T0, C0), # a file wearing the folder's name
|
||||
("S", "one/a/b.md", "B", 1, T0, C1), # makes ``one/a`` a folder
|
||||
]
|
||||
(source,) = build_kb_tree(["S"], rows, {})
|
||||
one = _folder_nodes(source)[0]
|
||||
@@ -386,8 +412,8 @@ def test_source_root_pending_and_zero_document_source_never() -> None:
|
||||
A registered 0-document source is NEVER pending (0 < the minimum —
|
||||
there is nothing to summarize), with or without a manual row."""
|
||||
rows = [
|
||||
("Full", "x/1.md", "1", 1, T0),
|
||||
("Full", "y.md", "Y", 1, T0),
|
||||
("Full", "x/1.md", "1", 1, T0, C0),
|
||||
("Full", "y.md", "Y", 1, T0, C1),
|
||||
]
|
||||
full, empty = build_kb_tree(["Full", "Empty"], rows, {})
|
||||
assert full.documents == 2
|
||||
@@ -407,11 +433,115 @@ def test_two_sources_pending_independently() -> None:
|
||||
row and the other not, only the rowless source's node is pending —
|
||||
the markers never leak across sources."""
|
||||
rows = [
|
||||
("A", "a1.md", "A1", 1, T0),
|
||||
("A", "a2.md", "A2", 1, T0),
|
||||
("B", "b1.md", "B1", 1, T0),
|
||||
("B", "b2.md", "B2", 1, T0),
|
||||
("A", "a1.md", "A1", 1, T0, C0),
|
||||
("A", "a2.md", "A2", 1, T0, C1),
|
||||
("B", "b1.md", "B1", 1, T0, C2),
|
||||
("B", "b2.md", "B2", 1, T0, C3),
|
||||
]
|
||||
a, b = build_kb_tree(["A", "B"], rows, {("A", ""): "A root."})
|
||||
assert (a.summary, a.summary_pending) == ("A root.", False)
|
||||
assert (b.summary, b.summary_pending) == (None, True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Document dates (phase 106, task 05, D8/D9) — the pure builder's date
|
||||
# threading: file ``created_at`` verbatim; folder/source ``updated_at``
|
||||
# = the subtree's MAX document ``created_at`` (derived as the builder
|
||||
# recurses, never stored; ``None`` for a node with no documents).
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_updated_at_deep_file_beats_shallow_sibling() -> None:
|
||||
"""D9: a folder's ``updated_at`` is the MAX over its WHOLE subtree —
|
||||
a deeper file's date wins over a shallower sibling's (the max
|
||||
recurses through every level, not just the direct files)."""
|
||||
rows = [
|
||||
("S", "root.md", "Root", 1, T0, C0),
|
||||
("S", "a/top.md", "Top", 1, T0, C1),
|
||||
("S", "a/b/shallow.md", "Shallow", 1, T0, C2),
|
||||
("S", "a/b/c/deep.md", "Deep", 1, T0, C3), # the overall max
|
||||
]
|
||||
(source,) = build_kb_tree(["S"], rows, {})
|
||||
a = _folder_nodes(source)[0]
|
||||
a_b = _folder_nodes(a)[0]
|
||||
a_b_c = _folder_nodes(a_b)[0]
|
||||
# The deepest folder: its one file's date.
|
||||
assert a_b_c.updated_at == C3
|
||||
# a/b: its own file (C2) vs its child's subtree max (C3) → C3.
|
||||
assert a_b.updated_at == C3
|
||||
# a: its direct file (C1) vs the deeper subtree (C3) → the DEEPER
|
||||
# file's date wins over the shallow sibling's.
|
||||
assert a.updated_at == C3
|
||||
# The source root: max over root.md (C0) + a's subtree (C3).
|
||||
assert source.updated_at == C3
|
||||
# File nodes carry their own date verbatim — no ``updated_at`` key.
|
||||
deep = _file_nodes(a_b_c)[0]
|
||||
assert deep.created_at == C3
|
||||
assert "updated_at" not in deep.model_dump()
|
||||
|
||||
|
||||
def test_updated_at_direct_file_wins_when_it_is_the_max() -> None:
|
||||
"""The inverse: when a folder's OWN direct file holds the newest
|
||||
date, the max stays at the direct level (the recursion takes the
|
||||
max, it does not prefer depth)."""
|
||||
rows = [
|
||||
("S", "root.md", "Root", 1, T0, C0),
|
||||
("S", "a/top.md", "Top", 1, T0, C3), # the overall max, DIRECT
|
||||
("S", "a/b/shallow.md", "Shallow", 1, T0, C1),
|
||||
("S", "a/b/c/deep.md", "Deep", 1, T0, C2),
|
||||
]
|
||||
(source,) = build_kb_tree(["S"], rows, {})
|
||||
a = _folder_nodes(source)[0]
|
||||
a_b = _folder_nodes(a)[0]
|
||||
a_b_c = _folder_nodes(a_b)[0]
|
||||
assert a_b_c.updated_at == C2 # its own file
|
||||
assert a_b.updated_at == C2 # max(C1, child C2)
|
||||
assert a.updated_at == C3 # the direct file (C3) beats the subtree (C2)
|
||||
assert source.updated_at == C3 # max(C0, a's C3)
|
||||
|
||||
|
||||
def test_updated_at_threads_through_folder_only_subtree() -> None:
|
||||
"""A folder with NO direct files (only subfolders) still carries the
|
||||
date threaded up from its child subfolders — the max is over the
|
||||
children (files AND folders), so a pure directory chain never loses
|
||||
the dates below it."""
|
||||
rows = [("S", "a/b/c/x.md", "X", 1, T0, C2)]
|
||||
(source,) = build_kb_tree(["S"], rows, {})
|
||||
a = _folder_nodes(source)[0] # no direct files — only subfolder a/b
|
||||
a_b = _folder_nodes(a)[0] # no direct files — only subfolder a/b/c
|
||||
a_b_c = _folder_nodes(a_b)[0]
|
||||
assert a_b_c.updated_at == C2
|
||||
assert a_b.updated_at == C2
|
||||
assert a.updated_at == C2
|
||||
assert source.updated_at == C2
|
||||
|
||||
|
||||
def test_updated_at_is_none_only_for_nodes_without_documents() -> None:
|
||||
"""``None`` is reserved for nodes with NO documents at all — a
|
||||
registered 0-document source; every node that has ≥ 1 document in
|
||||
its subtree carries a date (the 6th catalogue element is always
|
||||
present — ``created_at`` is NOT NULL, D1)."""
|
||||
full, empty = build_kb_tree(
|
||||
["Full", "Empty"],
|
||||
[("Full", "only.md", "Only", 1, T0, C1)],
|
||||
{},
|
||||
)
|
||||
assert full.documents == 1
|
||||
assert full.updated_at == C1
|
||||
assert empty.documents == 0
|
||||
assert empty.updated_at is None
|
||||
assert empty.children == []
|
||||
|
||||
|
||||
def test_updated_at_does_not_leak_across_sources() -> None:
|
||||
"""The max is per source subtree: one source's newest document never
|
||||
lifts another source's ``updated_at`` (the dates are computed
|
||||
inside :func:`build_kb_tree`'s per-source node, like ``documents``
|
||||
and ``summary_pending``)."""
|
||||
rows = [
|
||||
("A", "a1.md", "A1", 1, T0, C0),
|
||||
("B", "b1.md", "B1", 1, T0, C3), # B's date is the global max
|
||||
]
|
||||
a, b = build_kb_tree(["A", "B"], rows, {})
|
||||
assert a.updated_at == C0
|
||||
assert b.updated_at == C3
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
The RAG (Knowledge base) view lists the catalog the way the agent's
|
||||
``ls`` sees it (the phase-94 concept, ONE end to end): sources at the
|
||||
top, then per level the subfolders (recursive count + the STORED
|
||||
description) and the level's files (the existing 5-column
|
||||
``#docs-table`` — unchanged). The view's catalog load is now
|
||||
description) and the level's files (the ``#docs-table`` — phase 106,
|
||||
task 08, D8, added the Created column BEFORE Indexed, making it the
|
||||
6-column contract). The view's catalog load is now
|
||||
``loadTree()``: ONE fetch of ``GET /api/docs/tree`` (the full recursive
|
||||
tree in a single payload — task 02), then client-side drill navigation
|
||||
(no per-level fetch, no URL change).
|
||||
@@ -17,8 +18,12 @@ suite, task 06):
|
||||
* the shell's RAG view ships the static tree skeleton (``#kb-crumb``,
|
||||
``#kb-level`` + ``#kb-level-title``/``#kb-level-summary``,
|
||||
``#folders-wrap`` + ``#folders-table`` — ONE table for every level,
|
||||
the Folder | Documents | Description head) in order after
|
||||
``#stat-cards`` and BEFORE the unchanged file table;
|
||||
the Folder | Documents | Updated | Description head — phase 106
|
||||
(task 08, D8) added the Updated column (the subtree's MAX document
|
||||
created_at, D9) BETWEEN Documents and Description) in order after
|
||||
``#stat-cards`` and BEFORE the file table (now the 6-column
|
||||
Source | Path | Title | Chunks | Created | Indexed head — phase 106
|
||||
(task 08, D8) added the Created column BEFORE Indexed);
|
||||
* the exact ``/api/docs/tree`` fetch (and the flat ``/api/docs`` fetch
|
||||
is gone from the view module);
|
||||
* the drill state (``current`` / ``kbTree``), the state transitions
|
||||
@@ -189,8 +194,10 @@ def test_rag_view_ships_the_tree_skeleton_in_order() -> None:
|
||||
``#kb-crumb`` is the location nav; ``#kb-level`` is the level block
|
||||
(title + description); ``#folders-wrap`` hosts the ONE
|
||||
folders/sources table (``.table-wrap`` card, Folder | Documents |
|
||||
Description, visually-hidden caption, the ``.docs-table``
|
||||
language + ``.kb-folders-table``)."""
|
||||
Updated | Description — phase 106 (task 08, D8), visually-hidden
|
||||
caption, the ``.docs-table`` language + ``.kb-folders-table``).
|
||||
The file table's head is the 6-column contract (Source | Path |
|
||||
Title | Chunks | Created | Indexed — phase 106 (task 08, D8))."""
|
||||
view = _rag_view(_text(SHELL_HTML))
|
||||
for fragment in (
|
||||
'<nav id="kb-crumb" class="kb-crumb" aria-label="Catalog location" hidden></nav>',
|
||||
@@ -209,6 +216,7 @@ def test_rag_view_ships_the_tree_skeleton_in_order() -> None:
|
||||
for column in (
|
||||
"<th scope=\"col\">Folder</th>",
|
||||
"<th scope=\"col\">Documents</th>",
|
||||
"<th scope=\"col\">Updated</th>", # phase 106 (task 08, D8)
|
||||
"<th scope=\"col\">Description</th>",
|
||||
):
|
||||
assert column in head.group(1), f"#folders-table head must carry {column!r}"
|
||||
@@ -221,12 +229,13 @@ def test_rag_view_ships_the_tree_skeleton_in_order() -> None:
|
||||
< view.find('id="folders-wrap"')
|
||||
< view.find('id="docs-table"')
|
||||
), "the skeleton must sit between the stat cards and the file table"
|
||||
# The file table is UNCHANGED (the 5-column contract, makeRow's home).
|
||||
# The file table's head (the 6-column contract — phase 106 (task
|
||||
# 08, D8) added Created BEFORE Indexed; makeRow's home).
|
||||
doc_head = re.search(
|
||||
r'<table class="docs-table" id="docs-table">.*?<thead>(.*?)</thead>', view, re.S
|
||||
)
|
||||
assert doc_head, "the file table must keep its static thead"
|
||||
for column in ("Source", "Path", "Title", "Chunks", "Indexed"):
|
||||
for column in ("Source", "Path", "Title", "Chunks", "Created", "Indexed"):
|
||||
assert f">{column}</th>" in doc_head.group(1), f"#docs-table head must keep {column!r}"
|
||||
|
||||
|
||||
@@ -287,7 +296,9 @@ def test_source_row_click_drills_into_the_source_root() -> None:
|
||||
The row carries the recursive count and the stored (source, "")
|
||||
description — textContent only."""
|
||||
js = _js()
|
||||
body = js[js.find("function makeSourceRow(") : js.find("function makeSourceRow(") + 1200]
|
||||
# Slice to the next function (the row builders grew with phase 106's
|
||||
# Updated cell — a fixed window would drift out of the function).
|
||||
body = js[js.find("function makeSourceRow(") : js.find("function makeFolderRow(")]
|
||||
assert 'link.className = "folder-link"' in body, "the source row uses the row drill link"
|
||||
assert "link.textContent = s.name" in body
|
||||
assert "goTo({ source: s.name, folder: \"\" })" in body, (
|
||||
@@ -309,7 +320,7 @@ def test_folder_row_click_drills_into_the_folder() -> None:
|
||||
`{ source, folder: f.path }` (the source-relative folder path)."""
|
||||
js = _js()
|
||||
start = js.find("function makeFolderRow(")
|
||||
body = js[start : start + 1400]
|
||||
body = js[start : js.find("function renderLevel(")]
|
||||
assert 'link.className = "folder-link"' in body
|
||||
assert "link.textContent = f.path.split(\"/\").pop()" in body, (
|
||||
"the label is the last segment (the breadcrumb language)"
|
||||
@@ -1370,7 +1381,9 @@ def test_styles_carry_the_one_line_clamp() -> None:
|
||||
"white-space: nowrap",
|
||||
):
|
||||
assert prop in text, f".kb-desc-text must carry {prop!r}"
|
||||
col_i = css.find(".kb-folders-table td:nth-child(3) {")
|
||||
# Phase 106 (task 08): the clamp follows the Description cell, which
|
||||
# moved to the 4th column (Updated took 3rd).
|
||||
col_i = css.find(".kb-folders-table td:nth-child(4) {")
|
||||
assert col_i != -1
|
||||
col = css[col_i : col_i + 400]
|
||||
col = col[: col.find("\n}")]
|
||||
|
||||
@@ -24,6 +24,7 @@ only) is unchanged.
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -47,6 +48,13 @@ OVERVIEW = "- Homelab\n - Kubernetes (k3s)\n- Deployments\n - Borg backups"
|
||||
KB_INTRO = "The basic categories of everything in this knowledge base (generated at import time):"
|
||||
|
||||
|
||||
#: The fixture documents' fixed creation date (phase 106, D5) — the
|
||||
#: ``<document>`` block formats its UTC date part (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(path: str, content: str, title: str) -> Document:
|
||||
return Document(
|
||||
id=uuid.uuid4(),
|
||||
@@ -56,6 +64,7 @@ def _doc(path: str, content: str, title: str) -> Document:
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash="0" * 64,
|
||||
created_at=_FIXTURE_CREATED_AT,
|
||||
)
|
||||
|
||||
|
||||
@@ -137,7 +146,8 @@ def test_zero_note_prompt_is_byte_identical_to_pre_steering() -> None:
|
||||
fixtures account for it; the LOW prompt is untouched.)"""
|
||||
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||
block = (
|
||||
'<document source="Homelab" path="kubernetes.md" title="Kubernetes Homelab Cluster">\n'
|
||||
'<document source="Homelab" path="kubernetes.md" '
|
||||
'title="Kubernetes Homelab Cluster" date="2024-06-15">\n'
|
||||
"Talos Linux on three nodes.\n"
|
||||
"</document>"
|
||||
)
|
||||
@@ -394,7 +404,8 @@ def test_no_overview_prompt_is_byte_identical_to_pre_phase() -> None:
|
||||
body — the fixtures account for it; the LOW prompt is untouched.)"""
|
||||
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
|
||||
block = (
|
||||
'<document source="Homelab" path="kubernetes.md" title="Kubernetes Homelab Cluster">\n'
|
||||
'<document source="Homelab" path="kubernetes.md" '
|
||||
'title="Kubernetes Homelab Cluster" date="2024-06-15">\n'
|
||||
"Talos Linux on three nodes.\n"
|
||||
"</document>"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Unit: the phase-106 D5 date surfaces (task 06) — the date rides every
|
||||
document the model sees.
|
||||
|
||||
Three surfaces, pinned here against pure inputs (no database):
|
||||
|
||||
* the HIGH prompt's ``<document>`` block carries
|
||||
``date="YYYY-MM-DD"`` — the row's ``created_at`` UTC date part,
|
||||
APPENDED after ``title`` (the only position; always present,
|
||||
``created_at`` is NOT NULL) — while the DEFLECTION prompt stays
|
||||
byte-identical to the pre-phase text (the A8 contract — titles only,
|
||||
no documents, no dates);
|
||||
* the ``read`` tool result carries ``date: YYYY-MM-DD`` as its SECOND
|
||||
line — the FIRST line stays ``Document {source}/{path}:``
|
||||
BYTE-IDENTICAL (the E2E mock's ``_READ_RESULT_PREFIX`` header
|
||||
contract) — in BOTH the plain and the truncated shapes (marker +
|
||||
notice still follow the cut content);
|
||||
* the ``ls`` FILE line ENDS with the appended `` | date: YYYY-MM-DD``
|
||||
field (never inserted before ``title``) and the 50-line cap note is
|
||||
unchanged; source/folder lines carry no date.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import Document
|
||||
from app.rag import agent
|
||||
from app.rag.agent import READ_TRUNCATION_NOTICE, AgentHolder, run_agent
|
||||
from app.rag.llm import (
|
||||
LLMClient,
|
||||
StreamPiece,
|
||||
ToolCallPiece,
|
||||
)
|
||||
from app.rag.prompts import (
|
||||
_base,
|
||||
build_deflect_prompt,
|
||||
build_high_prompt,
|
||||
build_steering_section,
|
||||
)
|
||||
from app.rag.retriever import TRUNCATION_MARKER
|
||||
|
||||
#: A fixed creation date; the UTC date part is ``2024-06-15``.
|
||||
CREATED_AT = datetime(2024, 6, 15, 12, 30, 45, tzinfo=UTC)
|
||||
DATE = "2024-06-15"
|
||||
|
||||
|
||||
def _doc(
|
||||
source: str = "S",
|
||||
path: str = "P",
|
||||
title: str = "T",
|
||||
content: str = "CONTENT",
|
||||
created_at: datetime = CREATED_AT,
|
||||
) -> Document:
|
||||
return Document(
|
||||
id=uuid.uuid4(),
|
||||
source=source,
|
||||
path=path,
|
||||
full_path=f"/tmp/{path}",
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash="0" * 64,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
|
||||
def _settings(**kwargs: Any) -> Settings:
|
||||
kwargs.setdefault("_env_file", None)
|
||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
|
||||
|
||||
class ScriptedLLM:
|
||||
"""Canned stream sequences; records every ``chat_stream`` request."""
|
||||
|
||||
def __init__(self, *streams: list[StreamPiece | ToolCallPiece]) -> None:
|
||||
self.streams: list[list[StreamPiece | ToolCallPiece]] = list(streams)
|
||||
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: object = None,
|
||||
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
||||
self.requests.append((messages, tools))
|
||||
if not self.streams:
|
||||
return
|
||||
for piece in self.streams.pop(0):
|
||||
yield piece
|
||||
|
||||
|
||||
async def _run(
|
||||
llm: ScriptedLLM,
|
||||
holder: AgentHolder,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
async for _piece in run_agent(
|
||||
cast("LLMClient", llm),
|
||||
cast("Session", None),
|
||||
system_prompt="SYSTEM_PROMPT",
|
||||
user_message="QUESTION",
|
||||
seed_docs=[],
|
||||
settings=settings,
|
||||
holder=holder,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# The HIGH prompt's <document> block — date="YYYY-MM-DD" after title
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_high_block_carries_date_attribute_after_title() -> None:
|
||||
"""The block's identity attributes are ``source`` / ``path`` /
|
||||
``title`` — and, phase 106 (D5), ``date`` APPENDED after ``title``
|
||||
(the only position), always present."""
|
||||
doc = _doc(source="S", path="P", title="T", content="THE BODY")
|
||||
prompt = build_high_prompt([doc])
|
||||
block = (
|
||||
f'<document source="S" path="P" title="T" date="{DATE}">\n'
|
||||
"THE BODY\n"
|
||||
"</document>"
|
||||
)
|
||||
assert block in prompt
|
||||
# The attribute order is pinned: date directly after title, then
|
||||
# the closing bracket (nothing may land between title and date or
|
||||
# after date before the block opens).
|
||||
assert f'title="T" date="{DATE}">' in prompt
|
||||
assert '<document source="S" path="P" title="T">\n' not in prompt # pre-phase shape is gone
|
||||
|
||||
|
||||
def test_high_block_date_is_the_utc_date_part() -> None:
|
||||
"""The date is the row's ``created_at`` UTC DATE part: a late UTC
|
||||
instant (23:59:59 on 2024-06-15) renders ``2024-06-15`` — and the
|
||||
time-of-day is never shown (full precision is stored; only the
|
||||
date part rides the block). Stored rows are always UTC-aware
|
||||
(``timestamptz`` read-back + :func:`normalize_doc_date`), so the
|
||||
UTC date part is the stored date part."""
|
||||
doc = _doc(created_at=datetime(2024, 6, 15, 23, 59, 59, tzinfo=UTC))
|
||||
prompt = build_high_prompt([doc])
|
||||
assert f'title="T" date="{DATE}">' in prompt
|
||||
assert "2024-06-16" not in prompt
|
||||
# The time-of-day is never rendered on the block.
|
||||
assert "23:59" not in prompt and "12:30" not in prompt
|
||||
|
||||
|
||||
def test_high_block_date_always_present_for_every_document() -> None:
|
||||
"""Every document block carries the date (``created_at`` is NOT
|
||||
NULL — the attribute is never omitted), each with its OWN row's
|
||||
date."""
|
||||
a = _doc(source="A", path="a.md", title="A", content="CA", created_at=CREATED_AT)
|
||||
b = _doc(
|
||||
source="B",
|
||||
path="b.md",
|
||||
title="B",
|
||||
content="CB",
|
||||
created_at=datetime(2019, 6, 15, 5, 0, 0, tzinfo=UTC),
|
||||
)
|
||||
prompt = build_high_prompt([a, b])
|
||||
assert '<document source="A" path="a.md" title="A" date="2024-06-15">' in prompt
|
||||
assert '<document source="B" path="b.md" title="B" date="2019-06-15">' in prompt
|
||||
# No date-less block anywhere.
|
||||
assert prompt.count("<document ") == prompt.count(' date="')
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# The deflection prompt — byte-identical to the pre-phase text (A8)
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_deflect_prompt_is_byte_identical_to_pre_phase() -> None:
|
||||
"""The deflection path is titles-only (no documents involved): the
|
||||
A8 byte-identity contract holds — same inputs, pre-phase bytes,
|
||||
with or without steering notes."""
|
||||
# No notes: the pre-phase LOW build verbatim.
|
||||
expected_plain = (
|
||||
_base("LOW")
|
||||
+ "\n"
|
||||
+ "DEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
||||
"your notes come to the question. They are titles only; do not pretend "
|
||||
"they answer it. Use them to propose 2-3 alternative questions.\n"
|
||||
+ "Reply in plain text only — you have no tools in this mode.\n"
|
||||
+ "- T1\n- T2"
|
||||
)
|
||||
assert build_deflect_prompt(["T1", "T2"]) == expected_plain
|
||||
# With steering notes: the pre-phase text + the <tuning> section
|
||||
# (phase 15) — still no date anywhere.
|
||||
expected_steered = (
|
||||
_base("LOW")
|
||||
+ "\n"
|
||||
+ build_steering_section(["be concise"])
|
||||
+ "\n"
|
||||
+ "DEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
||||
"your notes come to the question. They are titles only; do not pretend "
|
||||
"they answer it. Use them to propose 2-3 alternative questions.\n"
|
||||
+ "Reply in plain text only — you have no tools in this mode.\n"
|
||||
+ "- T1\n- T2"
|
||||
)
|
||||
assert build_deflect_prompt(["T1", "T2"], notes=["be concise"]) == expected_steered
|
||||
for prompt in (expected_plain, expected_steered):
|
||||
assert "<document" not in prompt
|
||||
assert 'date="' not in prompt
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# The read result — the date is the SECOND line (first line
|
||||
# byte-identical — the mock's _READ_RESULT_PREFIX header contract)
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_read_result_plain_carries_date_second_line(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""At or under the cap: ``Document {source}/{path}:`` (byte-
|
||||
identical FIRST line) + ``date: YYYY-MM-DD`` (the D5 SECOND line)
|
||||
+ the full content — nothing else."""
|
||||
doc = _doc(source="S", path="a.md", title="A", content="A-CONTENT")
|
||||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/a.md"})],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings()))
|
||||
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
||||
content = llm.requests[1][0][3]["content"]
|
||||
lines = content.splitlines()
|
||||
assert lines[0] == "Document S/a.md:" # byte-identical first line
|
||||
assert lines[1] == f"date: {DATE}" # the D5 second line
|
||||
assert lines[2:] == ["A-CONTENT"]
|
||||
|
||||
|
||||
def test_read_result_truncated_carries_date_then_marker_and_notice(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Over the cap: the SAME two header lines, then the first ``cap``
|
||||
chars, the shared marker, and the pinned notice — the truncation
|
||||
contract (phase 95) is unchanged by the date line."""
|
||||
cap = 10
|
||||
content_body = "x" * 25
|
||||
doc = _doc(source="S", path="big.md", title="Big", content=content_body)
|
||||
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
||||
holder = AgentHolder()
|
||||
llm = ScriptedLLM(
|
||||
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/big.md"})],
|
||||
[StreamPiece("content", "ans")],
|
||||
)
|
||||
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
||||
expected = (
|
||||
"Document S/big.md:\n"
|
||||
f"date: {DATE}\n"
|
||||
+ content_body[:cap]
|
||||
+ "\n"
|
||||
+ TRUNCATION_MARKER
|
||||
+ "\n"
|
||||
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content_body))
|
||||
)
|
||||
assert llm.requests[1][0][3]["content"] == expected
|
||||
# The truncation still records on the holder (the ToolResultPiece
|
||||
# signal) — the date line adds nothing to the counters.
|
||||
assert holder.read_truncations == [("S/big.md", cap, len(content_body))]
|
||||
assert holder.tool_calls == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# The ls FILE line — the appended " | date: YYYY-MM-DD" field
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ls_file_line_ends_with_appended_date() -> None:
|
||||
"""The ``ls`` FILE line is ``source: X | path: Y | title: Z`` with
|
||||
the phase-106 D5 field APPENDED at the END (never inserted before
|
||||
``title`` — the mock's non-greedy ``path`` capture would swallow an
|
||||
inserted field)."""
|
||||
rows = [
|
||||
("a.md", "A", "2019-06-15"),
|
||||
("b.md", "B", "2024-06-15"),
|
||||
]
|
||||
subfolders, files, total = agent.group_folder_listing("S", "", rows, {})
|
||||
rendered = agent.render_folder_listing("S", subfolders, files, total)
|
||||
assert total == 2
|
||||
lines = rendered.splitlines()
|
||||
assert lines[0] == "S — 2 documents, 0 folders:"
|
||||
# The file lines end with the appended date field — per-row dates.
|
||||
assert lines[2] == "source: S | path: a.md | title: A | date: 2019-06-15"
|
||||
assert lines[3] == "source: S | path: b.md | title: B | date: 2024-06-15"
|
||||
# The date field is the LAST field (appended, never inserted before
|
||||
# ``title`` — where the mock's non-greedy path capture would
|
||||
# swallow it).
|
||||
for line in (lines[2], lines[3]):
|
||||
assert line.index("date:") > line.index("title:")
|
||||
assert line.rsplit(" | ", 1)[-1].startswith("date: ") # date is final
|
||||
|
||||
|
||||
def test_ls_folder_and_source_lines_carry_no_date() -> None:
|
||||
"""Only FILE lines are documents: the folder header, the indented
|
||||
subfolder lines, and the top-level source lines carry NO date."""
|
||||
rows = [
|
||||
("a/x.md", "X", "2019-06-15"),
|
||||
("a/y.md", "Y", "2020-01-02"),
|
||||
("top.md", "Top", "2024-06-15"),
|
||||
]
|
||||
subfolders, files, total = agent.group_folder_listing("S", "", rows, {"a": "A stuff."})
|
||||
rendered = agent.render_folder_listing("S", subfolders, files, total)
|
||||
lines = rendered.splitlines()
|
||||
assert lines[0] == "S — 1 documents, 1 folders:" # header: no date
|
||||
assert lines[2] == " a/ — 2 documents: A stuff." # subfolder: no date
|
||||
# Only the DIRECT root file lists (a/ is a subfolder line — the
|
||||
# drill-down is one level per call), and it alone carries a date.
|
||||
file_lines = [line for line in lines if line.startswith("source: ")]
|
||||
assert file_lines == [
|
||||
"source: S | path: top.md | title: Top | date: 2024-06-15"
|
||||
]
|
||||
# The top level (source lines) is byte-identical to the pre-phase
|
||||
# template — no date on source lines.
|
||||
assert agent.render_ls_top([("S", 3, "Source stuff.")]) == (
|
||||
"1 sources:\n\nS — 3 documents\n Source stuff."
|
||||
)
|
||||
assert "date" not in agent.render_ls_top([("S", 3, None)])
|
||||
|
||||
|
||||
def test_ls_cap_note_unchanged_past_fifty() -> None:
|
||||
"""The 50-line cap note is UNCHANGED by the date field: 51 files →
|
||||
50 lines (each with its appended date) + the exact pre-phase note;
|
||||
the line COUNT is unaffected by the wider lines."""
|
||||
rows = [(f"f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(51)]
|
||||
subfolders, files, total = agent.group_folder_listing("S", "", rows, {})
|
||||
rendered = agent.render_folder_listing("S", subfolders, files, total)
|
||||
lines = rendered.splitlines()
|
||||
assert total == 51 and len(lines) == 1 + 1 + 50 + 1 # header, blank, 50, note
|
||||
assert lines[-1] == (
|
||||
"…and 1 more documents in this folder — use grep (pattern) to "
|
||||
"find a specific one."
|
||||
)
|
||||
assert lines[1] == "" # the blank after the header is untouched
|
||||
assert all(
|
||||
line.endswith(" | date: 2024-06-15")
|
||||
for line in lines[2:52]
|
||||
)
|
||||
@@ -315,6 +315,7 @@ def _lexical_row(is_summary: bool, doc_path: str) -> object:
|
||||
doc_content=doc.content,
|
||||
content_hash=doc.content_hash,
|
||||
indexed_at=None,
|
||||
created_at=None,
|
||||
is_summary=is_summary,
|
||||
rank=0.33,
|
||||
)
|
||||
@@ -427,6 +428,7 @@ def _name_hit_lateral_row(doc: Document, is_summary: bool = False) -> SimpleName
|
||||
doc_content=doc.content,
|
||||
content_hash=doc.content_hash,
|
||||
indexed_at=None,
|
||||
created_at=None,
|
||||
chunk_id=uuid.uuid4(),
|
||||
position=-1 if is_summary else 0,
|
||||
content="summary chunk" if is_summary else "content chunk",
|
||||
@@ -526,7 +528,7 @@ def test_lexical_candidates_name_hits_lead_and_dedupe_with_fts() -> None:
|
||||
chunk_id=q38_chunk, position=1, content="c", doc_id=q38.id,
|
||||
source=q38.source, path=q38.path, full_path=q38.full_path,
|
||||
title=q38.title, doc_content=q38.content, content_hash=q38.content_hash,
|
||||
indexed_at=None, is_summary=False, rank=0.1,
|
||||
indexed_at=None, created_at=None, is_summary=False, rank=0.1,
|
||||
),
|
||||
# an FTS hit on a different chunk of the OTHER doc (kept)
|
||||
_lexical_row(False, "quadlets/qwen38-other.container"),
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Unit: the D6 recency boost on the fused score (phase 106, task 07).
|
||||
|
||||
Pure-function pins for
|
||||
:func:`app.rag.retriever.apply_recency_boost` — the decay magnitude
|
||||
(zero age → full weight, one half-life → ``weight/e``, ten half-lives
|
||||
→ negligible), the future-date clamp, the ``weight=0`` kill switch
|
||||
(byte-identical scores AND order), the tie-breaks (a raw-score tie
|
||||
breaks toward the newer document; the ``(path, position)`` key still
|
||||
applies when scores AND cosines AND ages are equal), and the
|
||||
no-mutation contract (the ``fuse`` convention). Fake rows, no DB —
|
||||
the real-Postgres fine-line battery (the owner's scenario) lives in
|
||||
``tests/integration/test_recency_boost.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Document
|
||||
from app.rag.retriever import RetrievedChunk, apply_recency_boost
|
||||
|
||||
#: A fixed "now" — the pins must not depend on the wall clock.
|
||||
NOW = datetime(2026, 9, 13, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _doc(path: str, created_at: datetime, source: str = "Homelab") -> Document:
|
||||
return Document(
|
||||
id=uuid.uuid4(),
|
||||
source=source,
|
||||
path=path,
|
||||
full_path=f"/tmp/{path}",
|
||||
title=path,
|
||||
content="x",
|
||||
content_hash="0" * 64,
|
||||
indexed_at=created_at,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
|
||||
def _chunk(
|
||||
doc: Document, score: float, position: int = 0, cosine: float = 0.9
|
||||
) -> RetrievedChunk:
|
||||
return RetrievedChunk(
|
||||
chunk_id=uuid.uuid4(),
|
||||
position=position,
|
||||
content="x",
|
||||
score=score,
|
||||
document=doc,
|
||||
cosine=cosine,
|
||||
)
|
||||
|
||||
|
||||
def test_zero_age_gets_the_full_weight_exactly() -> None:
|
||||
"""Age 0 → ``+weight`` with no float drift (``exp(0) == 1.0``)."""
|
||||
c = _chunk(_doc("a.md", NOW), 0.032787)
|
||||
out = apply_recency_boost([c], now=NOW, weight=0.0007, half_life_days=365)
|
||||
assert out[0].score == 0.032787 + 0.0007
|
||||
|
||||
|
||||
def test_one_half_life_of_age_decays_to_weight_over_e() -> None:
|
||||
"""Age = half-life → ``+weight·e⁻¹`` (±1e-9)."""
|
||||
c = _chunk(_doc("a.md", NOW - timedelta(days=365)), 0.032787)
|
||||
out = apply_recency_boost([c], now=NOW, weight=0.0007, half_life_days=365)
|
||||
assert out[0].score == pytest.approx(
|
||||
0.032787 + 0.0007 * math.exp(-1.0), abs=1e-9
|
||||
)
|
||||
|
||||
|
||||
def test_ten_half_lives_of_age_is_negligible() -> None:
|
||||
"""Age 10× the half-life → ``+weight·e⁻¹⁰`` < ``weight·1e-3`` — the
|
||||
boost has faded to nothing (recency is an age signal, not a binary)."""
|
||||
c = _chunk(_doc("a.md", NOW - timedelta(days=3650)), 0.032787)
|
||||
out = apply_recency_boost([c], now=NOW, weight=0.0007, half_life_days=365)
|
||||
assert out[0].score - 0.032787 < 0.0007 * 1e-3
|
||||
|
||||
|
||||
def test_future_created_at_clamps_to_zero_age() -> None:
|
||||
"""A future ``created_at`` clamps to age 0 — the full weight (the D3
|
||||
today-folding consistency: a future-sourced doc reads as brand-new)."""
|
||||
c = _chunk(_doc("a.md", NOW + timedelta(days=30)), 0.032787)
|
||||
out = apply_recency_boost([c], now=NOW, weight=0.0007, half_life_days=365)
|
||||
assert out[0].score == 0.032787 + 0.0007
|
||||
|
||||
|
||||
def test_weight_zero_is_byte_identical_scores_and_order() -> None:
|
||||
"""The kill switch: ``weight=0`` leaves every score untouched and the
|
||||
order byte-identical for an already-fused (already 4-key-sorted)
|
||||
input."""
|
||||
a0 = _chunk(_doc("a.md", NOW - timedelta(days=10)), 0.03, cosine=0.9, position=0)
|
||||
b0 = _chunk(_doc("b.md", NOW - timedelta(days=20)), 0.03, cosine=0.8, position=0)
|
||||
a1 = _chunk(_doc("a.md", NOW - timedelta(days=10)), 0.02, cosine=0.95, position=0)
|
||||
c1 = _chunk(_doc("c.md", NOW - timedelta(days=30)), 0.02, cosine=0.5, position=1)
|
||||
chunks = [a0, b0, a1, c1] # already sorted by the 4-key order
|
||||
out = apply_recency_boost(chunks, now=NOW, weight=0.0, half_life_days=365)
|
||||
assert [
|
||||
(rc.score, rc.cosine, rc.document.path, rc.position) for rc in out
|
||||
] == [
|
||||
(rc.score, rc.cosine, rc.document.path, rc.position) for rc in chunks
|
||||
]
|
||||
|
||||
|
||||
def test_raw_score_tie_breaks_toward_the_newer_document() -> None:
|
||||
"""An EXACT raw-score + cosine tie: the boost moves only the newer
|
||||
document (the older one's boost has decayed to ~0), so the newer
|
||||
rank rises above it — and with ``weight=0`` the pre-phase
|
||||
(path-ordered) ranking stands."""
|
||||
old = _chunk(_doc("a-old.md", NOW - timedelta(days=2500)), 0.016129)
|
||||
new = _chunk(_doc("b-new.md", NOW), 0.016129)
|
||||
out = apply_recency_boost([old, new], now=NOW, weight=0.0007, half_life_days=365)
|
||||
assert [rc.document.path for rc in out] == ["b-new.md", "a-old.md"]
|
||||
out_off = apply_recency_boost([old, new], now=NOW, weight=0.0, half_life_days=365)
|
||||
assert [rc.document.path for rc in out_off] == ["a-old.md", "b-new.md"]
|
||||
|
||||
|
||||
def test_path_position_tiebreak_when_scores_cosines_and_ages_equal() -> None:
|
||||
"""When the boosted scores AND cosines are equal (same age → same
|
||||
boost), the EXISTING ``(path, position)`` tie-break still decides —
|
||||
first by path, then by position within one path."""
|
||||
a = _chunk(_doc("a.md", NOW - timedelta(days=100)), 0.02, cosine=0.5, position=1)
|
||||
b = _chunk(_doc("b.md", NOW - timedelta(days=100)), 0.02, cosine=0.5, position=0)
|
||||
out = apply_recency_boost([b, a], now=NOW, weight=0.001, half_life_days=365)
|
||||
assert [rc.document.path for rc in out] == ["a.md", "b.md"]
|
||||
# Same path, different positions (same doc, same age, same score):
|
||||
p1 = _chunk(_doc("a.md", NOW - timedelta(days=100)), 0.02, cosine=0.5, position=1)
|
||||
p0 = _chunk(_doc("a.md", NOW - timedelta(days=100)), 0.02, cosine=0.5, position=0)
|
||||
out2 = apply_recency_boost([p1, p0], now=NOW, weight=0.001, half_life_days=365)
|
||||
assert [rc.position for rc in out2] == [0, 1]
|
||||
|
||||
|
||||
def test_defaults_come_from_settings_when_omitted() -> None:
|
||||
"""Omitted *weight* / *half_life_days* fall back to the settings
|
||||
(``recency_boost`` / ``recency_half_life_days``) — the explicit
|
||||
settings values must reproduce the default call exactly."""
|
||||
chunks = [
|
||||
_chunk(_doc("a.md", NOW - timedelta(days=30)), 0.02),
|
||||
_chunk(_doc("b.md", NOW - timedelta(days=700)), 0.02),
|
||||
]
|
||||
s = get_settings()
|
||||
out_default = apply_recency_boost(chunks, now=NOW)
|
||||
out_explicit = apply_recency_boost(
|
||||
chunks,
|
||||
now=NOW,
|
||||
weight=s.recency_boost,
|
||||
half_life_days=s.recency_half_life_days,
|
||||
)
|
||||
assert [rc.score for rc in out_default] == [rc.score for rc in out_explicit]
|
||||
|
||||
|
||||
def test_inputs_are_never_mutated() -> None:
|
||||
"""The ``fuse`` convention: the input list and its scores are
|
||||
untouched — every returned chunk is a fresh ``replace()`` copy."""
|
||||
chunks = [
|
||||
_chunk(_doc("a.md", NOW - timedelta(days=30)), 0.02),
|
||||
_chunk(_doc("b.md", NOW), 0.03),
|
||||
]
|
||||
original_scores = [rc.score for rc in chunks]
|
||||
original_order = [rc.chunk_id for rc in chunks]
|
||||
out = apply_recency_boost(chunks, now=NOW, weight=0.001, half_life_days=365)
|
||||
assert [rc.score for rc in chunks] == original_scores
|
||||
assert [rc.chunk_id for rc in chunks] == original_order
|
||||
assert out is not chunks
|
||||
assert all(o is not i for o, i in zip(out, chunks, strict=True))
|
||||
|
||||
|
||||
def test_non_positive_half_life_fails_loud() -> None:
|
||||
"""A ``half_life_days <= 0`` argument would divide the exponent by
|
||||
zero — the settings validator guards startup, the function guards
|
||||
direct calls (the ``fuse`` ``k <= 0`` pattern)."""
|
||||
c = _chunk(_doc("a.md", NOW), 0.02)
|
||||
with pytest.raises(ValueError, match="half_life_days must be > 0"):
|
||||
apply_recency_boost([c], now=NOW, weight=0.001, half_life_days=0)
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Unit: the phase-106 date COLUMNS in the UI (task 08, D8) + the
|
||||
viewer's Created badge (display only — the admin date EDITOR is
|
||||
task 09).
|
||||
|
||||
The owner asked for the date everywhere it is read: "For files,
|
||||
include a date/timestamp before the 'indexed' column in the UI";
|
||||
"I would also like to see a last updated dates/timestamps on folders
|
||||
before the description column but after the documents column in the
|
||||
UI"; "The UI must also show a date for every document at the top of
|
||||
that document when the user clicks it." The values ride the task-05
|
||||
APIs (``created_at`` on the tree's file nodes + ``/api/docs`` +
|
||||
``/api/documents/content``; the derived subtree-max ``updated_at`` on
|
||||
tree folders/sources, D9 — ``null`` for a 0-document source).
|
||||
|
||||
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_source_ignore_paths.py`` /
|
||||
``test_kb_tree_ui.py`` house pattern), this module pins the
|
||||
source-level contract a silent regression would break:
|
||||
|
||||
* ``frontend/index.html`` — the header cell ORDER, pinned as the
|
||||
exact ``<th>`` sequence in the RAG view:
|
||||
``Source | Path | Title | Chunks | Created | Indexed`` (Created
|
||||
BETWEEN Chunks and Indexed — D8 verbatim) and
|
||||
``Folder | Documents | Updated | Description`` (Updated BETWEEN
|
||||
Documents and Description — D8 verbatim);
|
||||
* ``frontend/assets/sources.js`` — ``makeRow``'s cell order
|
||||
(``created_at`` BEFORE ``indexed_at``; the Created cell built
|
||||
explicitly — ``textContent`` = ``fmtDate(d.created_at)`` AND
|
||||
``title`` = the full ISO value, the path-cell hover idiom the E2E
|
||||
asserts on — never innerHTML), the file-row object fed from the
|
||||
tree's file nodes carries ``created_at``, and the ``updatedTd``
|
||||
null → ``"–"`` branch (the statLast idiom) is present in BOTH row
|
||||
builders (makeSourceRow + makeFolderRow);
|
||||
* ``frontend/assets/document.js`` — the ONE shared core's
|
||||
``.doc-meta`` badge row: the ``doc-created`` badge BEFORE
|
||||
``doc-indexed`` (modal + ``/document.html`` through the same
|
||||
core — no per-surface copy), the ``Created `` label +
|
||||
``fmtDate(doc.created_at)`` template, and the full ISO timestamp on
|
||||
the badge's ``title`` (the ``titleEl`` ellipsis-precision idiom);
|
||||
* ``frontend/assets/styles.css`` — the ``.doc-created`` rule with
|
||||
the phase-106 D8 provenance comment + the recorded WCAG pair
|
||||
(5.1:1 — the same family as the Indexed badge), and the
|
||||
Description one-line clamp moved to ``td:nth-child(4)`` (the
|
||||
Updated column took 3rd — no hard-coded column count left behind).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
SOURCES_JS = FRONTEND / "assets" / "sources.js"
|
||||
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
assert path.is_file(), f"missing frontend file: {path}"
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return _text(SOURCES_JS)
|
||||
|
||||
|
||||
def _doc_js() -> str:
|
||||
return _text(DOCUMENT_JS)
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return _text(STYLES_CSS)
|
||||
|
||||
|
||||
def _rag_view(html: str) -> str:
|
||||
"""The RAG view section of the shell (view-scoped — the shell
|
||||
carries many views, so whole-file matches hit the wrong one)."""
|
||||
i = html.find('<section class="view" id="view-rag"')
|
||||
assert i != -1, "the RAG view section must be in the shell"
|
||||
j = html.find('<section class="view" id="view-git-sources"', i)
|
||||
assert j != -1, "the Sources view section must follow the RAG view"
|
||||
return html[i:j]
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a (possibly async, possibly nested) function via
|
||||
balanced-brace counting (the test_kb_tree_ui.py helper). The brace
|
||||
count starts AFTER the parameter list — a destructured parameter
|
||||
(renderDocument's target object) may carry braces of its own."""
|
||||
for prefix in ("async function ", "function "):
|
||||
start = js.find(f"{prefix}{name}(")
|
||||
if start != -1:
|
||||
# Skip the parameter list (balanced parens).
|
||||
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
|
||||
# Then brace-count the body.
|
||||
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")
|
||||
|
||||
|
||||
def _header_columns(html: str, table_id: str) -> list[str]:
|
||||
"""The table's ``<th>`` cells, IN DOCUMENT ORDER (the ORDER is the
|
||||
pin — D8's verbatim positions)."""
|
||||
i = html.find(f'id="{table_id}"')
|
||||
assert i != -1, f"#{table_id} must be in the RAG view"
|
||||
head = re.search(r"<thead>(.*?)</thead>", html[i :], re.S)
|
||||
assert head, f"#{table_id} must keep a static thead"
|
||||
return [m.group(1) for m in re.finditer(r"<th scope=\"col\">([^<]*)</th>", head.group(1))]
|
||||
|
||||
|
||||
def _css_rule(css: str, selector: str) -> str:
|
||||
"""The declarations of a simple rule (comments stripped first — a
|
||||
house comment may legally carry braces)."""
|
||||
clean = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||
start = clean.find(f"{selector} {{")
|
||||
assert start != -1, f"missing rule {selector} in styles.css"
|
||||
brace = clean.find("{", start)
|
||||
depth = 0
|
||||
for i in range(brace, len(clean)):
|
||||
if clean[i] == "{":
|
||||
depth += 1
|
||||
elif clean[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return clean[start : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {selector}")
|
||||
|
||||
|
||||
# ---------- index.html: the two header rows (ORDER pinned) ----------
|
||||
|
||||
|
||||
def test_file_table_header_order_is_source_path_title_chunks_created_indexed() -> None:
|
||||
"""D8 verbatim: the file table's ``<th>`` sequence is EXACTLY
|
||||
Source | Path | Title | Chunks | Created | Indexed — Created
|
||||
BETWEEN Chunks and Indexed (a set-membership pin would let a
|
||||
regression move the column; the sequence pins the position)."""
|
||||
view = _rag_view(_text(SHELL_HTML))
|
||||
assert _header_columns(view, "docs-table") == [
|
||||
"Source",
|
||||
"Path",
|
||||
"Title",
|
||||
"Chunks",
|
||||
"Created",
|
||||
"Indexed",
|
||||
], "the file table's column order (Created BEFORE Indexed, D8)"
|
||||
|
||||
|
||||
def test_folder_table_header_order_is_folder_documents_updated_description() -> None:
|
||||
"""D8 verbatim: the folder/source table's ``<th>`` sequence is
|
||||
EXACTLY Folder | Documents | Updated | Description — Updated
|
||||
BETWEEN Documents and Description."""
|
||||
view = _rag_view(_text(SHELL_HTML))
|
||||
assert _header_columns(view, "folders-table") == [
|
||||
"Folder",
|
||||
"Documents",
|
||||
"Updated",
|
||||
"Description",
|
||||
], "the folder table's column order (Updated BETWEEN the two, D8)"
|
||||
|
||||
|
||||
def test_inserted_headers_carry_the_phase_106_comment() -> None:
|
||||
"""House comment style: each inserted <th> is annotated with a
|
||||
phase-106 provenance comment (D8 for the position; D9 for the
|
||||
derived Updated value) — a bare <th> with no comment is the
|
||||
regression this guards against."""
|
||||
view = _rag_view(_text(SHELL_HTML))
|
||||
for col in ("Created", "Updated"):
|
||||
i = view.find(f"<th scope=\"col\">{col}</th>")
|
||||
assert i != -1, f"the {col} header must be in the RAG view"
|
||||
comment = view.rfind("<!--", 0, i)
|
||||
end = view.find("-->", comment)
|
||||
assert comment > -1 and "phase 106" in view[comment:end].lower(), (
|
||||
f"a phase-106 comment must sit above the {col} header"
|
||||
)
|
||||
assert "D8" in view[comment:end], f"the {col} comment cites D8"
|
||||
|
||||
|
||||
# ---------- sources.js: makeRow's Created cell (before Indexed) ----------
|
||||
|
||||
|
||||
def test_make_row_cell_order_is_created_before_indexed() -> None:
|
||||
"""makeRow (the file table's row builder): the cell order is
|
||||
[title, chunks, created, indexed] — the Created cell lands BEFORE
|
||||
the Indexed one (D8 verbatim). Both date cells are built
|
||||
EXPLICITLY (the plain-td loop can't carry per-cell titles): the
|
||||
Created cell has ``textContent = fmtDate(d.created_at)`` AND
|
||||
``title = d.created_at`` — the full ISO value on hover (the
|
||||
path-cell idiom; the E2E asserts on the locale-stable title, not
|
||||
on the toLocaleString output)."""
|
||||
make = _fn(_js(), "makeRow")
|
||||
# The loop keeps the two plain cells (title, chunks).
|
||||
assert "for (const value of [d.title, String(d.chunks)]) {" in make
|
||||
# The Created cell: explicit, locale date + the ISO title.
|
||||
created_i = make.find("const createdTd = document.createElement(\"td\");")
|
||||
assert created_i != -1, "the Created cell is built explicitly"
|
||||
assert "createdTd.textContent = fmtDate(d.created_at);" in make
|
||||
assert "createdTd.title = d.created_at;" in make, (
|
||||
"the full ISO value on hover (the E2E's locale-stable pin)"
|
||||
)
|
||||
# The value-list ORDER: created_at before indexed_at (text AND
|
||||
# append — both orderings pinned, a regression can't swap them).
|
||||
created_fmt = make.find("fmtDate(d.created_at)")
|
||||
indexed_fmt = make.find("fmtDate(d.indexed_at)")
|
||||
assert -1 < created_fmt < indexed_fmt, "created_at BEFORE indexed_at (D8)"
|
||||
assert (
|
||||
make.find("tr.appendChild(createdTd)")
|
||||
< make.find("tr.appendChild(indexedTd)")
|
||||
), "the Created cell appends BEFORE the Indexed one"
|
||||
# textContent only — the XSS contract (never innerHTML with
|
||||
# document-derived data).
|
||||
assert "createdTd.innerHTML" not in make and "indexedTd.innerHTML" not in make
|
||||
|
||||
|
||||
def test_tree_file_row_object_carries_created_at() -> None:
|
||||
"""renderLevel's file loop feeds makeRow the FLAT row shape —
|
||||
the tree's file node carries created_at (task 05's tree shape),
|
||||
and the row object restores it BEFORE indexed_at (the order the
|
||||
reader gets matches makeRow's cell order)."""
|
||||
js = _js()
|
||||
render = js[js.find("function renderLevel(") : js.find("function renderEmpty(")]
|
||||
assert "makeRow({" in render, "renderLevel still feeds makeRow"
|
||||
created_i = render.find("created_at: f.created_at,")
|
||||
indexed_i = render.find("indexed_at: f.indexed_at,")
|
||||
assert -1 < created_i < indexed_i, (
|
||||
"the row object carries created_at (before indexed_at — task 05's tree shape)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- sources.js: the Updated cell in BOTH row builders ----------
|
||||
|
||||
UPDATED_TAIL = "makeDescCell" # the Description cell follows Updated
|
||||
|
||||
|
||||
def test_source_row_has_updated_cell_between_count_and_description() -> None:
|
||||
"""makeSourceRow (top level — the rows ARE the sources): ONE new
|
||||
td BETWEEN the count td and the Description cell — the
|
||||
``updatedTd`` null → ``"–"`` branch (the statLast idiom: D9's
|
||||
``None`` for a 0-document source), the locale date otherwise
|
||||
(fmtDate), and the ISO value on the cell's title (hover
|
||||
precision — the makeRow path-cell idiom)."""
|
||||
make = _fn(_js(), "makeSourceRow")
|
||||
count_i = make.find("countTd.textContent = String(s.documents);")
|
||||
tail_i = make.find(UPDATED_TAIL, count_i)
|
||||
updated_i = make.find("const updatedTd = document.createElement(\"td\");", count_i)
|
||||
assert -1 < count_i < updated_i < tail_i, (
|
||||
"the Updated cell sits BETWEEN the count and the Description (D8)"
|
||||
)
|
||||
assert 'updatedTd.textContent = s.updated_at ? fmtDate(s.updated_at) : "–";' in make, (
|
||||
"the null → '–' branch (the statLast idiom — D9's None for a 0-document source)"
|
||||
)
|
||||
assert "if (s.updated_at) updatedTd.title = s.updated_at;" in make, (
|
||||
"the ISO value on hover (only when there is one)"
|
||||
)
|
||||
append_i = make.find("tr.appendChild(updatedTd)", count_i)
|
||||
desc_i = make.find(UPDATED_TAIL, append_i)
|
||||
assert -1 < append_i < desc_i, "appended before the Description cell"
|
||||
assert "updatedTd.innerHTML" not in make, "textContent only (XSS contract)"
|
||||
|
||||
|
||||
def test_folder_row_has_updated_cell_between_count_and_description() -> None:
|
||||
"""makeFolderRow (a level's subfolders): the SAME one td BETWEEN
|
||||
the count td and the Description cell — the ``updatedTd`` null →
|
||||
``"–"`` branch present in BOTH row builders (the phase pins it in
|
||||
each — a source row and a folder row are separate code paths)."""
|
||||
make = _fn(_js(), "makeFolderRow")
|
||||
count_i = make.find("countTd.textContent = String(f.documents);")
|
||||
tail_i = make.find(UPDATED_TAIL, count_i)
|
||||
updated_i = make.find("const updatedTd = document.createElement(\"td\");", count_i)
|
||||
assert -1 < count_i < updated_i < tail_i, (
|
||||
"the Updated cell sits BETWEEN the count and the Description (D8)"
|
||||
)
|
||||
assert 'updatedTd.textContent = f.updated_at ? fmtDate(f.updated_at) : "–";' in make, (
|
||||
"the null → '–' branch in the FOLDER builder too (both builders pinned)"
|
||||
)
|
||||
assert "if (f.updated_at) updatedTd.title = f.updated_at;" in make, (
|
||||
"the ISO value on hover (only when there is one)"
|
||||
)
|
||||
append_i = make.find("tr.appendChild(updatedTd)", count_i)
|
||||
desc_i = make.find(UPDATED_TAIL, append_i)
|
||||
assert -1 < append_i < desc_i, "appended before the Description cell"
|
||||
assert "updatedTd.innerHTML" not in make, "textContent only (XSS contract)"
|
||||
|
||||
|
||||
def test_module_docstring_carries_the_phase_106_contract() -> None:
|
||||
"""The house module-docstring convention: the phase-106 section
|
||||
records the Created column (before Indexed), the Updated column
|
||||
(between Documents and Description), and the untouched stat
|
||||
cards (the indexed_at 'last indexed' semantics stay)."""
|
||||
doc = _js()[: _js().find("import { fetchIsAdmin }")]
|
||||
for frag in (
|
||||
"Phase 106 (task 08, D8)",
|
||||
"Created column BEFORE Indexed",
|
||||
"BETWEEN\n * Documents and Description",
|
||||
"D9",
|
||||
"UNTOUCHED",
|
||||
):
|
||||
assert frag in doc, f"the module docstring lost: {frag!r}"
|
||||
|
||||
|
||||
# ---------- document.js: the shared core's Created badge ----------
|
||||
|
||||
|
||||
def test_meta_row_badge_order_is_created_before_indexed() -> None:
|
||||
"""renderDocument (the ONE shared core — the modal AND
|
||||
/document.html render through it, no per-surface copy): the
|
||||
.doc-meta badge row carries the ``doc-created`` badge BEFORE the
|
||||
``doc-indexed`` one (D8 verbatim — the date at the top of a
|
||||
clicked document)."""
|
||||
render = _fn(_doc_js(), "renderDocument")
|
||||
block = render[render.find("metaEl.replaceChildren(") :]
|
||||
created_i = block.find('metaBadge("doc-created"')
|
||||
indexed_i = block.find('metaBadge("doc-indexed"')
|
||||
chunks_i = block.find('metaBadge("doc-chunks"')
|
||||
assert -1 < created_i < indexed_i < chunks_i, (
|
||||
"the Created badge BEFORE Indexed, both before Chunks (D8)"
|
||||
)
|
||||
# The label + the locale-date template (the Indexed idiom).
|
||||
assert "Created ${fmtDate(doc.created_at)}" in block, (
|
||||
"the 'Created <date>' label + fmtDate(doc.created_at) template"
|
||||
)
|
||||
assert "Indexed ${fmtDate(doc.indexed_at)}" in block, (
|
||||
"the Indexed badge is unchanged (the idiom the Created one copies)"
|
||||
)
|
||||
|
||||
|
||||
def test_created_badge_carries_the_full_iso_title() -> None:
|
||||
"""The badge's ``title`` attribute carries the FULL ISO timestamp
|
||||
(the titleEl ellipsis-precision idiom — the meta row may clip,
|
||||
the exact value stays reachable): the created badge passes
|
||||
``doc.created_at`` as metaBadge's title argument, and metaBadge
|
||||
sets it via setAttribute (only when provided — the other badges
|
||||
keep the two-argument shape, byte-identical)."""
|
||||
js = _doc_js()
|
||||
render = _fn(js, "renderDocument")
|
||||
call_start = render.find("metaEl.replaceChildren(")
|
||||
# The call ends at the first `);` AFTER the last badge (the
|
||||
# doc-chunks one) — the comment above the created badge may carry
|
||||
# parentheses, so slicing from the call top would be brittle.
|
||||
chunks_i = render.find('metaBadge("doc-chunks"', call_start)
|
||||
block = render[call_start : render.find(");", chunks_i) + 2]
|
||||
created_i = block.find('metaBadge("doc-created"')
|
||||
created_call = block[created_i : block.find("),", created_i) + 1]
|
||||
assert created_call.endswith(", doc.created_at)"), (
|
||||
"the created badge passes doc.created_at as its title"
|
||||
)
|
||||
badge = _fn(js, "metaBadge")
|
||||
assert "function metaBadge(cls, text, title)" in badge, (
|
||||
"metaBadge's optional title parameter"
|
||||
)
|
||||
assert 'el.setAttribute("title", title)' in badge
|
||||
assert "title !== undefined" in badge, (
|
||||
"the guard keeps the other badges' two-argument shape"
|
||||
)
|
||||
# No other badge passes a title (the pre-phase badges keep the
|
||||
# two-argument shape — exactly one comma in the call).
|
||||
for cls_ in ("doc-source-badge", "format-badge", "doc-indexed", "doc-chunks"):
|
||||
i = block.find(f'metaBadge("{cls_}"')
|
||||
assert i != -1, f"the {cls_} badge must stay in the meta row"
|
||||
call = block[i : block.find("),", i) + 1]
|
||||
assert call.count(",") == 1, f"{cls_} keeps the two-argument shape"
|
||||
|
||||
|
||||
def test_core_docstring_and_comment_cite_phase_106_d8() -> None:
|
||||
"""House comment style: the module docstring + the badge-row
|
||||
comment record the phase-106 (task 08, D8) created-before-indexed
|
||||
position and the ONE-shared-core guarantee (modal + page)."""
|
||||
js = _doc_js()
|
||||
doc = js.split("*/", 1)[0]
|
||||
assert "Phase 106 (task 08, D8)" in doc
|
||||
assert "BEFORE the Indexed one" in doc
|
||||
assert "task 05" in doc, "the created_at payload provenance (task 05)"
|
||||
render = _fn(js, "renderDocument")
|
||||
assert "Phase 106 (task 08, D8)" in render, "the inline comment at the insertion site"
|
||||
|
||||
|
||||
# ---------- styles.css: the .doc-created rule + the clamp move ----------
|
||||
|
||||
|
||||
def test_doc_created_rule_present_with_provenance_and_contrast() -> None:
|
||||
"""styles.css carries the ``.doc-created`` rule (the doc-indexed
|
||||
badge family — the meta row's ink-soft text) with the phase-106
|
||||
D8 provenance comment + the recorded WCAG pair (5.1:1 on
|
||||
--surface — the same pair the Indexed badge inherits via
|
||||
.doc-meta / .doc-modal-meta)."""
|
||||
css = _css()
|
||||
rule = _css_rule(css, ".doc-created")
|
||||
assert "color: var(--ink-soft)" in rule, "the meta-row family (the Indexed look)"
|
||||
# The provenance comment sits DIRECTLY above the rule (the
|
||||
# house style: phase + decision + the verified contrast pair).
|
||||
rule_i = css.find(".doc-created {")
|
||||
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 rule"
|
||||
)
|
||||
header = css[comment_start:comment_end]
|
||||
assert "phase 106" in header.lower() and "D8" in header, (
|
||||
"the provenance comment cites phase 106 + D8"
|
||||
)
|
||||
assert "5.1:1" in header, "the verified contrast pair is recorded (house style)"
|
||||
# The rule sits next to the meta-row badge family (after
|
||||
# .doc-chunks, before the .doc-shell block).
|
||||
assert (
|
||||
css.find(".doc-chunks {") < rule_i < css.find(".doc-shell {")
|
||||
), "next to the existing meta-row badge rules"
|
||||
|
||||
|
||||
def test_folders_description_clamp_follows_the_moved_cell() -> None:
|
||||
"""The column-count change must not break the table's rules: the
|
||||
Description one-line clamp (phase 99) follows the cell, which
|
||||
moved to the 4th (the Updated column took 3rd) — no stale
|
||||
``td:nth-child(3)`` folders rule may remain, and the new Updated
|
||||
cell's ink pair (the table ink on --surface, 13.8:1) is recorded
|
||||
in the house comment."""
|
||||
css = _css()
|
||||
assert ".kb-folders-table td:nth-child(4) {" in css, (
|
||||
"the Description clamp moved with the cell (4th column)"
|
||||
)
|
||||
assert ".kb-folders-table td:nth-child(3)" not in css, (
|
||||
"no stale 3rd-column folders rule (the Updated cell is there now)"
|
||||
)
|
||||
i = css.find(".kb-folders-table td:nth-child(4)")
|
||||
comment = css.rfind("/*", 0, i)
|
||||
end = css.find("*/", comment)
|
||||
assert comment > -1 and "phase 106" in css[comment:end].lower(), (
|
||||
"a phase-106 comment explains the Updated column + the clamp move"
|
||||
)
|
||||
assert "13.8:1" in css[comment:end], (
|
||||
"the Updated cell's ink pair is recorded (table ink on --surface)"
|
||||
)
|
||||
@@ -7,6 +7,7 @@ prompts, and the ``plan_turn`` wiring (notes → prompt + ``tuning_count``).
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -30,6 +31,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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -107,14 +107,18 @@ def test_gate_runs_after_the_phase36_base_construction() -> None:
|
||||
36 (the anonymous byte-for-byte shape): the base construction
|
||||
(className, the bare h2 label, the .doc-summary-text node, the
|
||||
append) precedes the gate call, and the admin wiring runs ONLY in
|
||||
the gate's success branch (``if (admin) wireSummaryEdit(...)``)."""
|
||||
the gate's success branch (``if (admin) wireSummaryEdit(...)``).
|
||||
The summary gate is searched AFTER the section mount — phase 106
|
||||
(task 09) added the date editor's own ``docAdminReady()`` gate
|
||||
earlier in renderDocument (one gate per admin affordance, both on
|
||||
the same cached whoami promise)."""
|
||||
js = _js()
|
||||
base = js.find('section.className = "doc-summary"')
|
||||
label = js.find('title.textContent = "Summary"')
|
||||
text_node = js.find('body.className = "doc-summary-text"')
|
||||
append = js.find("section.append(title, body)")
|
||||
mount = js.find("contentEl.appendChild(section)")
|
||||
gate = js.find("void docAdminReady().then(")
|
||||
gate = js.find("void docAdminReady().then(", mount)
|
||||
wiring = js.find("if (admin) wireSummaryEdit(section, doc);")
|
||||
assert 0 < base < label < text_node < append < mount < gate < wiring, (
|
||||
"phase-36 base construction first; the admin affordance is a "
|
||||
|
||||
@@ -41,6 +41,7 @@ import asyncio
|
||||
import re
|
||||
import threading
|
||||
from collections.abc import Callable, Iterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -758,6 +759,7 @@ class _GatedImport:
|
||||
progress: Callable[[str, str, int, int], None] | 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.prune_flags.append(prune)
|
||||
if progress is not None:
|
||||
|
||||
Reference in New Issue
Block a user