ai-tutor-chatbot / tests /test_incremental_context.py
omarsol's picture
fix(pipeline): stop incremental context runs gutting updated sources
82b28e5
Raw History Blame
11.3 kB
"""Tests for the incremental contextual-node selection/merge in the workflows.
Regression coverage for two data bugs in new-content-only mode:
1. Data destruction: the workflows used to drop every existing node whose
*source* had any new doc, gutting incrementally updated sources. The merge
must instead preserve all existing nodes except those for doc_ids being
(re)processed in the run.
2. Stale context: doc_ids are path-based and stable across content edits, so a
membership-only reprocess filter never re-contextualized a doc whose
content changed in place — its pkl nodes kept the old text forever. The
selection must compare the row's ``content_hash`` against the
``doc_content_hash`` stamped in node metadata, while treating legacy nodes
without the hash as unchanged (no surprise full-corpus Gemini reprocess).
"""
import asyncio
import json
from unittest.mock import patch
from app.chroma_rag import ChunkRecord
from data.scraping_scripts.update_docs_workflow import (
DOC_CONTENT_HASH_METADATA_KEY,
build_doc_hash_map,
merge_contextual_nodes,
select_docs_to_process,
)
def make_node(
chunk_id: str, doc_id: str, source: str, content_hash: str | None = None
) -> ChunkRecord:
metadata = {"doc_id": doc_id, "source": source}
if content_hash is not None:
metadata[DOC_CONTENT_HASH_METADATA_KEY] = content_hash
return ChunkRecord(
chunk_id=chunk_id,
doc_id=doc_id,
text=f"text for {chunk_id}",
metadata=metadata,
)
def make_doc(doc_id: str, content_hash: str | None = "sha256:aaa") -> dict:
doc = {
"doc_id": doc_id,
"content": f"content for {doc_id}",
"name": doc_id,
"url": f"https://example.com/{doc_id}",
"source": doc_id.split(":")[0],
"retrieve_doc": False,
"tokens": 10,
}
if content_hash is not None:
doc["content_hash"] = content_hash
return doc
def test_new_doc_in_existing_source_keeps_all_existing_nodes():
# A source with already-indexed docs gains one new doc: every existing
# node survives (same source or not) and the new doc's nodes are appended.
existing = [
make_node("t-p1-c1", "transformers:page-1", "transformers"),
make_node("t-p1-c2", "transformers:page-1", "transformers"),
make_node("t-p2-c1", "transformers:page-2", "transformers"),
make_node("l-p1-c1", "langchain:page-1", "langchain"),
]
new = [
make_node("t-p3-c1", "transformers:page-3", "transformers"),
make_node("t-p3-c2", "transformers:page-3", "transformers"),
]
merged = merge_contextual_nodes(existing, new, {"transformers:page-3"})
assert merged == existing + new
def test_reprocessed_doc_id_replaces_only_its_own_nodes():
# If a doc_id in the new batch already has nodes in the pkl, those old
# nodes are replaced by the freshly generated ones; sibling docs from the
# same source are untouched.
stale = [
make_node("a-c1", "course:lesson-a", "course"),
make_node("a-c2", "course:lesson-a", "course"),
]
sibling = make_node("b-c1", "course:lesson-b", "course")
regenerated = [make_node("a-c1-new", "course:lesson-a", "course")]
merged = merge_contextual_nodes(stale + [sibling], regenerated, {"course:lesson-a"})
assert merged == [sibling] + regenerated
def test_nodes_with_undetermined_doc_id_are_kept():
class OpaqueNode:
"""No doc_id, metadata, or source_node: doc_id lookup raises."""
opaque = OpaqueNode()
parseable = make_node("x-c1", "src:doc-x", "src")
new = [make_node("y-c1", "src:doc-y", "src")]
merged = merge_contextual_nodes([opaque, parseable], new, {"src:doc-y"})
assert merged == [opaque, parseable] + new
def test_empty_existing_pkl_yields_only_new_nodes():
new = [make_node("n-c1", "src:doc-n", "src")]
assert merge_contextual_nodes([], new, {"src:doc-n"}) == new
# --- Hash-forward selection: new + changed docs, legacy nodes untouched ---
def test_changed_content_hash_is_reprocessed_and_old_nodes_replaced():
# A doc whose content changed in place (same path-stable doc_id, new
# content_hash) must be selected for reprocessing, and its old nodes must
# be replaced by the regenerated ones when merged back.
existing = [
make_node("d1-c1", "src:doc-1", "src", content_hash="sha256:old"),
make_node("d1-c2", "src:doc-1", "src", content_hash="sha256:old"),
make_node("d2-c1", "src:doc-2", "src", content_hash="sha256:same"),
]
all_docs = [
make_doc("src:doc-1", "sha256:new"),
make_doc("src:doc-2", "sha256:same"),
]
selected, stats = select_docs_to_process(all_docs, build_doc_hash_map(existing))
assert [doc["doc_id"] for doc in selected] == ["src:doc-1"]
assert stats == {"new": 0, "changed": 1, "legacy_unhashed": 0}
regenerated = [
make_node("d1-c1-new", "src:doc-1", "src", content_hash="sha256:new")
]
merged = merge_contextual_nodes(
existing, regenerated, {doc["doc_id"] for doc in selected}
)
assert merged == [existing[2]] + regenerated
def test_unchanged_content_hash_is_not_reprocessed():
existing = [make_node("d1-c1", "src:doc-1", "src", content_hash="sha256:same")]
selected, stats = select_docs_to_process(
[make_doc("src:doc-1", "sha256:same")], build_doc_hash_map(existing)
)
assert selected == []
assert stats == {"new": 0, "changed": 0, "legacy_unhashed": 0}
def test_legacy_nodes_without_hash_are_treated_as_unchanged():
# Nodes written before hashes were stamped must NOT be reprocessed, even
# if the row's hash cannot be matched: --process-all-context is the
# explicit one-time baseline, not an implicit mass Gemini run.
existing = [make_node("d1-c1", "src:doc-1", "src")]
stored_hashes = build_doc_hash_map(existing)
assert stored_hashes == {"src:doc-1": None}
selected, stats = select_docs_to_process(
[make_doc("src:doc-1", "sha256:new")], stored_hashes
)
assert selected == []
assert stats == {"new": 0, "changed": 0, "legacy_unhashed": 1}
def test_new_doc_is_still_detected():
existing = [make_node("d1-c1", "src:doc-1", "src", content_hash="sha256:a")]
selected, stats = select_docs_to_process(
[make_doc("src:doc-1", "sha256:a"), make_doc("src:doc-2", "sha256:b")],
build_doc_hash_map(existing),
)
assert [doc["doc_id"] for doc in selected] == ["src:doc-2"]
assert stats == {"new": 1, "changed": 0, "legacy_unhashed": 0}
def test_row_without_content_hash_is_treated_as_unchanged():
# Older JSONL rows may lack content_hash entirely; with no basis for
# comparison the doc must not be reprocessed.
selected, stats = select_docs_to_process(
[make_doc("src:doc-1", content_hash=None)], {"src:doc-1": "sha256:a"}
)
assert selected == []
assert stats == {"new": 0, "changed": 0, "legacy_unhashed": 0}
def test_mixed_hash_doc_uses_any_stored_hash():
# A doc with some hashed and some legacy-unhashed nodes (shouldn't exist,
# but must resolve sanely): any stored hash counts, in either node order.
hashed_first = [
make_node("d1-c1", "src:doc-1", "src", content_hash="sha256:a"),
make_node("d1-c2", "src:doc-1", "src"),
]
unhashed_first = list(reversed(hashed_first))
assert build_doc_hash_map(hashed_first) == {"src:doc-1": "sha256:a"}
assert build_doc_hash_map(unhashed_first) == {"src:doc-1": "sha256:a"}
selected, stats = select_docs_to_process(
[make_doc("src:doc-1", "sha256:b")], build_doc_hash_map(unhashed_first)
)
assert [doc["doc_id"] for doc in selected] == ["src:doc-1"]
assert stats == {"new": 0, "changed": 1, "legacy_unhashed": 0}
def test_nodes_with_undetermined_doc_id_are_skipped_in_hash_map():
class OpaqueNode:
"""No doc_id, metadata, or source_node: doc_id lookup raises."""
assert build_doc_hash_map(
[OpaqueNode(), make_node("d1-c1", "src:doc-1", "src", content_hash="sha256:a")]
) == {"src:doc-1": "sha256:a"}
# --- Producer side: the context step must stamp the hash it reads back ---
def test_metadata_key_constant_matches_producer():
# update_docs_workflow duplicates the key to stay import-light; the copies
# must never drift from the producer's.
from data.scraping_scripts.add_context_to_nodes import (
DOC_CONTENT_HASH_METADATA_KEY as producer_key,
)
assert producer_key == DOC_CONTENT_HASH_METADATA_KEY
def test_process_stamps_doc_content_hash_on_every_chunk(tmp_path):
# create_docs -> process is the single funnel for the full-rebuild path,
# the incremental path, and create_vector_stores' non-pickle path; every
# resulting chunk must carry the row's content_hash. No Gemini call: the
# context generator is stubbed out.
from data.scraping_scripts.add_context_to_nodes import create_docs, process
row = make_doc("src:doc-1", "sha256:abc123")
row["content"] = "# Doc One\n\nSome content about incremental context."
jsonl_path = tmp_path / "docs.jsonl"
jsonl_path.write_text(json.dumps(row) + "\n", encoding="utf-8")
documents = create_docs(str(jsonl_path))
assert documents[0].metadata["content_hash"] == "sha256:abc123"
async def fake_situate_context(_doc: str, _chunk: str) -> str:
return "situated"
with patch(
"data.scraping_scripts.add_context_to_nodes.situate_context",
fake_situate_context,
):
records = asyncio.run(process(documents, semaphore_limit=1))
assert records
assert all(
record.metadata[DOC_CONTENT_HASH_METADATA_KEY] == "sha256:abc123"
for record in records
)
# The selection round-trip closes: unchanged hash -> no reprocess,
# changed hash -> reprocess.
stored_hashes = build_doc_hash_map(records)
unchanged, _ = select_docs_to_process([row], stored_hashes)
changed, _ = select_docs_to_process(
[make_doc("src:doc-1", "sha256:def456")], stored_hashes
)
assert unchanged == []
assert [doc["doc_id"] for doc in changed] == ["src:doc-1"]
def test_legacy_row_without_hash_produces_unhashed_nodes(tmp_path):
# Rows without content_hash (older JSONLs) must not stamp the field at
# all, so those docs land in the legacy-unhashed bucket downstream.
from data.scraping_scripts.add_context_to_nodes import create_docs, process
row = make_doc("src:doc-1", content_hash=None)
row["content"] = "# Doc One\n\nLegacy row without a content hash."
jsonl_path = tmp_path / "docs.jsonl"
jsonl_path.write_text(json.dumps(row) + "\n", encoding="utf-8")
documents = create_docs(str(jsonl_path))
assert "content_hash" not in documents[0].metadata
async def fake_situate_context(_doc: str, _chunk: str) -> str:
return "situated"
with patch(
"data.scraping_scripts.add_context_to_nodes.situate_context",
fake_situate_context,
):
records = asyncio.run(process(documents, semaphore_limit=1))
assert records
assert all(
DOC_CONTENT_HASH_METADATA_KEY not in record.metadata for record in records
)
assert build_doc_hash_map(records) == {"src:doc-1": None}