"""Text processing utilities for tokenization and n-gram generation.""" import re from typing import List, Tuple, Dict import nltk from collections import Counter # Download required NLTK data try: nltk.data.find('tokenizers/punkt') except LookupError: nltk.download('punkt', quiet=True) def tokenize(text: str, lowercase: bool = True) -> List[str]: """Tokenize text into words. Args: text: Input text string lowercase: Whether to convert to lowercase Returns: List of tokens """ if lowercase: text = text.lower() # Simple word tokenization tokens = re.findall(r'\b\w+\b', text) return tokens def get_ngrams(tokens: List[str], n: int) -> List[Tuple[str, ...]]: """Generate n-grams from token list. Args: tokens: List of tokens n: N-gram size Returns: List of n-gram tuples """ if n > len(tokens): return [] return [tuple(tokens[i:i+n]) for i in range(len(tokens) - n + 1)] def get_all_ngrams(tokens: List[str], max_n: int = 4) -> Dict[int, List[Tuple[str, ...]]]: """Generate all n-grams up to max_n. Args: tokens: List of tokens max_n: Maximum n-gram size Returns: Dictionary mapping n to list of n-grams """ return {n: get_ngrams(tokens, n) for n in range(1, max_n + 1)} def count_ngrams(tokens: List[str], n: int) -> Counter: """Count n-gram occurrences. Args: tokens: List of tokens n: N-gram size Returns: Counter of n-gram frequencies """ ngrams = get_ngrams(tokens, n) return Counter(ngrams) def find_matching_ngrams(ref_tokens: List[str], cand_tokens: List[str], n: int) -> List[Tuple[Tuple[str, ...], int, int]]: """Find matching n-grams between reference and candidate. Args: ref_tokens: Reference tokens cand_tokens: Candidate tokens n: N-gram size Returns: List of (ngram, ref_pos, cand_pos) tuples """ ref_ngrams = get_ngrams(ref_tokens, n) cand_ngrams = get_ngrams(cand_tokens, n) matches = [] for i, ref_ngram in enumerate(ref_ngrams): for j, cand_ngram in enumerate(cand_ngrams): if ref_ngram == cand_ngram: matches.append((ref_ngram, i, j)) return matches def get_skip_bigrams(tokens: List[str], max_skip: int = 2) -> List[Tuple[str, str]]: """Generate skip-bigrams (pairs with gaps). Args: tokens: List of tokens max_skip: Maximum number of tokens to skip Returns: List of skip-bigram tuples """ skip_bigrams = [] for i in range(len(tokens)): for j in range(i + 1, min(i + max_skip + 2, len(tokens))): skip_bigrams.append((tokens[i], tokens[j])) return skip_bigrams def highlight_matching_ngrams(text: str, matches: List[Tuple[Tuple[str, ...], int, int]], color: str = "#90EE90") -> str: """Highlight matching n-grams in text with HTML. Args: text: Original text matches: List of (ngram, start_pos, end_pos) - end_pos is length in tokens color: Highlight color Returns: HTML string with highlights """ tokens = tokenize(text, lowercase=False) if not matches: return text # Sort matches by position matches = sorted(matches, key=lambda x: x[1]) # Build highlighted text result = [] last_end = 0 for ngram, start, _ in matches: # Add text before match if start > last_end: result.append(" ".join(tokens[last_end:start])) # Add highlighted match ngram_text = " ".join(ngram) result.append(f'{ngram_text}') last_end = start + len(ngram) # Add remaining text if last_end < len(tokens): result.append(" ".join(tokens[last_end:])) return " ".join(result)