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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user