phase: 106_document_dates
Build and Push Containers / build-and-push-app (push) Successful in 4m35s
Build and Push Containers / build-and-push-db (push) Successful in 14s

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:
2026-09-13 19:28:05 -04:00
parent cec819743d
commit ee3efb28c9
113 changed files with 8228 additions and 344 deletions
+16 -4
View File
@@ -1,8 +1,11 @@
"""Evaluate hybrid retrieval against the live knowledge base (phase 09).
Embeds each question via aipi, runs the same hybrid search the chat API
uses (cosine top-N + FTS top-N, RRF-fused), and prints the top-5 documents
with their cosine / fts / fused scores plus the honesty-gate verdict:
uses (cosine top-N + FTS top-N, RRF-fused — plus the phase-106 recency
boost, which ``retrieve()`` applies after the fusion), and prints the
top-5 documents with their cosine / fts / fused scores (labelled
``effective`` when the recency boost is on — the post-boost score)
and the document's creation date, plus the honesty-gate verdict:
uv run python -m scripts.eval_retrieval "How did I install gitlab?"
uv run python -m scripts.eval_retrieval --from-file questions.txt
@@ -96,7 +99,9 @@ def main(argv: list[str] | None = None) -> int:
print(
f"eval: threshold={settings.relevance_threshold} "
f"vector_candidates={settings.hybrid_vector_candidates} "
f"lexical_candidates={settings.hybrid_lexical_candidates} rrf_k={settings.rrf_k}"
f"lexical_candidates={settings.hybrid_lexical_candidates} rrf_k={settings.rrf_k} "
f"recency_boost={settings.recency_boost} "
f"recency_half_life_days={settings.recency_half_life_days}"
)
with SessionLocal() as db:
for question, vec in zip(questions, vectors, strict=True):
@@ -116,10 +121,17 @@ def main(argv: list[str] | None = None) -> int:
key = f"{c.document.source}/{c.document.path}"
if key not in best_by_doc:
best_by_doc[key] = c
# Phase 106, D6: the post-recency-boost score is the
# effective score ``retrieve()`` ranked by — labelled
# ``effective`` while the boost is on (``fused`` when the
# kill switch is off), with the document's creation date
# alongside (the boost's input).
score_label = "effective" if settings.recency_boost > 0 else "fused"
for i, c in enumerate(list(best_by_doc.values())[: args.top], start=1):
print(
f" {i}. {c.document.source}/{c.document.path} "
f"cosine={c.cosine:.4f} fts={int(c.fts_hit)} fused={c.score:.5f} "
f"cosine={c.cosine:.4f} fts={int(c.fts_hit)} "
f"{score_label}={c.score:.5f} created={c.document.created_at:%Y-%m-%d} "
f"({c.document.title})"
)
return 0
+99 -2
View File
@@ -11,15 +11,33 @@ used.
This module is the only place the ``git`` CLI is invoked (A11: stdlib
``subprocess`` only, no new packages) — every git command goes through
:func:`run_git`: the clone/pull in :func:`clone_or_pull` and the
:func:`run_git`: the clone/pull in :func:`clone_or_pull`, the per-file
last-commit-date walk in :func:`file_commit_dates` (phase 106), and the
docs-push sequence in :mod:`app.core.docs_push` (phase 59).
Per-file last-commit dates (phase 106, D2/D10) — behavior verified
against scratch repos 2026-09-13:
* a LOCAL-PATH checkout made by :func:`clone_or_pull` keeps FULL
history (``git clone --depth 1 /local/path`` prints "--depth is
ignored in local clones" and does not shallow) →
:func:`file_commit_dates` yields TRUE per-file last-commit dates;
* a URL-TRANSPORT checkout (https/ssh/``file://``) is shallow, and in a
shallow clone git reports the TIP commit as every existing file's
last commit (the shallow boundary is each file's history root) → a
uniform per-repo tip date: no intra-repo distortion, a real
cross-source signal, refreshed on every pull.
"""
from __future__ import annotations
import logging
import subprocess
from datetime import datetime
from pathlib import Path
__all__ = ["GitSyncError", "clone_or_pull", "run_git"]
logger = logging.getLogger(__name__)
__all__ = ["GitSyncError", "clone_or_pull", "file_commit_dates", "run_git"]
class GitSyncError(RuntimeError):
@@ -48,6 +66,85 @@ def clone_or_pull(url: str, dest: Path | str) -> Path:
return dest
def _parse_commit_dates(output: str) -> dict[str, datetime]:
"""Parse ``git log --name-only --format=@@%cI`` output (newest first).
A ``@@<ISO-8601>`` line starts a commit (``%cI`` is ISO-strict, so
the date is always aware — parsed with ``datetime.fromisoformat``);
the following non-empty, non-``@@`` lines are repo-relative paths.
The FIRST sighting of a path wins (the walk is newest-first) — that
is the file's last-commit date. Paths are split on whitespace (like
name-only output), ``\\``-normalized to ``/``, and a leading ``/``
is stripped. Raises ``ValueError`` on a malformed commit date or a
path line before any commit header (the caller fails soft).
"""
dates: dict[str, datetime] = {}
commit: datetime | None = None
for line in output.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("@@"):
commit = datetime.fromisoformat(line[2:])
continue
if commit is None:
raise ValueError(f"path line before any commit header: {line!r}")
for raw_path in line.split():
path = raw_path.replace("\\", "/").lstrip("/")
if path:
dates.setdefault(path, commit)
return dates
def file_commit_dates(dest: Path | str) -> dict[str, datetime]:
"""Per-file last-commit dates for one checkout (phase 106, D2).
ONE ``git log --name-only --format=@@%cI`` walk through
:func:`run_git` (the A11 single-invocation site, one git call per
source per sync) → ``{repo-relative POSIX path: last-commit
datetime}``, newest-first so the first sighting of a path wins.
The checkout behavior is pinned (verified 2026-09-13 — see the
module docstring): a local-path ``clone_or_pull`` checkout keeps
FULL history → TRUE per-file dates; a URL-transport checkout is
shallow → the repo's TIP-commit date for every working-tree file
(D10: uniform within the repo, real across sources).
Fail-soft (pinned): a missing/non-directory checkout, a git failure
(:class:`GitSyncError`), or ANY parse anomaly logs a warning and
returns ``{}`` — the importer falls back to file mtimes; a date
walk must never break a sync.
"""
dest = Path(dest)
if not dest.is_dir():
logger.warning(
"file_commit_dates: %s is not a directory — no git dates "
"(the importer will fall back to file mtimes)",
dest,
)
return {}
try:
output = run_git(["git", "log", "--name-only", "--format=@@%cI"], cwd=dest)
except GitSyncError as exc:
logger.warning(
"file_commit_dates: git log failed for %s: %s — the importer "
"will fall back to file mtimes",
dest,
exc,
)
return {}
try:
return _parse_commit_dates(output)
except ValueError as exc:
logger.warning(
"file_commit_dates: unparseable git log output for %s (%s) — the "
"importer will fall back to file mtimes",
dest,
exc,
)
return {}
def run_git(argv: list[str], cwd: Path) -> str:
"""Run one git command, capturing output; raise GitSyncError on failure.
+45 -22
View File
@@ -33,7 +33,12 @@ with no ignore. Phase 105 extends the same resolution with each row's
``include_hidden`` flag — a second per-root map keyed by the same root
strings (the importer reads it per root); manual ``--source`` dirs and
the legacy fallback have no rows, so they import with the empty map
(hidden paths skipped — A4).
(hidden paths skipped — A4). Phase 106 (D2) extends it a third time
with each GIT row's per-file last-commit dates — a map keyed by the
same root strings, built from ``file_commit_dates`` after the clone;
manual ``--source`` dirs and the legacy fallback have no rows (no
clone), so they import with the empty map and take the importer's
mtime fallback.
Imported formats (PLAN anchor A9, revised; phase 56): the A9 family by
default — ``md, markdown, txt, yaml, yml, json, py`` plus the quadlet
@@ -97,6 +102,7 @@ import asyncio
import logging
import re
import sys
from datetime import datetime
from pathlib import Path
from app.config import Settings, get_settings
@@ -110,7 +116,7 @@ from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from app.rag.overview import regenerate_overview
from app.rag.sources_meta import bump_sources_version
from scripts.git_sync import GitSyncError, clone_or_pull
from scripts.git_sync import GitSyncError, clone_or_pull, file_commit_dates
logger = logging.getLogger("scripts.import_docs")
@@ -166,11 +172,16 @@ def repo_name(url: str) -> str:
def _resolve_sources(
cli_sources: list[Path] | None, settings: Settings
) -> tuple[list[Path], dict[str, list[str]], dict[str, bool]]:
cli_sources: list[Path] | None, settings: Settings,
) -> tuple[
list[Path],
dict[str, list[str]],
dict[str, bool],
dict[str, dict[str, datetime]],
]:
"""Resolve the directories to import (phase 28, extended in phases
35 and 38; per-root ignore maps, phase 89; per-root hidden-folders
flag maps, phase 105).
flag maps, phase 105; per-root source-date maps, phase 106).
Precedence: ``--source`` (explicit manual paths — always wins) >
the effective sources — the ``git_sources`` DB rows (git + local),
@@ -183,23 +194,28 @@ def _resolve_sources(
stored directory, re-verified ``.is_dir()`` at run time) > the
legacy ``DEFAULT_SOURCES``.
Returns ``(sources, ignore_by_root, include_hidden_by_root)``
(phase 89; phase 105 adds the per-root flag map — the flag is
stored per row, manual ``--source`` dirs and the legacy fallback
have no rows and import with the empty map: hidden paths skipped,
A4): both maps are keyed by the resolved root string, exactly as
Returns ``(sources, ignore_by_root, include_hidden_by_root,
doc_dates_by_root)`` (phase 89; phase 105 adds the per-root flag
map — the flag is stored per row, manual ``--source`` dirs and the
legacy fallback have no rows and import with the empty map: hidden
paths skipped, A4; phase 106 adds the per-root source-date map):
all three maps are keyed by the resolved root string, exactly as
the importer sees it (two rows sharing a root string get the union
— extend, not replace — for the ignore lists, and the OR of their
flags for the hidden map); manual ``--source`` dirs and the legacy
fallback have no rows, so they import with empty maps (no ignore,
hidden skipped).
— extend, not replace — for the ignore lists, the OR of their
flags for the hidden map, and one date walk for the date map);
the date map lists ONLY git roots (the ``file_commit_dates`` walk
over the fresh checkout after the clone — fail-soft to ``{}``,
which the importer reads as "no source dates, use mtimes"), and
manual ``--source`` dirs and the legacy fallback have no rows, so
they import with empty maps (no ignore, hidden skipped, mtime
fallback).
A :class:`GitSyncError` from a failing clone/pull — or a missing
local directory (``local source missing: <path>``) — propagates to
:func:`main`, which aborts the run before importing anything.
"""
if cli_sources:
return [path.expanduser() for path in cli_sources], {}, {}
return [path.expanduser() for path in cli_sources], {}, {}, {}
db = SessionLocal()
try:
rows, origin = effective_sources(db)
@@ -215,9 +231,14 @@ def _resolve_sources(
sources: list[Path] = []
ignore_by_root: dict[str, list[str]] = {}
include_hidden_by_root: dict[str, bool] = {}
doc_dates_by_root: dict[str, dict[str, datetime]] = {}
for row in rows:
if row.kind == "git":
root = clone_or_pull(row.url, sources_root / repo_name(row.url))
# Phase 106 (D2): the checkout's per-file last-commit
# dates, keyed by the SAME root string the importer
# sees; local rows contribute nothing (mtime fallback).
doc_dates_by_root[str(root)] = file_commit_dates(root)
else:
# kind=local — the stored expanded path (phase 38 also
# mirrors it in the NOT-NULL ``url`` location column, the
@@ -242,8 +263,8 @@ def _resolve_sources(
include_hidden_by_root.get(str(root), False)
or bool(row.include_hidden)
)
return sources, ignore_by_root, include_hidden_by_root
return [path.expanduser() for path in DEFAULT_SOURCES], {}, {}
return sources, ignore_by_root, include_hidden_by_root, doc_dates_by_root
return [path.expanduser() for path in DEFAULT_SOURCES], {}, {}, {}
def _overview_row_exists() -> bool:
@@ -283,12 +304,13 @@ def main(argv: list[str] | None = None) -> int:
# Git sources resolve (and clone/pull) *before* any import: a failing
# repo aborts the run with a non-zero exit, naming the failure — a bad
# URL must never silently import partial junk. The second element is
# the phase-89 per-root ignore map and the third the phase-105
# per-root hidden-folders flag map (both empty for manual/fallback
# paths).
# the phase-89 per-root ignore map, the third the phase-105
# per-root hidden-folders flag map, and the fourth the phase-106
# per-root source-date map (git rows only — empty for manual/
# fallback paths, which take the importer's mtime fallback).
try:
sources, ignore_by_root, include_hidden_by_root = _resolve_sources(
args.source, settings
sources, ignore_by_root, include_hidden_by_root, doc_dates_by_root = (
_resolve_sources(args.source, settings)
)
except GitSyncError as e:
print(f"import_docs: source sync failed: {e}", file=sys.stderr)
@@ -356,6 +378,7 @@ def main(argv: list[str] | None = None) -> int:
sources, llm, prune=args.prune, limit=args.limit,
ignore_by_root=ignore_by_root,
include_hidden_by_root=include_hidden_by_root,
doc_dates_by_root=doc_dates_by_root,
)
if args.limit is not None:
# An incomplete walk is debug-only — it must never advance