phase: 127_sync_incremental_folder_summaries
All verified green — the previous rejection was a harness interruption (no code failure), and the working tree + doc fixes are intact. **Phase 127 — final verification pass: 100% green** (all 4 tasks already complete; no code changes needed this pass) - **Re-verified implementation:** `ImportSummary.changed_paths` at all 5 increment sites + `_prune`; `affected_folder_keys` + `generate_folder_summaries(changed_docs=…)` (restricted keys, prune pass both modes, hook total = affected count); sync changed-KB branch passes `changed_docs=summary.changed_paths`; CLI keeps full-regen contract - **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → **3022 passed**, `app/` coverage **99%**; `uv run pytest tests/e2e/test_incremental_folder_summaries.py -v --no-cov` → **3 passed in isolation**; `uv run ruff check . && uv run pyright` → clean, **0 errors** - **Completion criteria:** one-file change rewrites only its folder chain (E2E pins siblings byte-identical, text AND `updated_at`) ✅; coverage >90% ✅; isolated E2E green ✅; no completed-phase behavior change (full suite green) ✅; atomic commit — delegated to the harness per pass rules (no git commands run by me) - **Docs:** README.md re-checked — all 7 previous-pass fixes still in place (Theme page row, drill-down `ls` + capped `read`, phase-124 answer images section + `BOR_ANSWER_IMAGES`, markdown-included summaries, 17-color palette, folder summaries in `BOR_LLM_SUMMARY_MODEL`, CLI regen note); AGENTS.md verified consistent — no changes needed - **Deviations:** none; untracked owner file `NOTES.md` left untouched (rides in the harness commit) - **Next pending phase:** `128_sync_label_mobile`
This commit is contained in:
@@ -15,9 +15,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf as fitz
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -28,6 +30,7 @@ from app.models import Chunk, Document
|
||||
from app.rag.importer import (
|
||||
EXCLUDED_DIRS,
|
||||
ImportSummary,
|
||||
_prune,
|
||||
_store_summary,
|
||||
import_sources,
|
||||
iter_importable_files,
|
||||
@@ -1194,3 +1197,236 @@ def test_progress_hook_with_limit_keeps_full_total(db, tmp_path: Path) -> None:
|
||||
assert events == [(root.name, "a.md", 1, 3), (root.name, "b.md", 2, 3)]
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
# ---------- phase 127 (task 01): ImportSummary.changed_paths ----------
|
||||
|
||||
|
||||
class _VisionLLM(FakeEmbedder):
|
||||
"""The phase-122 mock VISION client (the test_pdf pattern): the
|
||||
multimodal describe call (``user`` content = a part list) gets a fixed
|
||||
description; text (``lite``) calls keep the ``FakeEmbedder``
|
||||
behaviour."""
|
||||
|
||||
DESCRIPTION = "A test image: a solid swatch of color."
|
||||
|
||||
async def chat(self, messages, model=None): # pyright: ignore[reportIncompatibleMethodOverride]
|
||||
self.chat_calls.append(list(messages))
|
||||
user = next((m["content"] for m in messages if m.get("role") == "user"), "")
|
||||
if isinstance(user, list):
|
||||
return self.DESCRIPTION
|
||||
first = user.split()
|
||||
return "Summary of " + (first[0] if first else "<empty>")
|
||||
|
||||
|
||||
def _png_bytes(width: int, height: int, rgb: tuple[float, float, float]) -> bytes:
|
||||
"""One solid-color PNG built with PyMuPDF (the phase-126 no-binary-
|
||||
fixtures convention)."""
|
||||
pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, width, height), 0)
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
pix.set_pixel(x, y, rgb)
|
||||
return pix.tobytes("png")
|
||||
|
||||
|
||||
def _build_one_page_pdf(path: Path, text: str) -> None:
|
||||
"""One temp single-page PDF (the phase-126 convention — no binary
|
||||
fixtures)."""
|
||||
doc = fitz.open()
|
||||
doc.new_page().insert_text((72, 72), text)
|
||||
doc.save(path)
|
||||
doc.close()
|
||||
|
||||
|
||||
def test_import_summary_changed_paths_defaults_empty() -> None:
|
||||
"""Completion criterion: default construction gives an empty change
|
||||
set — no existing construction site migrates."""
|
||||
assert ImportSummary().changed_paths == []
|
||||
|
||||
|
||||
def test_changed_paths_records_added_updated_and_pruned_in_order(
|
||||
db, tmp_path: Path
|
||||
) -> None:
|
||||
"""The contract (phase 127, task 01): one ``(source, path)`` entry per
|
||||
ADDED, UPDATED, or PRUNED document — added/updated in walk order, the
|
||||
pruned pair last (``_prune`` runs after the walk) — and
|
||||
``len(changed_paths) == added + updated + pruned``."""
|
||||
root = tmp_path / "changed"
|
||||
root.mkdir()
|
||||
for name in ("a.md", "b.md", "c.md"):
|
||||
(root / name).write_text(f"# {name}\n\nv1\n")
|
||||
llm = FakeEmbedder()
|
||||
try:
|
||||
source = root.name
|
||||
first = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert first.added == 3 and first.pruned == 0
|
||||
assert first.changed_paths == [
|
||||
(source, "a.md"),
|
||||
(source, "b.md"),
|
||||
(source, "c.md"),
|
||||
]
|
||||
# Run 2: update a.md, add d.md + e.md, delete c.md (→ pruned).
|
||||
(root / "a.md").write_text("# a\n\nv2 changed\n")
|
||||
(root / "d.md").write_text("# d\n\nnew d\n")
|
||||
(root / "e.md").write_text("# e\n\nnew e\n")
|
||||
(root / "c.md").unlink()
|
||||
second = asyncio.run(import_sources([root], llm, session=db, prune=True))
|
||||
assert (second.added, second.updated, second.unchanged, second.pruned) == (
|
||||
2, 1, 1, 1,
|
||||
)
|
||||
assert second.changed_paths == [
|
||||
(source, "a.md"), # updated — first in walk order
|
||||
(source, "d.md"), # added
|
||||
(source, "e.md"), # added
|
||||
(source, "c.md"), # pruned — the _prune pass runs after the walk
|
||||
]
|
||||
assert len(second.changed_paths) == (
|
||||
second.added + second.updated + second.pruned
|
||||
)
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_changed_paths_empty_on_unchanged_rerun(db, tmp_path: Path) -> None:
|
||||
"""An all-unchanged re-run (prune included) records nothing."""
|
||||
root = tmp_path / "nochange"
|
||||
root.mkdir()
|
||||
(root / "a.md").write_text("# a\n\nstable\n")
|
||||
(root / "b.md").write_text("# b\n\nstable\n")
|
||||
llm = FakeEmbedder()
|
||||
try:
|
||||
asyncio.run(import_sources([root], llm, session=db))
|
||||
second = asyncio.run(import_sources([root], llm, session=db, prune=True))
|
||||
assert second.unchanged == 2
|
||||
assert second.changed_paths == []
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_changed_paths_empty_on_backfill_rerun(db, tmp_path: Path) -> None:
|
||||
"""A backfill (phase 118: unchanged doc, NULL summary) is NOT a content
|
||||
change: the add still records, the backfill re-run stays empty."""
|
||||
root = tmp_path / "backfill"
|
||||
root.mkdir()
|
||||
(root / "a.yaml").write_text("alpha x\n")
|
||||
try:
|
||||
first = asyncio.run(import_sources([root], _FailingChatEmbedder(), session=db))
|
||||
assert first.added == 1 and first.summary_errors == 1
|
||||
assert first.changed_paths == [(root.name, "a.yaml")] # the ADD records
|
||||
doc = db.scalar(
|
||||
select(Document).where(Document.source == root.name, Document.path == "a.yaml")
|
||||
)
|
||||
assert doc is not None and doc.summary is None
|
||||
second = asyncio.run(import_sources([root], FakeEmbedder(), session=db))
|
||||
assert second.unchanged == 1 and second.summary_backfilled == 1
|
||||
assert second.changed_paths == []
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_changed_paths_empty_on_date_only_rerun(db, tmp_path: Path) -> None:
|
||||
"""A date-only refresh (phase 106, D4: content untouched, mtime moved)
|
||||
is NOT a content change: the re-run records nothing."""
|
||||
root = tmp_path / "dateonly"
|
||||
root.mkdir()
|
||||
path = root / "a.md"
|
||||
path.write_text("# a\n\nstable\n")
|
||||
os.utime(path, (0, datetime(2020, 1, 1, tzinfo=UTC).timestamp()))
|
||||
llm = FakeEmbedder()
|
||||
try:
|
||||
first = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert first.added == 1 and first.dates_updated == 0
|
||||
assert first.changed_paths == [(root.name, "a.md")]
|
||||
# The content is untouched — only the mtime moved (the D4
|
||||
# date-refresh on the unchanged path).
|
||||
os.utime(path, (0, datetime(2021, 6, 15, tzinfo=UTC).timestamp()))
|
||||
second = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert second.unchanged == 1 and second.dates_updated == 1
|
||||
assert second.changed_paths == []
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_changed_paths_records_a_changed_image(db, tmp_path: Path) -> None:
|
||||
"""The image increment site (phase 122 path): a NEW image and a
|
||||
byte-CHANGED image each record their ``(source, rel)``."""
|
||||
root = tmp_path / "imgsrc"
|
||||
root.mkdir()
|
||||
(root / "pic.png").write_bytes(_png_bytes(1, 1, (1.0, 0.0, 0.0)))
|
||||
llm = _VisionLLM()
|
||||
llm.settings = Settings(
|
||||
_env_file=None, # pyright: ignore[reportCallIssue]
|
||||
images=True,
|
||||
image_dir=str(tmp_path / "images"),
|
||||
)
|
||||
try:
|
||||
source = root.name
|
||||
first = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert first.added == 1 and first.images_failed == 0
|
||||
assert first.changed_paths == [(source, "pic.png")]
|
||||
# The BYTES changed (a new color) — the image digest is over the
|
||||
# bytes, so this is an UPDATE.
|
||||
(root / "pic.png").write_bytes(_png_bytes(1, 1, (0.0, 1.0, 1.0)))
|
||||
second = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert second.updated == 1
|
||||
assert second.changed_paths == [(source, "pic.png")]
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_changed_paths_records_a_changed_pdf(db, tmp_path: Path) -> None:
|
||||
"""The PDF increment site (phase 126 path): a NEW PDF and a text-
|
||||
CHANGED PDF each record their ``(source, rel)``."""
|
||||
root = tmp_path / "pdfsrc"
|
||||
root.mkdir()
|
||||
pdf_path = root / "doc.pdf"
|
||||
_build_one_page_pdf(pdf_path, "alpha page one")
|
||||
llm = _VisionLLM()
|
||||
llm.settings = Settings(
|
||||
_env_file=None, # pyright: ignore[reportCallIssue]
|
||||
images=True,
|
||||
image_dir=str(tmp_path / "images"),
|
||||
import_extensions="md,txt,pdf",
|
||||
)
|
||||
try:
|
||||
source = root.name
|
||||
first = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert first.added == 1
|
||||
assert first.changed_paths == [(source, "doc.pdf")]
|
||||
# The extracted TEXT changed — the PDF digest is over it, so this
|
||||
# is an UPDATE.
|
||||
_build_one_page_pdf(pdf_path, "bravo page one, revised")
|
||||
second = asyncio.run(import_sources([root], llm, session=db))
|
||||
assert second.updated == 1
|
||||
assert second.changed_paths == [(source, "doc.pdf")]
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
|
||||
def test_prune_records_changed_paths_only_when_summary_given(db, tmp_path: Path) -> None:
|
||||
"""The ``_prune`` recording seam: with NO summary (the pre-phase
|
||||
count-only contract) nothing is recorded; with one, each ACTUALLY
|
||||
deleted doc appends its ``(source, path)`` (skipped docs never do)."""
|
||||
root = tmp_path / "prunit"
|
||||
root.mkdir()
|
||||
(root / "gone.md").write_text("# gone\n\nbye\n")
|
||||
(root / "gone2.md").write_text("# gone2\n\nbye\n")
|
||||
llm = FakeEmbedder()
|
||||
try:
|
||||
source = root.name
|
||||
asyncio.run(import_sources([root], llm, session=db))
|
||||
(root / "gone.md").unlink()
|
||||
(root / "gone2.md").unlink()
|
||||
# Default call (summary=None): count only, no recording.
|
||||
pruned = _prune(db, {source}, {(source, "gone2.md")})
|
||||
assert pruned == 1
|
||||
assert db.scalar(
|
||||
select(Document).where(Document.source == source, Document.path == "gone.md")
|
||||
) is None
|
||||
# With a summary: the deleted doc records its pair.
|
||||
counters = ImportSummary()
|
||||
pruned = _prune(db, {source}, set(), summary=counters)
|
||||
assert pruned == 1
|
||||
assert counters.changed_paths == [(source, "gone2.md")]
|
||||
finally:
|
||||
_cleanup_source(db, root.name)
|
||||
|
||||
Reference in New Issue
Block a user