omarsol Claude Fable 5 commited on
Commit
82b28e5
·
1 Parent(s): 1bf89b7

fix(pipeline): stop incremental context runs gutting updated sources

Browse files

In 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>

data/scraping_scripts/add_context_to_nodes.py CHANGED
@@ -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={ # type: ignore
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
- return {
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}"
data/scraping_scripts/add_course_workflow.py CHANGED
@@ -37,7 +37,7 @@ import os
37
  import pickle
38
  import subprocess
39
  import sys
40
- from typing import Dict, List, Set
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 app.chroma_rag import get_chunk_record_doc_id
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
- def get_processed_doc_ids() -> Set[str]:
259
- """Get set of doc_ids that have already been processed with context."""
260
- if not os.path.exists("data/all_sources_contextual_nodes.pkl"):
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:
data/scraping_scripts/update_docs_workflow.py CHANGED
@@ -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
- --new-context-only Only process new content when adding context
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 get_processed_doc_ids() -> Set[str]:
251
- """Get set of doc_ids that have already been processed with context."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  if not os.path.exists("data/all_sources_contextual_nodes.pkl"):
253
- return set()
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 doc_ids: {e}")
261
- return set()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
 
264
  def add_context_to_nodes(new_only: bool = False) -> None:
265
- """Add context to document nodes, optionally processing only new content."""
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
- processed_ids = get_processed_doc_ids()
272
-
273
- # Filter for unprocessed documents
274
- new_docs = [doc for doc in all_docs if doc["doc_id"] not in processed_ids]
 
 
 
 
 
 
 
 
 
275
 
276
- if not new_docs:
277
- logger.info("No new documents to process")
278
  return
279
 
280
- # Save temporary JSONL with only new documents
281
  temp_file = "data/new_docs_temp.jsonl"
282
- save_jsonl(new_docs, temp_file)
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
- async def main():
297
- # First, get the list of sources being updated from the temp file
298
- updated_sources = set()
299
- with open("{temp_file}", "r") as f:
300
- for line in f:
301
- data = json.loads(line)
302
- updated_sources.add(data["source"])
303
-
304
- print(f"Updating nodes for sources: {{updated_sources}}")
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
- asyncio.run(main())
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
- result = subprocess.run(cmd)
 
 
 
 
 
 
 
 
 
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="Process all content when adding context (default: only process new content)",
 
 
 
 
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",
tests/test_incremental_context.py ADDED
@@ -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}