claude-os/app/core/ingestion.py
Illya e63466845f fix: Skip node_modules and common build directories during ingestion
The ingest_directory function was recursively indexing ALL files including
node_modules, which caused:
- Massive index bloat (thousands of irrelevant library files)
- Polluted search results with third-party code
- Very slow indexing times

Added SKIP_DIRECTORIES set with common directories to exclude:
- Package managers: node_modules, vendor, Pods
- Build outputs: dist, build, target, .next, .nuxt
- VCS: .git, .svn, .hg
- Caches: __pycache__, .pytest_cache, .mypy_cache, .cache
- IDE: .vscode, .idea
- Our own: .claude-os

Also added should_skip_path() helper function with wildcard support
for patterns like *.egg-info.
2025-12-08 23:33:33 -05:00

408 lines
12 KiB
Python

"""
Document ingestion pipeline for Claude OS.
Handles file upload, text extraction, chunking, embedding, and storage.
"""
import logging
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List
import fitz # PyMuPDF
from llama_index.core import Document, Settings
from llama_index.core.node_parser import SentenceSplitter
from llama_index.embeddings.ollama import OllamaEmbedding
from app.core.sqlite_manager import get_sqlite_manager
from app.core.config import Config
from app.core.markdown_preprocessor import preprocess_markdown
logger = logging.getLogger(__name__)
def extract_text_from_file(file_path: str) -> str:
"""
Extract text content from a file.
Args:
file_path: Path to the file
Returns:
str: Extracted text content
"""
file_path = Path(file_path)
extension = file_path.suffix.lower()
try:
if extension == ".pdf":
# Extract text from PDF using PyMuPDF
text = ""
with fitz.open(file_path) as doc:
for page in doc:
text += page.get_text()
return text
else:
# Read text/code files with UTF-8 encoding
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
except Exception as e:
logger.error(f"Failed to extract text from {file_path}: {e}")
return ""
def chunk_document(text: str, metadata: Dict) -> List[Document]:
"""
Chunk document text into smaller pieces with overlap.
Args:
text: Document text
metadata: Document metadata
Returns:
List of Document objects
"""
splitter = SentenceSplitter(
chunk_size=Config.CHUNK_SIZE,
chunk_overlap=Config.CHUNK_OVERLAP
)
# Create a single document
doc = Document(text=text, metadata=metadata)
# Split into chunks
nodes = splitter.get_nodes_from_documents([doc])
# Convert nodes back to documents
chunks = []
for i, node in enumerate(nodes):
chunk_metadata = metadata.copy()
chunk_metadata["chunk_index"] = i
chunk_metadata["chunk_id"] = node.node_id
chunks.append(Document(text=node.text, metadata=chunk_metadata))
return chunks
def ingest_file(
file_path: str,
collection_name: str,
filename: str
) -> Dict[str, any]:
"""
Ingest a single file into a knowledge base.
Args:
file_path: Path to the file
collection_name: Target collection name
filename: Original filename
Returns:
dict: Ingestion result with status and details
"""
try:
# Extract text
text = extract_text_from_file(file_path)
if not text.strip():
return {
"status": "error",
"filename": filename,
"error": "No text content extracted"
}
# Create base metadata
file_ext = Path(filename).suffix.lower()
metadata = {
"filename": filename,
"file_type": file_ext,
"upload_date": datetime.now().isoformat(),
"source_path": str(file_path)
}
# Preprocess markdown files
if file_ext in ['.md', '.markdown']:
try:
processed_text, enriched_metadata = preprocess_markdown(
text, filename, str(file_path)
)
text = processed_text
metadata.update(enriched_metadata)
logger.info(f"Preprocessed markdown: {filename}")
except Exception as e:
logger.warning(f"Markdown preprocessing failed for {filename}: {e}")
# Continue with original text if preprocessing fails
# Chunk document
chunks = chunk_document(text, metadata)
# Initialize embedding model
embed_model = OllamaEmbedding(
model_name=Config.OLLAMA_EMBED_MODEL,
base_url=Config.OLLAMA_HOST
)
# Get SQLite collection
pg_manager = get_sqlite_manager()
if not pg_manager.collection_exists(collection_name):
return {
"status": "error",
"filename": filename,
"error": f"Collection {collection_name} not found"
}
# Generate embeddings and add to collection
documents = []
embeddings = []
metadatas = []
ids = []
failed_chunks = 0
for i, chunk in enumerate(chunks):
try:
# Truncate very long chunks to prevent Ollama crashes
chunk_text = chunk.text
if len(chunk_text) > 8000: # Limit to ~8K characters
logger.warning(f"Truncating chunk {i} of {filename} from {len(chunk_text)} to 8000 chars")
chunk_text = chunk_text[:8000]
# Generate embedding
embedding = embed_model.get_text_embedding(chunk_text)
# Create unique ID
chunk_id = f"{filename}_{chunk.metadata['chunk_index']}_{uuid.uuid4().hex[:8]}"
# Collect data for batch insert
documents.append(chunk_text)
embeddings.append(embedding)
metadatas.append(chunk.metadata)
ids.append(chunk_id)
except Exception as e:
failed_chunks += 1
logger.warning(f"Failed to embed chunk {i} of {filename}: {e}. Skipping this chunk.")
continue
if not documents:
return {
"status": "error",
"filename": filename,
"error": f"All {len(chunks)} chunks failed to generate embeddings"
}
# Add all documents to SQLite in batch
pg_manager.add_documents(
kb_name=collection_name,
documents=documents,
embeddings=embeddings,
metadatas=metadatas,
ids=ids
)
success_msg = f"Ingested {filename}: {len(documents)}/{len(chunks)} chunks"
if failed_chunks > 0:
success_msg += f" ({failed_chunks} chunks skipped due to errors)"
logger.info(success_msg)
return {
"status": "success",
"filename": filename,
"chunks": len(chunks),
"file_type": file_ext
}
except Exception as e:
logger.error(f"Failed to ingest {filename}: {e}")
return {
"status": "error",
"filename": filename,
"error": str(e)
}
def ingest_documents(
collection_name: str,
documents: List[str],
metadatas: List[Dict]
) -> Dict[str, any]:
"""
Ingest a list of pre-processed documents into a knowledge base.
Used by hooks system for bulk ingestion.
Args:
collection_name: Target collection name
documents: List of document texts
metadatas: List of metadata dicts corresponding to each document
Returns:
dict: Ingestion result with status and details
"""
try:
# Initialize embedding model
embed_model = OllamaEmbedding(
model_name=Config.OLLAMA_EMBED_MODEL,
base_url=Config.OLLAMA_HOST
)
# Get SQLite manager
db_manager = get_sqlite_manager()
if not db_manager.collection_exists(collection_name):
return {
"status": "error",
"error": f"Collection {collection_name} not found",
"documents_processed": 0
}
# Process all documents
all_document_texts = []
all_embeddings = []
all_metadatas = []
all_ids = []
for doc_text, metadata in zip(documents, metadatas):
if not doc_text.strip():
logger.warning(f"Skipping empty document: {metadata.get('filename', 'unknown')}")
continue
# Chunk the document
chunks = chunk_document(doc_text, metadata)
# Generate embeddings for each chunk
for i, chunk in enumerate(chunks):
try:
# Truncate very long chunks to prevent Ollama crashes
chunk_text = chunk.text
if len(chunk_text) > 8000: # Limit to ~8K characters
logger.warning(f"Truncating chunk {i} from {len(chunk_text)} to 8000 chars")
chunk_text = chunk_text[:8000]
embedding = embed_model.get_text_embedding(chunk_text)
# Create unique ID
chunk_id = f"{metadata.get('filename', 'doc')}_{chunk.metadata['chunk_index']}_{uuid.uuid4().hex[:8]}"
# Collect data
all_document_texts.append(chunk_text)
all_embeddings.append(embedding)
all_metadatas.append(chunk.metadata)
all_ids.append(chunk_id)
except Exception as e:
logger.warning(f"Failed to embed chunk {i}: {e}. Skipping this chunk.")
continue
if not all_document_texts:
return {
"status": "error",
"error": "No valid documents to ingest",
"documents_processed": 0
}
# Add all documents to database in batch
db_manager.add_documents(
kb_name=collection_name,
documents=all_document_texts,
embeddings=all_embeddings,
metadatas=all_metadatas,
ids=all_ids
)
logger.info(f"Ingested {len(documents)} documents into {collection_name}: {len(all_document_texts)} total chunks")
return {
"status": "success",
"documents_processed": len(documents),
"chunks_created": len(all_document_texts),
"collection_name": collection_name
}
except Exception as e:
logger.error(f"Failed to ingest documents into {collection_name}: {e}")
return {
"status": "error",
"error": str(e),
"documents_processed": 0
}
# Directories to always skip during ingestion
SKIP_DIRECTORIES = {
'node_modules',
'.git',
'.svn',
'.hg',
'__pycache__',
'.pytest_cache',
'.mypy_cache',
'.tox',
'.nox',
'.eggs',
'*.egg-info',
'dist',
'build',
'.next',
'.nuxt',
'.output',
'coverage',
'.nyc_output',
'.cache',
'vendor',
'target', # Rust/Java
'Pods', # iOS
'.gradle',
'.idea',
'.vscode',
'.claude-os', # Our own cache
}
def should_skip_path(file_path: Path) -> bool:
"""Check if a file path should be skipped based on directory exclusions."""
for part in file_path.parts:
if part in SKIP_DIRECTORIES:
return True
# Handle wildcard patterns like *.egg-info
for pattern in SKIP_DIRECTORIES:
if '*' in pattern and part.endswith(pattern.replace('*', '')):
return True
return False
def ingest_directory(
dir_path: str,
collection_name: str
) -> List[Dict[str, any]]:
"""
Recursively ingest all supported files from a directory.
Automatically skips common non-source directories like node_modules, .git, etc.
Args:
dir_path: Path to directory
collection_name: Target collection name
Returns:
List of ingestion results
"""
results = []
dir_path = Path(dir_path)
if not dir_path.exists() or not dir_path.is_dir():
logger.error(f"Directory not found: {dir_path}")
return [{
"status": "error",
"error": f"Directory not found: {dir_path}"
}]
# Recursively find all supported files, skipping excluded directories
for file_path in dir_path.rglob("*"):
# Skip excluded directories
if should_skip_path(file_path):
continue
if file_path.is_file() and Config.is_supported_file(file_path.name):
result = ingest_file(
str(file_path),
collection_name,
file_path.name
)
results.append(result)
return results