fix(pipeline): stop incremental context runs gutting updated sources
Browse filesIn default new-content-only mode, add_context_to_nodes derived
updated_sources from the new docs and removed EVERY existing pkl node
whose source was in that set, re-adding only the new docs' nodes: a
source with 1,000 indexed pages and 1 new page kept context for 1 page.
create_vector_stores then deleted the other 999 pages' embeddings as
stale and the gutted bundle uploaded to HF. The next run regenerated
the 999 at Gemini cost while dropping the 1 (never converging).
- Replace the untestable inline python -c heredoc with an in-process,
unit-tested merge: merge_contextual_nodes keeps every existing node
except those whose doc_id is being (re)processed this run; nodes with
undeterminable doc_ids are kept. Removal by source no longer happens.
- add_course_workflow now shares the same function instead of carrying
a byte-for-byte duplicate of the broken block.
- Close the change-detection gap the doc_id-membership filter left:
content edits keep a stable doc_id, so edited docs were never
re-contextualized. Nodes now record the source row's content_hash
(excluded from embedded text), and the incremental filter selects new
docs plus docs whose stored hash differs. Legacy unhashed nodes are
treated as unchanged (no surprise Gemini reprocess); one
--process-all-context run baselines hashes corpus-wide.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@@ -36,6 +36,13 @@ CONTEXT_RETRY_ATTEMPTS = int(os.getenv("GEMINI_CONTEXT_RETRY_ATTEMPTS", "8"))
|
|
| 36 |
DEFAULT_SEMAPHORE_LIMIT = int(os.getenv("GEMINI_CONTEXT_CONCURRENCY", "50"))
|
| 37 |
MAX_DOCUMENT_TOKENS = 120_000
|
| 38 |
RETRYABLE_GENAI_STATUS_CODES = {408, 429, 500, 502, 503, 504}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
_genai_client: genai.Client | None = None
|
| 40 |
_token_encoding = tiktoken.get_encoding("cl100k_base")
|
| 41 |
|
|
@@ -83,28 +90,36 @@ def create_docs(input_file: str) -> List[Document]:
|
|
| 83 |
documents: list[Document] = []
|
| 84 |
for line in f:
|
| 85 |
data = json.loads(line)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
documents.append(
|
| 87 |
Document(
|
| 88 |
doc_id=data["doc_id"],
|
| 89 |
text=data["content"],
|
| 90 |
-
metadata=
|
| 91 |
-
"url": data["url"],
|
| 92 |
-
"title": data["name"],
|
| 93 |
-
"tokens": data["tokens"],
|
| 94 |
-
"retrieve_doc": data["retrieve_doc"],
|
| 95 |
-
"source": data["source"],
|
| 96 |
-
},
|
| 97 |
excluded_llm_metadata_keys=[
|
| 98 |
"title",
|
| 99 |
"tokens",
|
| 100 |
"retrieve_doc",
|
| 101 |
"source",
|
|
|
|
| 102 |
],
|
| 103 |
excluded_embed_metadata_keys=[
|
| 104 |
"url",
|
| 105 |
"tokens",
|
| 106 |
"retrieve_doc",
|
| 107 |
"source",
|
|
|
|
| 108 |
],
|
| 109 |
)
|
| 110 |
)
|
|
@@ -242,7 +257,7 @@ Return a title for the document and the succinct context.
|
|
| 242 |
|
| 243 |
|
| 244 |
def document_to_row(document: Document) -> dict:
|
| 245 |
-
|
| 246 |
"doc_id": document.doc_id,
|
| 247 |
"content": document.get_content(),
|
| 248 |
"name": document.metadata["title"],
|
|
@@ -251,6 +266,10 @@ def document_to_row(document: Document) -> dict:
|
|
| 251 |
"retrieve_doc": document.metadata["retrieve_doc"],
|
| 252 |
"tokens": document.metadata["tokens"],
|
| 253 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
|
| 256 |
async def process_chunk(
|
|
@@ -278,6 +297,15 @@ async def process_chunk(
|
|
| 278 |
context = await situate_context(doc.get_content(), chunk_record.text)
|
| 279 |
metadata = dict(chunk_record.metadata)
|
| 280 |
metadata["raw_text"] = chunk_record.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
contextual_text = (
|
| 282 |
f"{format_chunk_for_retrieval(chunk_record.text, metadata)}"
|
| 283 |
f"\n\nContext: {context}"
|
|
|
|
| 36 |
DEFAULT_SEMAPHORE_LIMIT = int(os.getenv("GEMINI_CONTEXT_CONCURRENCY", "50"))
|
| 37 |
MAX_DOCUMENT_TOKENS = 120_000
|
| 38 |
RETRYABLE_GENAI_STATUS_CODES = {408, 429, 500, 502, 503, 504}
|
| 39 |
+
# Node-metadata key recording the source document's JSONL ``content_hash`` on
|
| 40 |
+
# every contextual chunk. update_docs_workflow reads it back from the PKL to
|
| 41 |
+
# detect docs whose content changed in place (doc_ids are path-based and stable
|
| 42 |
+
# across edits, so id membership alone cannot catch this). update_docs_workflow
|
| 43 |
+
# keeps its own copy of this constant so it does not have to import this
|
| 44 |
+
# (Gemini/tiktoken/llama_index-heavy) module; tests assert the two stay in sync.
|
| 45 |
+
DOC_CONTENT_HASH_METADATA_KEY = "doc_content_hash"
|
| 46 |
_genai_client: genai.Client | None = None
|
| 47 |
_token_encoding = tiktoken.get_encoding("cl100k_base")
|
| 48 |
|
|
|
|
| 90 |
documents: list[Document] = []
|
| 91 |
for line in f:
|
| 92 |
data = json.loads(line)
|
| 93 |
+
metadata = {
|
| 94 |
+
"url": data["url"],
|
| 95 |
+
"title": data["name"],
|
| 96 |
+
"tokens": data["tokens"],
|
| 97 |
+
"retrieve_doc": data["retrieve_doc"],
|
| 98 |
+
"source": data["source"],
|
| 99 |
+
}
|
| 100 |
+
# Rows written by process_md_files carry a doc-level content hash;
|
| 101 |
+
# older JSONLs may not, so only record it when present.
|
| 102 |
+
content_hash = data.get("content_hash")
|
| 103 |
+
if content_hash:
|
| 104 |
+
metadata["content_hash"] = content_hash
|
| 105 |
documents.append(
|
| 106 |
Document(
|
| 107 |
doc_id=data["doc_id"],
|
| 108 |
text=data["content"],
|
| 109 |
+
metadata=metadata, # type: ignore
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
excluded_llm_metadata_keys=[
|
| 111 |
"title",
|
| 112 |
"tokens",
|
| 113 |
"retrieve_doc",
|
| 114 |
"source",
|
| 115 |
+
"content_hash",
|
| 116 |
],
|
| 117 |
excluded_embed_metadata_keys=[
|
| 118 |
"url",
|
| 119 |
"tokens",
|
| 120 |
"retrieve_doc",
|
| 121 |
"source",
|
| 122 |
+
"content_hash",
|
| 123 |
],
|
| 124 |
)
|
| 125 |
)
|
|
|
|
| 257 |
|
| 258 |
|
| 259 |
def document_to_row(document: Document) -> dict:
|
| 260 |
+
row = {
|
| 261 |
"doc_id": document.doc_id,
|
| 262 |
"content": document.get_content(),
|
| 263 |
"name": document.metadata["title"],
|
|
|
|
| 266 |
"retrieve_doc": document.metadata["retrieve_doc"],
|
| 267 |
"tokens": document.metadata["tokens"],
|
| 268 |
}
|
| 269 |
+
content_hash = document.metadata.get("content_hash")
|
| 270 |
+
if content_hash:
|
| 271 |
+
row["content_hash"] = content_hash
|
| 272 |
+
return row
|
| 273 |
|
| 274 |
|
| 275 |
async def process_chunk(
|
|
|
|
| 297 |
context = await situate_context(doc.get_content(), chunk_record.text)
|
| 298 |
metadata = dict(chunk_record.metadata)
|
| 299 |
metadata["raw_text"] = chunk_record.text
|
| 300 |
+
# Stamp the document-level content hash on every chunk so incremental
|
| 301 |
+
# workflow runs can detect in-place content changes
|
| 302 |
+
# (update_docs_workflow.select_docs_to_process). Every contextualization
|
| 303 |
+
# path — full rebuild (main), incremental (update_docs_workflow), and
|
| 304 |
+
# create_vector_stores' non-pickle path — funnels through process_chunk,
|
| 305 |
+
# so this covers all of them. Absent for legacy rows without a hash.
|
| 306 |
+
doc_content_hash = doc.metadata.get("content_hash")
|
| 307 |
+
if doc_content_hash:
|
| 308 |
+
metadata[DOC_CONTENT_HASH_METADATA_KEY] = doc_content_hash
|
| 309 |
contextual_text = (
|
| 310 |
f"{format_chunk_for_retrieval(chunk_record.text, metadata)}"
|
| 311 |
f"\n\nContext: {context}"
|
|
@@ -37,7 +37,7 @@ import os
|
|
| 37 |
import pickle
|
| 38 |
import subprocess
|
| 39 |
import sys
|
| 40 |
-
from typing import Dict, List
|
| 41 |
|
| 42 |
from dotenv import load_dotenv
|
| 43 |
from huggingface_hub import hf_hub_download
|
|
@@ -52,7 +52,7 @@ from data.scraping_scripts.source_registry import (
|
|
| 52 |
required_data_files,
|
| 53 |
source_output_files,
|
| 54 |
)
|
| 55 |
-
from
|
| 56 |
|
| 57 |
# Load environment variables from .env file
|
| 58 |
load_dotenv()
|
|
@@ -144,14 +144,6 @@ def load_jsonl(file_path: str) -> List[Dict]:
|
|
| 144 |
return data
|
| 145 |
|
| 146 |
|
| 147 |
-
def save_jsonl(data: List[Dict], file_path: str) -> None:
|
| 148 |
-
"""Save data to a JSONL file."""
|
| 149 |
-
with open(file_path, "w", encoding="utf-8") as f:
|
| 150 |
-
for item in data:
|
| 151 |
-
json.dump(item, f, ensure_ascii=False)
|
| 152 |
-
f.write("\n")
|
| 153 |
-
|
| 154 |
-
|
| 155 |
def process_markdown_files(course_name: str) -> str:
|
| 156 |
"""Process markdown files for a specific course. Returns path to output JSONL."""
|
| 157 |
logger.info(f"Processing markdown files for course: {course_name}")
|
|
@@ -255,118 +247,9 @@ def purge_sources_from_pkl(sources_to_purge: List[str]) -> None:
|
|
| 255 |
)
|
| 256 |
|
| 257 |
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
return set()
|
| 262 |
-
|
| 263 |
-
try:
|
| 264 |
-
with open("data/all_sources_contextual_nodes.pkl", "rb") as f:
|
| 265 |
-
nodes = pickle.load(f)
|
| 266 |
-
return {get_chunk_record_doc_id(node) for node in nodes}
|
| 267 |
-
except Exception as e:
|
| 268 |
-
logger.error(f"Error loading processed doc_ids: {e}")
|
| 269 |
-
return set()
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
def add_context_to_nodes(new_only: bool = False) -> None:
|
| 273 |
-
"""Add context to document nodes, optionally processing only new content."""
|
| 274 |
-
logger.info("Adding context to document nodes")
|
| 275 |
-
|
| 276 |
-
if new_only:
|
| 277 |
-
# Load all documents
|
| 278 |
-
all_docs = load_jsonl("data/all_sources_data.jsonl")
|
| 279 |
-
processed_ids = get_processed_doc_ids()
|
| 280 |
-
|
| 281 |
-
# Filter for unprocessed documents
|
| 282 |
-
new_docs = [doc for doc in all_docs if doc["doc_id"] not in processed_ids]
|
| 283 |
-
|
| 284 |
-
if not new_docs:
|
| 285 |
-
logger.info("No new documents to process")
|
| 286 |
-
return
|
| 287 |
-
|
| 288 |
-
# Save temporary JSONL with only new documents
|
| 289 |
-
temp_file = "data/new_docs_temp.jsonl"
|
| 290 |
-
save_jsonl(new_docs, temp_file)
|
| 291 |
-
|
| 292 |
-
# Temporarily modify the add_context_to_nodes.py script to use the temp file
|
| 293 |
-
cmd = [
|
| 294 |
-
sys.executable,
|
| 295 |
-
"-c",
|
| 296 |
-
f"""
|
| 297 |
-
import asyncio
|
| 298 |
-
import os
|
| 299 |
-
import pickle
|
| 300 |
-
import json
|
| 301 |
-
from data.scraping_scripts.add_context_to_nodes import create_docs, process
|
| 302 |
-
from app.chroma_rag import get_chunk_record_source
|
| 303 |
-
|
| 304 |
-
async def main():
|
| 305 |
-
# First, get the list of sources being updated from the temp file
|
| 306 |
-
updated_sources = set()
|
| 307 |
-
with open("{temp_file}", "r") as f:
|
| 308 |
-
for line in f:
|
| 309 |
-
data = json.loads(line)
|
| 310 |
-
updated_sources.add(data["source"])
|
| 311 |
-
|
| 312 |
-
print(f"Updating nodes for sources: {{updated_sources}}")
|
| 313 |
-
|
| 314 |
-
# Process new documents
|
| 315 |
-
documents = create_docs("{temp_file}")
|
| 316 |
-
enhanced_nodes = await process(documents)
|
| 317 |
-
print(f"Generated context for {{len(enhanced_nodes)}} new nodes")
|
| 318 |
-
|
| 319 |
-
# Load existing nodes if they exist
|
| 320 |
-
existing_nodes = []
|
| 321 |
-
if os.path.exists("data/all_sources_contextual_nodes.pkl"):
|
| 322 |
-
with open("data/all_sources_contextual_nodes.pkl", "rb") as f:
|
| 323 |
-
existing_nodes = pickle.load(f)
|
| 324 |
-
|
| 325 |
-
# Filter out existing nodes for sources we're updating
|
| 326 |
-
filtered_nodes = []
|
| 327 |
-
removed_count = 0
|
| 328 |
-
|
| 329 |
-
for node in existing_nodes:
|
| 330 |
-
try:
|
| 331 |
-
source = get_chunk_record_source(node)
|
| 332 |
-
if source not in updated_sources:
|
| 333 |
-
filtered_nodes.append(node)
|
| 334 |
-
else:
|
| 335 |
-
removed_count += 1
|
| 336 |
-
except Exception:
|
| 337 |
-
# Keep nodes where we can't determine the source
|
| 338 |
-
filtered_nodes.append(node)
|
| 339 |
-
|
| 340 |
-
print(f"Removed {{removed_count}} existing nodes for updated sources")
|
| 341 |
-
existing_nodes = filtered_nodes
|
| 342 |
-
|
| 343 |
-
# Combine filtered existing nodes with new nodes
|
| 344 |
-
all_nodes = existing_nodes + enhanced_nodes
|
| 345 |
-
|
| 346 |
-
# Save all nodes
|
| 347 |
-
with open("data/all_sources_contextual_nodes.pkl", "wb") as f:
|
| 348 |
-
pickle.dump(all_nodes, f)
|
| 349 |
-
|
| 350 |
-
print(f"Total nodes in updated file: {{len(all_nodes)}}")
|
| 351 |
-
|
| 352 |
-
asyncio.run(main())
|
| 353 |
-
""",
|
| 354 |
-
]
|
| 355 |
-
else:
|
| 356 |
-
# Process all documents
|
| 357 |
-
cmd = [sys.executable, "-m", "data.scraping_scripts.add_context_to_nodes"]
|
| 358 |
-
|
| 359 |
-
result = subprocess.run(cmd)
|
| 360 |
-
|
| 361 |
-
if result.returncode != 0:
|
| 362 |
-
logger.error("Error adding context to nodes - check output above")
|
| 363 |
-
sys.exit(1)
|
| 364 |
-
|
| 365 |
-
logger.info("Successfully added context to nodes")
|
| 366 |
-
|
| 367 |
-
# Clean up temp file if it exists
|
| 368 |
-
if new_only and os.path.exists("data/new_docs_temp.jsonl"):
|
| 369 |
-
os.remove("data/new_docs_temp.jsonl")
|
| 370 |
|
| 371 |
|
| 372 |
def create_vector_stores() -> None:
|
|
|
|
| 37 |
import pickle
|
| 38 |
import subprocess
|
| 39 |
import sys
|
| 40 |
+
from typing import Dict, List
|
| 41 |
|
| 42 |
from dotenv import load_dotenv
|
| 43 |
from huggingface_hub import hf_hub_download
|
|
|
|
| 52 |
required_data_files,
|
| 53 |
source_output_files,
|
| 54 |
)
|
| 55 |
+
from data.scraping_scripts.update_docs_workflow import add_context_to_nodes
|
| 56 |
|
| 57 |
# Load environment variables from .env file
|
| 58 |
load_dotenv()
|
|
|
|
| 144 |
return data
|
| 145 |
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
def process_markdown_files(course_name: str) -> str:
|
| 148 |
"""Process markdown files for a specific course. Returns path to output JSONL."""
|
| 149 |
logger.info(f"Processing markdown files for course: {course_name}")
|
|
|
|
| 247 |
)
|
| 248 |
|
| 249 |
|
| 250 |
+
# The context-addition step (incl. the incremental merge that preserves
|
| 251 |
+
# existing contextual nodes) is shared with the docs workflow:
|
| 252 |
+
# data.scraping_scripts.update_docs_workflow.add_context_to_nodes.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
|
| 255 |
def create_vector_stores() -> None:
|
|
@@ -18,7 +18,7 @@ Usage:
|
|
| 18 |
Additional flags to run specific steps (if you want to restart from a specific point):
|
| 19 |
--skip-download Skip the GitHub download step
|
| 20 |
--skip-process Skip the markdown processing step
|
| 21 |
-
--
|
| 22 |
--skip-context Skip the context addition step entirely
|
| 23 |
--skip-vectors Skip vector store creation
|
| 24 |
--skip-upload Skip uploading to HuggingFace
|
|
@@ -47,7 +47,7 @@ from data.scraping_scripts.source_registry import (
|
|
| 47 |
required_data_files,
|
| 48 |
source_output_files,
|
| 49 |
)
|
| 50 |
-
from app.chroma_rag import get_chunk_record_doc_id
|
| 51 |
|
| 52 |
# Load environment variables from .env file
|
| 53 |
load_dotenv()
|
|
@@ -135,6 +135,14 @@ DOC_SOURCES = list(DOC_SOURCE_KEYS)
|
|
| 135 |
GITHUB_SOURCES = list(GITHUB_SOURCE_KEYS)
|
| 136 |
LLMS_TXT_SOURCES = list(LLMS_TXT_SOURCE_KEYS)
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
def load_jsonl(file_path: str) -> List[Dict]:
|
| 140 |
"""Load data from a JSONL file."""
|
|
@@ -247,109 +255,225 @@ def process_markdown_files(sources: List[str]) -> None:
|
|
| 247 |
logger.info("Successfully processed markdown files")
|
| 248 |
|
| 249 |
|
| 250 |
-
def
|
| 251 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
if not os.path.exists("data/all_sources_contextual_nodes.pkl"):
|
| 253 |
-
return
|
| 254 |
|
| 255 |
try:
|
| 256 |
with open("data/all_sources_contextual_nodes.pkl", "rb") as f:
|
| 257 |
nodes = pickle.load(f)
|
| 258 |
-
return {get_chunk_record_doc_id(node) for node in nodes}
|
| 259 |
except Exception as e:
|
| 260 |
-
logger.error(f"Error loading processed
|
| 261 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
|
| 263 |
|
| 264 |
def add_context_to_nodes(new_only: bool = False) -> None:
|
| 265 |
-
"""Add context to document nodes, optionally
|
| 266 |
logger.info("Adding context to document nodes")
|
| 267 |
|
| 268 |
if new_only:
|
| 269 |
# Load all documents
|
| 270 |
all_docs = load_jsonl("data/all_sources_data.jsonl")
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
#
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
-
if not
|
| 277 |
-
logger.info("No new documents to process")
|
| 278 |
return
|
| 279 |
|
| 280 |
-
# Save temporary JSONL with only
|
| 281 |
temp_file = "data/new_docs_temp.jsonl"
|
| 282 |
-
save_jsonl(
|
| 283 |
-
|
| 284 |
-
# Temporarily modify the add_context_to_nodes.py script to use the temp file
|
| 285 |
-
cmd = [
|
| 286 |
-
sys.executable,
|
| 287 |
-
"-c",
|
| 288 |
-
f"""
|
| 289 |
-
import asyncio
|
| 290 |
-
import os
|
| 291 |
-
import pickle
|
| 292 |
-
import json
|
| 293 |
-
from data.scraping_scripts.add_context_to_nodes import create_docs, process
|
| 294 |
-
from app.chroma_rag import get_chunk_record_source
|
| 295 |
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
# Process new documents
|
| 307 |
-
documents = create_docs("{temp_file}")
|
| 308 |
-
enhanced_nodes = await process(documents)
|
| 309 |
-
print(f"Generated context for {{len(enhanced_nodes)}} new nodes")
|
| 310 |
-
|
| 311 |
-
# Load existing nodes if they exist
|
| 312 |
-
existing_nodes = []
|
| 313 |
-
if os.path.exists("data/all_sources_contextual_nodes.pkl"):
|
| 314 |
-
with open("data/all_sources_contextual_nodes.pkl", "rb") as f:
|
| 315 |
-
existing_nodes = pickle.load(f)
|
| 316 |
-
|
| 317 |
-
# Filter out existing nodes for sources we're updating
|
| 318 |
-
filtered_nodes = []
|
| 319 |
-
removed_count = 0
|
| 320 |
-
|
| 321 |
-
for node in existing_nodes:
|
| 322 |
-
try:
|
| 323 |
-
source = get_chunk_record_source(node)
|
| 324 |
-
if source not in updated_sources:
|
| 325 |
-
filtered_nodes.append(node)
|
| 326 |
-
else:
|
| 327 |
-
removed_count += 1
|
| 328 |
-
except Exception:
|
| 329 |
-
# Keep nodes where we can't determine the source
|
| 330 |
-
filtered_nodes.append(node)
|
| 331 |
-
|
| 332 |
-
print(f"Removed {{removed_count}} existing nodes for updated sources")
|
| 333 |
-
existing_nodes = filtered_nodes
|
| 334 |
-
|
| 335 |
-
# Combine filtered existing nodes with new nodes
|
| 336 |
-
all_nodes = existing_nodes + enhanced_nodes
|
| 337 |
-
|
| 338 |
-
# Save all nodes
|
| 339 |
-
with open("data/all_sources_contextual_nodes.pkl", "wb") as f:
|
| 340 |
-
pickle.dump(all_nodes, f)
|
| 341 |
-
|
| 342 |
-
print(f"Total nodes in updated file: {{len(all_nodes)}}")
|
| 343 |
|
| 344 |
-
|
| 345 |
-
""",
|
| 346 |
-
]
|
| 347 |
-
else:
|
| 348 |
-
# Process all documents
|
| 349 |
-
logger.info("Adding context to all nodes")
|
| 350 |
-
cmd = [sys.executable, "-m", "data.scraping_scripts.add_context_to_nodes"]
|
| 351 |
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
|
| 354 |
if result.returncode != 0:
|
| 355 |
logger.error("Error adding context to nodes - check output above")
|
|
@@ -357,10 +481,6 @@ asyncio.run(main())
|
|
| 357 |
|
| 358 |
logger.info("Successfully added context to nodes")
|
| 359 |
|
| 360 |
-
# Clean up temp file if it exists
|
| 361 |
-
if new_only and os.path.exists("data/new_docs_temp.jsonl"):
|
| 362 |
-
os.remove("data/new_docs_temp.jsonl")
|
| 363 |
-
|
| 364 |
|
| 365 |
def create_vector_stores() -> None:
|
| 366 |
"""Create vector stores from processed documents."""
|
|
@@ -443,7 +563,11 @@ def main():
|
|
| 443 |
parser.add_argument(
|
| 444 |
"--process-all-context",
|
| 445 |
action="store_true",
|
| 446 |
-
help=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
)
|
| 448 |
parser.add_argument(
|
| 449 |
"--skip-context",
|
|
|
|
| 18 |
Additional flags to run specific steps (if you want to restart from a specific point):
|
| 19 |
--skip-download Skip the GitHub download step
|
| 20 |
--skip-process Skip the markdown processing step
|
| 21 |
+
--process-all-context Regenerate context for every doc (default: only new or changed content)
|
| 22 |
--skip-context Skip the context addition step entirely
|
| 23 |
--skip-vectors Skip vector store creation
|
| 24 |
--skip-upload Skip uploading to HuggingFace
|
|
|
|
| 47 |
required_data_files,
|
| 48 |
source_output_files,
|
| 49 |
)
|
| 50 |
+
from app.chroma_rag import get_chunk_record_doc_id, get_chunk_record_metadata
|
| 51 |
|
| 52 |
# Load environment variables from .env file
|
| 53 |
load_dotenv()
|
|
|
|
| 135 |
GITHUB_SOURCES = list(GITHUB_SOURCE_KEYS)
|
| 136 |
LLMS_TXT_SOURCES = list(LLMS_TXT_SOURCE_KEYS)
|
| 137 |
|
| 138 |
+
# Node-metadata key stamped by add_context_to_nodes.process_chunk with the
|
| 139 |
+
# source document's JSONL ``content_hash``. Deliberately duplicated here (not
|
| 140 |
+
# imported) so this module keeps its light import footprint —
|
| 141 |
+
# add_context_to_nodes pulls in Gemini/tiktoken/llama_index, which this
|
| 142 |
+
# module's importers and tests should not need.
|
| 143 |
+
# tests/test_incremental_context.py asserts the two constants stay in sync.
|
| 144 |
+
DOC_CONTENT_HASH_METADATA_KEY = "doc_content_hash"
|
| 145 |
+
|
| 146 |
|
| 147 |
def load_jsonl(file_path: str) -> List[Dict]:
|
| 148 |
"""Load data from a JSONL file."""
|
|
|
|
| 255 |
logger.info("Successfully processed markdown files")
|
| 256 |
|
| 257 |
|
| 258 |
+
def build_doc_hash_map(nodes: List) -> Dict[str, str | None]:
|
| 259 |
+
"""Map doc_id -> stored ``doc_content_hash`` for a list of contextual nodes.
|
| 260 |
+
|
| 261 |
+
Membership means the doc already has contextual nodes; the value is the
|
| 262 |
+
document-level content hash stamped at context-generation time, or ``None``
|
| 263 |
+
for legacy nodes written before hashes existed. If a doc has a mix of
|
| 264 |
+
hashed and unhashed nodes (shouldn't happen, but resolve it sanely), any
|
| 265 |
+
stored hash for the doc counts. Nodes whose doc_id can't be determined are
|
| 266 |
+
skipped.
|
| 267 |
+
"""
|
| 268 |
+
doc_hashes: Dict[str, str | None] = {}
|
| 269 |
+
for node in nodes:
|
| 270 |
+
try:
|
| 271 |
+
doc_id = get_chunk_record_doc_id(node)
|
| 272 |
+
except Exception:
|
| 273 |
+
continue
|
| 274 |
+
try:
|
| 275 |
+
metadata = get_chunk_record_metadata(node)
|
| 276 |
+
except Exception:
|
| 277 |
+
metadata = {}
|
| 278 |
+
stored_hash = metadata.get(DOC_CONTENT_HASH_METADATA_KEY)
|
| 279 |
+
if doc_id not in doc_hashes:
|
| 280 |
+
doc_hashes[doc_id] = stored_hash
|
| 281 |
+
elif doc_hashes[doc_id] is None and stored_hash is not None:
|
| 282 |
+
doc_hashes[doc_id] = stored_hash
|
| 283 |
+
return doc_hashes
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def get_processed_doc_hashes() -> Dict[str, str | None]:
|
| 287 |
+
"""Get doc_id -> stored content hash for docs already processed with context.
|
| 288 |
+
|
| 289 |
+
Key membership carries the old ``get_processed_doc_ids`` contract (the doc
|
| 290 |
+
has contextual nodes in the PKL); the value adds the stored
|
| 291 |
+
``doc_content_hash`` (``None`` for legacy nodes), enabling changed-content
|
| 292 |
+
detection. See ``build_doc_hash_map`` for resolution rules.
|
| 293 |
+
"""
|
| 294 |
if not os.path.exists("data/all_sources_contextual_nodes.pkl"):
|
| 295 |
+
return {}
|
| 296 |
|
| 297 |
try:
|
| 298 |
with open("data/all_sources_contextual_nodes.pkl", "rb") as f:
|
| 299 |
nodes = pickle.load(f)
|
|
|
|
| 300 |
except Exception as e:
|
| 301 |
+
logger.error(f"Error loading processed doc hashes: {e}")
|
| 302 |
+
return {}
|
| 303 |
+
|
| 304 |
+
return build_doc_hash_map(nodes)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def select_docs_to_process(
|
| 308 |
+
all_docs: List[Dict],
|
| 309 |
+
stored_hashes: Dict[str, str | None],
|
| 310 |
+
) -> tuple[List[Dict], Dict[str, int]]:
|
| 311 |
+
"""Pick the docs needing (re)contextualization, with selection stats.
|
| 312 |
+
|
| 313 |
+
A doc is selected when:
|
| 314 |
+
|
| 315 |
+
- its doc_id has no contextual nodes yet (**new**), or
|
| 316 |
+
- its stored ``doc_content_hash`` differs from the JSONL row's current
|
| 317 |
+
``content_hash`` (**changed** in place; doc_ids are path-based and stable
|
| 318 |
+
across content edits, so id membership alone can never catch this).
|
| 319 |
+
|
| 320 |
+
Docs whose pkl nodes lack the hash field (**legacy**, written before this
|
| 321 |
+
fix) are treated as UNCHANGED so shipping hash-forward detection does not
|
| 322 |
+
trigger a surprise full-corpus Gemini reprocess; a one-time
|
| 323 |
+
``--process-all-context`` run baselines hashes for the whole corpus, after
|
| 324 |
+
which in-place edits are picked up automatically. Rows without a
|
| 325 |
+
``content_hash`` (older JSONLs) are likewise treated as unchanged.
|
| 326 |
+
|
| 327 |
+
Returns ``(docs_to_process, stats)`` with stats keys ``new``, ``changed``,
|
| 328 |
+
and ``legacy_unhashed``.
|
| 329 |
+
"""
|
| 330 |
+
docs_to_process: List[Dict] = []
|
| 331 |
+
stats = {"new": 0, "changed": 0, "legacy_unhashed": 0}
|
| 332 |
+
|
| 333 |
+
for doc in all_docs:
|
| 334 |
+
doc_id = doc["doc_id"]
|
| 335 |
+
if doc_id not in stored_hashes:
|
| 336 |
+
stats["new"] += 1
|
| 337 |
+
docs_to_process.append(doc)
|
| 338 |
+
continue
|
| 339 |
+
|
| 340 |
+
stored_hash = stored_hashes[doc_id]
|
| 341 |
+
if stored_hash is None:
|
| 342 |
+
stats["legacy_unhashed"] += 1
|
| 343 |
+
continue
|
| 344 |
+
|
| 345 |
+
row_hash = doc.get("content_hash")
|
| 346 |
+
if row_hash is not None and row_hash != stored_hash:
|
| 347 |
+
stats["changed"] += 1
|
| 348 |
+
docs_to_process.append(doc)
|
| 349 |
+
|
| 350 |
+
return docs_to_process, stats
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def merge_contextual_nodes(
|
| 354 |
+
existing_nodes: List,
|
| 355 |
+
new_nodes: List,
|
| 356 |
+
reprocessed_doc_ids: Set[str],
|
| 357 |
+
) -> List:
|
| 358 |
+
"""Merge freshly contextualized nodes into an existing contextual-node list.
|
| 359 |
+
|
| 360 |
+
Every existing node is preserved except those whose doc_id is in
|
| 361 |
+
``reprocessed_doc_ids`` (the docs being (re)processed in this run); their
|
| 362 |
+
old nodes are superseded by ``new_nodes``, which are appended. Nodes whose
|
| 363 |
+
doc_id cannot be determined are kept. Nodes are never removed by source:
|
| 364 |
+
an incremental run that adds one new page to a source must keep every
|
| 365 |
+
other already-indexed page of that source intact.
|
| 366 |
+
"""
|
| 367 |
+
merged: List = []
|
| 368 |
+
replaced_count = 0
|
| 369 |
+
unknown_count = 0
|
| 370 |
+
|
| 371 |
+
for node in existing_nodes:
|
| 372 |
+
try:
|
| 373 |
+
doc_id = get_chunk_record_doc_id(node)
|
| 374 |
+
except Exception:
|
| 375 |
+
# Keep nodes whose doc_id can't be determined
|
| 376 |
+
merged.append(node)
|
| 377 |
+
unknown_count += 1
|
| 378 |
+
continue
|
| 379 |
+
|
| 380 |
+
if doc_id in reprocessed_doc_ids:
|
| 381 |
+
replaced_count += 1
|
| 382 |
+
else:
|
| 383 |
+
merged.append(node)
|
| 384 |
+
|
| 385 |
+
logger.info(
|
| 386 |
+
"Merging contextual nodes: kept %s existing nodes "
|
| 387 |
+
"(%s with undetermined doc_id), replaced %s nodes for reprocessed "
|
| 388 |
+
"doc_ids, appended %s new nodes",
|
| 389 |
+
len(merged),
|
| 390 |
+
unknown_count,
|
| 391 |
+
replaced_count,
|
| 392 |
+
len(new_nodes),
|
| 393 |
+
)
|
| 394 |
+
return merged + list(new_nodes)
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def _add_context_for_new_docs(temp_file: str, reprocessed_doc_ids: Set[str]) -> None:
|
| 398 |
+
"""Generate context for the docs in ``temp_file`` and merge into the PKL."""
|
| 399 |
+
# Imported lazily: pulls in Gemini/tiktoken/llama_index, which the rest of
|
| 400 |
+
# this module (and its importers/tests) should not need.
|
| 401 |
+
import asyncio
|
| 402 |
+
|
| 403 |
+
from data.scraping_scripts.add_context_to_nodes import create_docs, process
|
| 404 |
+
|
| 405 |
+
documents = create_docs(temp_file)
|
| 406 |
+
enhanced_nodes = asyncio.run(process(documents))
|
| 407 |
+
logger.info("Generated context for %s new nodes", len(enhanced_nodes))
|
| 408 |
+
|
| 409 |
+
pkl_path = "data/all_sources_contextual_nodes.pkl"
|
| 410 |
+
existing_nodes: List = []
|
| 411 |
+
if os.path.exists(pkl_path):
|
| 412 |
+
with open(pkl_path, "rb") as f:
|
| 413 |
+
existing_nodes = pickle.load(f)
|
| 414 |
+
|
| 415 |
+
all_nodes = merge_contextual_nodes(
|
| 416 |
+
existing_nodes, enhanced_nodes, reprocessed_doc_ids
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
with open(pkl_path, "wb") as f:
|
| 420 |
+
pickle.dump(all_nodes, f)
|
| 421 |
+
|
| 422 |
+
logger.info("Total nodes in updated file: %s", len(all_nodes))
|
| 423 |
|
| 424 |
|
| 425 |
def add_context_to_nodes(new_only: bool = False) -> None:
|
| 426 |
+
"""Add context to document nodes, optionally only new/changed content."""
|
| 427 |
logger.info("Adding context to document nodes")
|
| 428 |
|
| 429 |
if new_only:
|
| 430 |
# Load all documents
|
| 431 |
all_docs = load_jsonl("data/all_sources_data.jsonl")
|
| 432 |
+
stored_hashes = get_processed_doc_hashes()
|
| 433 |
+
|
| 434 |
+
# Select docs with no contextual nodes yet (new) plus docs whose
|
| 435 |
+
# content hash differs from the one stamped in the pkl (changed).
|
| 436 |
+
docs_to_process, stats = select_docs_to_process(all_docs, stored_hashes)
|
| 437 |
+
logger.info(
|
| 438 |
+
"Context selection: %s new docs, %s changed docs, %s legacy docs "
|
| 439 |
+
"without a stored content hash (treated as unchanged; a one-time "
|
| 440 |
+
"--process-all-context run baselines hashes for the whole corpus)",
|
| 441 |
+
stats["new"],
|
| 442 |
+
stats["changed"],
|
| 443 |
+
stats["legacy_unhashed"],
|
| 444 |
+
)
|
| 445 |
|
| 446 |
+
if not docs_to_process:
|
| 447 |
+
logger.info("No new or changed documents to process")
|
| 448 |
return
|
| 449 |
|
| 450 |
+
# Save temporary JSONL with only the documents to (re)process
|
| 451 |
temp_file = "data/new_docs_temp.jsonl"
|
| 452 |
+
save_jsonl(docs_to_process, temp_file)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
|
| 454 |
+
try:
|
| 455 |
+
# merge_contextual_nodes replaces every existing node whose doc_id
|
| 456 |
+
# is in this set, so a changed doc's old nodes are dropped in
|
| 457 |
+
# favor of the freshly contextualized ones.
|
| 458 |
+
_add_context_for_new_docs(
|
| 459 |
+
temp_file, {doc["doc_id"] for doc in docs_to_process}
|
| 460 |
+
)
|
| 461 |
+
except Exception:
|
| 462 |
+
logger.exception("Error adding context to nodes")
|
| 463 |
+
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 464 |
|
| 465 |
+
logger.info("Successfully added context to nodes")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
|
| 467 |
+
# Clean up temp file (kept on failure to help debugging)
|
| 468 |
+
if os.path.exists(temp_file):
|
| 469 |
+
os.remove(temp_file)
|
| 470 |
+
return
|
| 471 |
+
|
| 472 |
+
# Process all documents
|
| 473 |
+
logger.info("Adding context to all nodes")
|
| 474 |
+
result = subprocess.run(
|
| 475 |
+
[sys.executable, "-m", "data.scraping_scripts.add_context_to_nodes"]
|
| 476 |
+
)
|
| 477 |
|
| 478 |
if result.returncode != 0:
|
| 479 |
logger.error("Error adding context to nodes - check output above")
|
|
|
|
| 481 |
|
| 482 |
logger.info("Successfully added context to nodes")
|
| 483 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
|
| 485 |
def create_vector_stores() -> None:
|
| 486 |
"""Create vector stores from processed documents."""
|
|
|
|
| 563 |
parser.add_argument(
|
| 564 |
"--process-all-context",
|
| 565 |
action="store_true",
|
| 566 |
+
help=(
|
| 567 |
+
"Process all content when adding context (default: only process "
|
| 568 |
+
"new or changed content; also baselines doc content hashes for "
|
| 569 |
+
"legacy nodes written before hashes were stamped)"
|
| 570 |
+
),
|
| 571 |
)
|
| 572 |
parser.add_argument(
|
| 573 |
"--skip-context",
|
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the incremental contextual-node selection/merge in the workflows.
|
| 2 |
+
|
| 3 |
+
Regression coverage for two data bugs in new-content-only mode:
|
| 4 |
+
|
| 5 |
+
1. Data destruction: the workflows used to drop every existing node whose
|
| 6 |
+
*source* had any new doc, gutting incrementally updated sources. The merge
|
| 7 |
+
must instead preserve all existing nodes except those for doc_ids being
|
| 8 |
+
(re)processed in the run.
|
| 9 |
+
2. Stale context: doc_ids are path-based and stable across content edits, so a
|
| 10 |
+
membership-only reprocess filter never re-contextualized a doc whose
|
| 11 |
+
content changed in place — its pkl nodes kept the old text forever. The
|
| 12 |
+
selection must compare the row's ``content_hash`` against the
|
| 13 |
+
``doc_content_hash`` stamped in node metadata, while treating legacy nodes
|
| 14 |
+
without the hash as unchanged (no surprise full-corpus Gemini reprocess).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import json
|
| 19 |
+
from unittest.mock import patch
|
| 20 |
+
|
| 21 |
+
from app.chroma_rag import ChunkRecord
|
| 22 |
+
from data.scraping_scripts.update_docs_workflow import (
|
| 23 |
+
DOC_CONTENT_HASH_METADATA_KEY,
|
| 24 |
+
build_doc_hash_map,
|
| 25 |
+
merge_contextual_nodes,
|
| 26 |
+
select_docs_to_process,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def make_node(
|
| 31 |
+
chunk_id: str, doc_id: str, source: str, content_hash: str | None = None
|
| 32 |
+
) -> ChunkRecord:
|
| 33 |
+
metadata = {"doc_id": doc_id, "source": source}
|
| 34 |
+
if content_hash is not None:
|
| 35 |
+
metadata[DOC_CONTENT_HASH_METADATA_KEY] = content_hash
|
| 36 |
+
return ChunkRecord(
|
| 37 |
+
chunk_id=chunk_id,
|
| 38 |
+
doc_id=doc_id,
|
| 39 |
+
text=f"text for {chunk_id}",
|
| 40 |
+
metadata=metadata,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def make_doc(doc_id: str, content_hash: str | None = "sha256:aaa") -> dict:
|
| 45 |
+
doc = {
|
| 46 |
+
"doc_id": doc_id,
|
| 47 |
+
"content": f"content for {doc_id}",
|
| 48 |
+
"name": doc_id,
|
| 49 |
+
"url": f"https://example.com/{doc_id}",
|
| 50 |
+
"source": doc_id.split(":")[0],
|
| 51 |
+
"retrieve_doc": False,
|
| 52 |
+
"tokens": 10,
|
| 53 |
+
}
|
| 54 |
+
if content_hash is not None:
|
| 55 |
+
doc["content_hash"] = content_hash
|
| 56 |
+
return doc
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_new_doc_in_existing_source_keeps_all_existing_nodes():
|
| 60 |
+
# A source with already-indexed docs gains one new doc: every existing
|
| 61 |
+
# node survives (same source or not) and the new doc's nodes are appended.
|
| 62 |
+
existing = [
|
| 63 |
+
make_node("t-p1-c1", "transformers:page-1", "transformers"),
|
| 64 |
+
make_node("t-p1-c2", "transformers:page-1", "transformers"),
|
| 65 |
+
make_node("t-p2-c1", "transformers:page-2", "transformers"),
|
| 66 |
+
make_node("l-p1-c1", "langchain:page-1", "langchain"),
|
| 67 |
+
]
|
| 68 |
+
new = [
|
| 69 |
+
make_node("t-p3-c1", "transformers:page-3", "transformers"),
|
| 70 |
+
make_node("t-p3-c2", "transformers:page-3", "transformers"),
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
merged = merge_contextual_nodes(existing, new, {"transformers:page-3"})
|
| 74 |
+
|
| 75 |
+
assert merged == existing + new
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_reprocessed_doc_id_replaces_only_its_own_nodes():
|
| 79 |
+
# If a doc_id in the new batch already has nodes in the pkl, those old
|
| 80 |
+
# nodes are replaced by the freshly generated ones; sibling docs from the
|
| 81 |
+
# same source are untouched.
|
| 82 |
+
stale = [
|
| 83 |
+
make_node("a-c1", "course:lesson-a", "course"),
|
| 84 |
+
make_node("a-c2", "course:lesson-a", "course"),
|
| 85 |
+
]
|
| 86 |
+
sibling = make_node("b-c1", "course:lesson-b", "course")
|
| 87 |
+
regenerated = [make_node("a-c1-new", "course:lesson-a", "course")]
|
| 88 |
+
|
| 89 |
+
merged = merge_contextual_nodes(stale + [sibling], regenerated, {"course:lesson-a"})
|
| 90 |
+
|
| 91 |
+
assert merged == [sibling] + regenerated
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_nodes_with_undetermined_doc_id_are_kept():
|
| 95 |
+
class OpaqueNode:
|
| 96 |
+
"""No doc_id, metadata, or source_node: doc_id lookup raises."""
|
| 97 |
+
|
| 98 |
+
opaque = OpaqueNode()
|
| 99 |
+
parseable = make_node("x-c1", "src:doc-x", "src")
|
| 100 |
+
new = [make_node("y-c1", "src:doc-y", "src")]
|
| 101 |
+
|
| 102 |
+
merged = merge_contextual_nodes([opaque, parseable], new, {"src:doc-y"})
|
| 103 |
+
|
| 104 |
+
assert merged == [opaque, parseable] + new
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_empty_existing_pkl_yields_only_new_nodes():
|
| 108 |
+
new = [make_node("n-c1", "src:doc-n", "src")]
|
| 109 |
+
|
| 110 |
+
assert merge_contextual_nodes([], new, {"src:doc-n"}) == new
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# --- Hash-forward selection: new + changed docs, legacy nodes untouched ---
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_changed_content_hash_is_reprocessed_and_old_nodes_replaced():
|
| 117 |
+
# A doc whose content changed in place (same path-stable doc_id, new
|
| 118 |
+
# content_hash) must be selected for reprocessing, and its old nodes must
|
| 119 |
+
# be replaced by the regenerated ones when merged back.
|
| 120 |
+
existing = [
|
| 121 |
+
make_node("d1-c1", "src:doc-1", "src", content_hash="sha256:old"),
|
| 122 |
+
make_node("d1-c2", "src:doc-1", "src", content_hash="sha256:old"),
|
| 123 |
+
make_node("d2-c1", "src:doc-2", "src", content_hash="sha256:same"),
|
| 124 |
+
]
|
| 125 |
+
all_docs = [
|
| 126 |
+
make_doc("src:doc-1", "sha256:new"),
|
| 127 |
+
make_doc("src:doc-2", "sha256:same"),
|
| 128 |
+
]
|
| 129 |
+
|
| 130 |
+
selected, stats = select_docs_to_process(all_docs, build_doc_hash_map(existing))
|
| 131 |
+
|
| 132 |
+
assert [doc["doc_id"] for doc in selected] == ["src:doc-1"]
|
| 133 |
+
assert stats == {"new": 0, "changed": 1, "legacy_unhashed": 0}
|
| 134 |
+
|
| 135 |
+
regenerated = [
|
| 136 |
+
make_node("d1-c1-new", "src:doc-1", "src", content_hash="sha256:new")
|
| 137 |
+
]
|
| 138 |
+
merged = merge_contextual_nodes(
|
| 139 |
+
existing, regenerated, {doc["doc_id"] for doc in selected}
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
assert merged == [existing[2]] + regenerated
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_unchanged_content_hash_is_not_reprocessed():
|
| 146 |
+
existing = [make_node("d1-c1", "src:doc-1", "src", content_hash="sha256:same")]
|
| 147 |
+
|
| 148 |
+
selected, stats = select_docs_to_process(
|
| 149 |
+
[make_doc("src:doc-1", "sha256:same")], build_doc_hash_map(existing)
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
assert selected == []
|
| 153 |
+
assert stats == {"new": 0, "changed": 0, "legacy_unhashed": 0}
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_legacy_nodes_without_hash_are_treated_as_unchanged():
|
| 157 |
+
# Nodes written before hashes were stamped must NOT be reprocessed, even
|
| 158 |
+
# if the row's hash cannot be matched: --process-all-context is the
|
| 159 |
+
# explicit one-time baseline, not an implicit mass Gemini run.
|
| 160 |
+
existing = [make_node("d1-c1", "src:doc-1", "src")]
|
| 161 |
+
stored_hashes = build_doc_hash_map(existing)
|
| 162 |
+
|
| 163 |
+
assert stored_hashes == {"src:doc-1": None}
|
| 164 |
+
|
| 165 |
+
selected, stats = select_docs_to_process(
|
| 166 |
+
[make_doc("src:doc-1", "sha256:new")], stored_hashes
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
assert selected == []
|
| 170 |
+
assert stats == {"new": 0, "changed": 0, "legacy_unhashed": 1}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_new_doc_is_still_detected():
|
| 174 |
+
existing = [make_node("d1-c1", "src:doc-1", "src", content_hash="sha256:a")]
|
| 175 |
+
|
| 176 |
+
selected, stats = select_docs_to_process(
|
| 177 |
+
[make_doc("src:doc-1", "sha256:a"), make_doc("src:doc-2", "sha256:b")],
|
| 178 |
+
build_doc_hash_map(existing),
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
assert [doc["doc_id"] for doc in selected] == ["src:doc-2"]
|
| 182 |
+
assert stats == {"new": 1, "changed": 0, "legacy_unhashed": 0}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_row_without_content_hash_is_treated_as_unchanged():
|
| 186 |
+
# Older JSONL rows may lack content_hash entirely; with no basis for
|
| 187 |
+
# comparison the doc must not be reprocessed.
|
| 188 |
+
selected, stats = select_docs_to_process(
|
| 189 |
+
[make_doc("src:doc-1", content_hash=None)], {"src:doc-1": "sha256:a"}
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
assert selected == []
|
| 193 |
+
assert stats == {"new": 0, "changed": 0, "legacy_unhashed": 0}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def test_mixed_hash_doc_uses_any_stored_hash():
|
| 197 |
+
# A doc with some hashed and some legacy-unhashed nodes (shouldn't exist,
|
| 198 |
+
# but must resolve sanely): any stored hash counts, in either node order.
|
| 199 |
+
hashed_first = [
|
| 200 |
+
make_node("d1-c1", "src:doc-1", "src", content_hash="sha256:a"),
|
| 201 |
+
make_node("d1-c2", "src:doc-1", "src"),
|
| 202 |
+
]
|
| 203 |
+
unhashed_first = list(reversed(hashed_first))
|
| 204 |
+
|
| 205 |
+
assert build_doc_hash_map(hashed_first) == {"src:doc-1": "sha256:a"}
|
| 206 |
+
assert build_doc_hash_map(unhashed_first) == {"src:doc-1": "sha256:a"}
|
| 207 |
+
|
| 208 |
+
selected, stats = select_docs_to_process(
|
| 209 |
+
[make_doc("src:doc-1", "sha256:b")], build_doc_hash_map(unhashed_first)
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
assert [doc["doc_id"] for doc in selected] == ["src:doc-1"]
|
| 213 |
+
assert stats == {"new": 0, "changed": 1, "legacy_unhashed": 0}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def test_nodes_with_undetermined_doc_id_are_skipped_in_hash_map():
|
| 217 |
+
class OpaqueNode:
|
| 218 |
+
"""No doc_id, metadata, or source_node: doc_id lookup raises."""
|
| 219 |
+
|
| 220 |
+
assert build_doc_hash_map(
|
| 221 |
+
[OpaqueNode(), make_node("d1-c1", "src:doc-1", "src", content_hash="sha256:a")]
|
| 222 |
+
) == {"src:doc-1": "sha256:a"}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# --- Producer side: the context step must stamp the hash it reads back ---
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_metadata_key_constant_matches_producer():
|
| 229 |
+
# update_docs_workflow duplicates the key to stay import-light; the copies
|
| 230 |
+
# must never drift from the producer's.
|
| 231 |
+
from data.scraping_scripts.add_context_to_nodes import (
|
| 232 |
+
DOC_CONTENT_HASH_METADATA_KEY as producer_key,
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
assert producer_key == DOC_CONTENT_HASH_METADATA_KEY
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def test_process_stamps_doc_content_hash_on_every_chunk(tmp_path):
|
| 239 |
+
# create_docs -> process is the single funnel for the full-rebuild path,
|
| 240 |
+
# the incremental path, and create_vector_stores' non-pickle path; every
|
| 241 |
+
# resulting chunk must carry the row's content_hash. No Gemini call: the
|
| 242 |
+
# context generator is stubbed out.
|
| 243 |
+
from data.scraping_scripts.add_context_to_nodes import create_docs, process
|
| 244 |
+
|
| 245 |
+
row = make_doc("src:doc-1", "sha256:abc123")
|
| 246 |
+
row["content"] = "# Doc One\n\nSome content about incremental context."
|
| 247 |
+
jsonl_path = tmp_path / "docs.jsonl"
|
| 248 |
+
jsonl_path.write_text(json.dumps(row) + "\n", encoding="utf-8")
|
| 249 |
+
|
| 250 |
+
documents = create_docs(str(jsonl_path))
|
| 251 |
+
assert documents[0].metadata["content_hash"] == "sha256:abc123"
|
| 252 |
+
|
| 253 |
+
async def fake_situate_context(_doc: str, _chunk: str) -> str:
|
| 254 |
+
return "situated"
|
| 255 |
+
|
| 256 |
+
with patch(
|
| 257 |
+
"data.scraping_scripts.add_context_to_nodes.situate_context",
|
| 258 |
+
fake_situate_context,
|
| 259 |
+
):
|
| 260 |
+
records = asyncio.run(process(documents, semaphore_limit=1))
|
| 261 |
+
|
| 262 |
+
assert records
|
| 263 |
+
assert all(
|
| 264 |
+
record.metadata[DOC_CONTENT_HASH_METADATA_KEY] == "sha256:abc123"
|
| 265 |
+
for record in records
|
| 266 |
+
)
|
| 267 |
+
# The selection round-trip closes: unchanged hash -> no reprocess,
|
| 268 |
+
# changed hash -> reprocess.
|
| 269 |
+
stored_hashes = build_doc_hash_map(records)
|
| 270 |
+
unchanged, _ = select_docs_to_process([row], stored_hashes)
|
| 271 |
+
changed, _ = select_docs_to_process(
|
| 272 |
+
[make_doc("src:doc-1", "sha256:def456")], stored_hashes
|
| 273 |
+
)
|
| 274 |
+
assert unchanged == []
|
| 275 |
+
assert [doc["doc_id"] for doc in changed] == ["src:doc-1"]
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def test_legacy_row_without_hash_produces_unhashed_nodes(tmp_path):
|
| 279 |
+
# Rows without content_hash (older JSONLs) must not stamp the field at
|
| 280 |
+
# all, so those docs land in the legacy-unhashed bucket downstream.
|
| 281 |
+
from data.scraping_scripts.add_context_to_nodes import create_docs, process
|
| 282 |
+
|
| 283 |
+
row = make_doc("src:doc-1", content_hash=None)
|
| 284 |
+
row["content"] = "# Doc One\n\nLegacy row without a content hash."
|
| 285 |
+
jsonl_path = tmp_path / "docs.jsonl"
|
| 286 |
+
jsonl_path.write_text(json.dumps(row) + "\n", encoding="utf-8")
|
| 287 |
+
|
| 288 |
+
documents = create_docs(str(jsonl_path))
|
| 289 |
+
assert "content_hash" not in documents[0].metadata
|
| 290 |
+
|
| 291 |
+
async def fake_situate_context(_doc: str, _chunk: str) -> str:
|
| 292 |
+
return "situated"
|
| 293 |
+
|
| 294 |
+
with patch(
|
| 295 |
+
"data.scraping_scripts.add_context_to_nodes.situate_context",
|
| 296 |
+
fake_situate_context,
|
| 297 |
+
):
|
| 298 |
+
records = asyncio.run(process(documents, semaphore_limit=1))
|
| 299 |
+
|
| 300 |
+
assert records
|
| 301 |
+
assert all(
|
| 302 |
+
DOC_CONTENT_HASH_METADATA_KEY not in record.metadata for record in records
|
| 303 |
+
)
|
| 304 |
+
assert build_doc_hash_map(records) == {"src:doc-1": None}
|