Files
mosaic/utils/text_utils.py

254 lines
9.0 KiB
Python

"""Text chunking, cleaning, and token estimation for Mosaic."""
import re
import hashlib
import logging
from typing import Optional
logger = logging.getLogger(__name__)
# Rough token estimation: ~4 chars per token for English text
CHARS_PER_TOKEN = 4
# Default chunk sizes
DEFAULT_CHUNK_TOKENS = 8000
DEFAULT_OVERLAP_TOKENS = 500
EMAIL_MAX_TOKENS = 4000
PDF_MAX_TOKENS = 8000
def estimate_tokens(text: str) -> int:
"""Estimate token count from text length."""
return len(text) // CHARS_PER_TOKEN
def clean_text(text: str) -> str:
"""Clean extracted text: normalize whitespace, remove control chars."""
if not text:
return ""
# Remove null bytes and other control characters (keep newlines, tabs)
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
# Normalize line endings
text = text.replace('\r\n', '\n').replace('\r', '\n')
# Collapse excessive blank lines (3+ → 2)
text = re.sub(r'\n{3,}', '\n\n', text)
# Strip trailing whitespace per line
text = '\n'.join(line.rstrip() for line in text.split('\n'))
return text.strip()
def chunk_text(text: str, doc_type: str = "text",
max_tokens: int = DEFAULT_CHUNK_TOKENS,
overlap_tokens: int = DEFAULT_OVERLAP_TOKENS,
metadata: Optional[dict] = None) -> list[dict]:
"""Chunk text using per-document-type strategy.
Args:
text: The text to chunk
doc_type: Document type (html, pdf, email, cable, text)
max_tokens: Maximum tokens per chunk
overlap_tokens: Token overlap between chunks
metadata: Optional metadata (e.g., cable headers)
Returns:
List of dicts with 'text', 'chunk_index', 'total_chunks', 'metadata'
"""
if not text:
return []
if doc_type == 'cable':
return _chunk_cable(text, max_tokens, overlap_tokens, metadata)
elif doc_type == 'pdf':
return _chunk_pdf(text, max_tokens, overlap_tokens)
elif doc_type == 'email':
return _chunk_email(text, max_tokens, overlap_tokens, metadata)
elif doc_type == 'html':
return _chunk_html(text, max_tokens, overlap_tokens)
else:
return _chunk_default(text, max_tokens, overlap_tokens)
def _chunk_cable(text: str, max_tokens: int, overlap_tokens: int,
metadata: Optional[dict] = None) -> list[dict]:
"""Chunk cable: headers as structured context, body chunked independently."""
headers = metadata or {}
header_text = ""
if headers:
header_lines = []
for key in ('SUBJECT', 'ORIGIN', 'CLASSIFICATION', 'TAGS', 'DATE'):
if key in headers:
header_lines.append(f"{key}: {headers[key]}")
header_text = '\n'.join(header_lines)
# If small enough, return as one chunk
total_tokens = estimate_tokens(header_text + '\n\n' + text)
if total_tokens <= max_tokens:
return [{'text': (header_text + '\n\n' + text).strip(),
'chunk_index': 0, 'total_chunks': 1, 'metadata': headers}]
# Chunk body, prepend headers to each chunk
body_chunks = _chunk_default(text, max_tokens - estimate_tokens(header_text) - 50,
overlap_tokens)
for i, chunk in enumerate(body_chunks):
chunk['text'] = header_text + '\n\n' + chunk['text']
chunk['metadata'] = headers
chunk['chunk_index'] = i
chunk['total_chunks'] = len(body_chunks)
return body_chunks
def _chunk_pdf(text: str, max_tokens: int, overlap_tokens: int) -> list[dict]:
"""Chunk PDF by page boundaries (indicated by form feeds or page markers)."""
# Split by form feed or common page break markers
pages = re.split(r'\f|\n-{3,}\n|\n={3,}\n', text)
pages = [p.strip() for p in pages if p.strip()]
if not pages:
return _chunk_default(text, max_tokens, overlap_tokens)
chunks = []
current_text = ""
for page in pages:
combined_tokens = estimate_tokens(current_text + '\n\n' + page)
if combined_tokens > max_tokens and current_text:
chunks.append(current_text.strip())
# Keep overlap from end of previous chunk
overlap_chars = overlap_tokens * CHARS_PER_TOKEN
current_text = current_text[-overlap_chars:] + '\n\n' + page
else:
current_text = (current_text + '\n\n' + page).strip()
if current_text.strip():
chunks.append(current_text.strip())
return [{'text': c, 'chunk_index': i, 'total_chunks': len(chunks), 'metadata': {}}
for i, c in enumerate(chunks)]
def _chunk_email(text: str, max_tokens: int, overlap_tokens: int,
metadata: Optional[dict] = None) -> list[dict]:
"""Chunk email: headers + body as atomic unit unless body is too large."""
headers = metadata or {}
total_tokens = estimate_tokens(text)
if total_tokens <= EMAIL_MAX_TOKENS:
return [{'text': text, 'chunk_index': 0, 'total_chunks': 1, 'metadata': headers}]
# Split headers from body
header_section = ""
body = text
if '\n\n' in text:
parts = text.split('\n\n', 1)
header_section = parts[0]
body = parts[1] if len(parts) > 1 else ""
# Chunk body, prepend headers
body_chunks = _chunk_default(body, max_tokens - estimate_tokens(header_section) - 50,
overlap_tokens)
for i, chunk in enumerate(body_chunks):
chunk['text'] = header_section + '\n\n' + chunk['text']
chunk['metadata'] = headers
chunk['chunk_index'] = i
chunk['total_chunks'] = len(body_chunks)
return body_chunks
def _chunk_html(text: str, max_tokens: int, overlap_tokens: int) -> list[dict]:
"""Chunk HTML: section-aware chunking preserving heading hierarchy."""
# Split on headings
sections = re.split(r'(?=\n#{1,6}\s|\n[A-Z][A-Za-z\s]{3,}\n[=-]+\n)', text)
sections = [s for s in sections if s.strip()]
if not sections or len(sections) == 1:
return _chunk_default(text, max_tokens, overlap_tokens)
chunks = []
current_text = ""
for section in sections:
combined_tokens = estimate_tokens(current_text + '\n\n' + section)
if combined_tokens > max_tokens and current_text:
chunks.append(current_text.strip())
overlap_chars = overlap_tokens * CHARS_PER_TOKEN
current_text = current_text[-overlap_chars:] + '\n\n' + section
else:
current_text = (current_text + '\n\n' + section).strip()
if current_text.strip():
chunks.append(current_text.strip())
return [{'text': c, 'chunk_index': i, 'total_chunks': len(chunks), 'metadata': {}}
for i, c in enumerate(chunks)]
def _chunk_default(text: str, max_tokens: int, overlap_tokens: int) -> list[dict]:
"""Default chunking: paragraph-aware with token overlap."""
total_tokens = estimate_tokens(text)
if total_tokens <= max_tokens:
return [{'text': text, 'chunk_index': 0, 'total_chunks': 1, 'metadata': {}}]
max_chars = max_tokens * CHARS_PER_TOKEN
overlap_chars = overlap_tokens * CHARS_PER_TOKEN
# Split into paragraphs
paragraphs = text.split('\n\n')
chunks = []
current = ""
for para in paragraphs:
if len(current) + len(para) + 2 > max_chars and current:
chunks.append(current.strip())
# Keep overlap
current = current[-overlap_chars:] + '\n\n' + para
else:
current = (current + '\n\n' + para) if current else para
if current.strip():
chunks.append(current.strip())
# If still too large (single giant paragraph), force-split
final_chunks = []
for chunk in chunks:
if len(chunk) > max_chars * 1.5:
# Force split at sentence boundaries or character limit
for sub in _force_split(chunk, max_chars, overlap_chars):
final_chunks.append(sub)
else:
final_chunks.append(chunk)
return [{'text': c, 'chunk_index': i, 'total_chunks': len(final_chunks), 'metadata': {}}
for i, c in enumerate(final_chunks)]
def _force_split(text: str, max_chars: int, overlap_chars: int) -> list[str]:
"""Force-split text at sentence boundaries or character limits."""
sentences = re.split(r'(?<=[.!?])\s+', text)
chunks = []
current = ""
for sent in sentences:
if len(current) + len(sent) + 1 > max_chars and current:
chunks.append(current.strip())
current = current[-overlap_chars:] + ' ' + sent
else:
current = (current + ' ' + sent) if current else sent
if current.strip():
chunks.append(current.strip())
return chunks
def dedup_text(texts: list[str], threshold: float = 0.95) -> list[str]:
"""Remove near-duplicate texts using hash comparison.
For exact duplicates. Near-duplicate detection would require
more sophisticated approaches (minhash, simhash, etc.)
"""
seen = set()
unique = []
for text in texts:
h = hashlib.md5(text.encode()).hexdigest()
if h not in seen:
seen.add(h)
unique.append(text)
return unique