Text

Text processing helpers with explicit, inspectable behavior.

text.py 169 lines
Raw .py
"""Text processing helpers with explicit, inspectable behavior."""

import re
from typing import Dict, Iterable, List, Mapping, Sequence, Set, Tuple

from .models import Chunk, Document


TOKEN_RE = re.compile(r"[a-z0-9]+(?:[-'][a-z0-9]+)?", re.IGNORECASE)
SENTENCE_RE = re.compile(r"(?<=[.!?])\s+")

STOPWORDS: Set[str] = {
    "a",
    "an",
    "and",
    "are",
    "as",
    "at",
    "be",
    "between",
    "by",
    "did",
    "do",
    "does",
    "for",
    "from",
    "how",
    "in",
    "into",
    "is",
    "it",
    "of",
    "on",
    "or",
    "original",
    "that",
    "the",
    "their",
    "this",
    "to",
    "was",
    "were",
    "what",
    "when",
    "which",
    "who",
    "with",
}

DEFAULT_SYNONYM_GROUPS: Tuple[Tuple[str, ...], ...] = (
    ("retrieve", "retrieval", "retriever", "search", "lookup", "fetch"),
    ("generate", "generation", "generator", "decode", "decoder"),
    ("document", "documents", "passage", "passages", "chunk", "chunks", "context"),
    ("graph", "network", "linked", "relation", "relations"),
    ("question", "query", "queries"),
    ("evaluate", "evaluation", "metric", "metrics", "benchmark"),
    ("adaptive", "dynamic", "conditional", "route", "routing"),
    ("combine", "fusion", "fuse", "aggregate", "aggregation"),
)


def tokenize(text: str) -> List[str]:
    """Lowercase alphanumeric tokenization used by every reference component."""

    return [match.group(0).lower() for match in TOKEN_RE.finditer(text)]


def content_terms(text: str) -> List[str]:
    """Tokens after removing a small, visible stop-word list."""

    return [token for token in tokenize(text) if token not in STOPWORDS]


def split_sentences(text: str) -> List[str]:
    """Split prose into non-empty sentences without an external NLP model."""

    return [part.strip() for part in SENTENCE_RE.split(text.strip()) if part.strip()]


def synonym_map(groups: Sequence[Sequence[str]] = DEFAULT_SYNONYM_GROUPS) -> Dict[str, str]:
    """Map synonyms to the first term of each declared group."""

    mapping: Dict[str, str] = {}
    for group in groups:
        if not group:
            continue
        canonical = group[0].lower()
        for term in group:
            mapping[term.lower()] = canonical
    return mapping


def semantic_terms(text: str, mapping: Mapping[str, str]) -> List[str]:
    """Canonicalized terms for the dependency-free semantic proxy."""

    return [mapping.get(token, token) for token in content_terms(text)]


def chunk_document(document: Document, chunk_size: int = 120, overlap: int = 20) -> List[Chunk]:
    """Split a document into token windows.

    Token offsets, source identity, and metadata are preserved so that a final
    citation can be traced back to the original document.  The function uses
    whitespace reconstruction for portability; production systems should keep
    exact character offsets as well.
    """

    if chunk_size <= 0:
        raise ValueError("chunk_size must be positive")
    if overlap < 0 or overlap >= chunk_size:
        raise ValueError("overlap must satisfy 0 <= overlap < chunk_size")
    matches = list(TOKEN_RE.finditer(document.text))
    if not matches:
        return []
    chunks: List[Chunk] = []
    step = chunk_size - overlap
    for start in range(0, len(matches), step):
        end = min(start + chunk_size, len(matches))
        chunk_id = f"{document.id}::c{len(chunks):03d}"
        char_start = matches[start].start()
        char_end = matches[end - 1].end()
        chunks.append(
            Chunk(
                id=chunk_id,
                document_id=document.id,
                text=document.text[char_start:char_end],
                start_token=start,
                end_token=end,
                title=document.title,
                source=document.source,
                metadata=document.metadata,
            )
        )
        if end == len(matches):
            break
    return chunks


def chunk_documents(
    documents: Iterable[Document], chunk_size: int = 120, overlap: int = 20
) -> List[Chunk]:
    """Chunk a document collection in stable input order."""

    chunks: List[Chunk] = []
    for document in documents:
        chunks.extend(chunk_document(document, chunk_size=chunk_size, overlap=overlap))
    return chunks


def jaccard(left: Iterable[str], right: Iterable[str]) -> float:
    """Set Jaccard similarity with well-defined empty-set behavior."""

    left_set, right_set = set(left), set(right)
    union = left_set | right_set
    if not union:
        return 1.0
    return len(left_set & right_set) / len(union)


def ordered_unique(values: Iterable[str]) -> Tuple[str, ...]:
    """Return unique values while retaining first occurrence order."""

    seen: Set[str] = set()
    output: List[str] = []
    for value in values:
        if value not in seen:
            output.append(value)
            seen.add(value)
    return tuple(output)

Source details

src/rag_evolution/text.py · SHA-256 9522f6f001d0766a5a2642cee3e81650ffd57c872ac52f7e6e1dc6951c053848