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