254 lines
9.0 KiB
Python
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
|