76d991c447
Auto Update PR / update-prs (push) Has been cancelled
CI / format-check (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / live-api-tests (push) Has been cancelled
CI / plugin-integration-test (push) Has been cancelled
CI / ollama-integration-test (push) Has been cancelled
CI / test-fork-pr (push) Has been cancelled
648 lines
19 KiB
Python
648 lines
19 KiB
Python
# Copyright 2025 Google LLC.
|
||
#
|
||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||
# you may not use this file except in compliance with the License.
|
||
# You may obtain a copy of the License at
|
||
#
|
||
# http://www.apache.org/licenses/LICENSE-2.0
|
||
#
|
||
# Unless required by applicable law or agreed to in writing, software
|
||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
# See the License for the specific language governing permissions and
|
||
# limitations under the License.
|
||
|
||
"""Tokenization utilities for text.
|
||
|
||
Provides methods to split text into regex-based or Unicode-aware tokens.
|
||
Tokenization is used for alignment in `resolver.py` and for determining
|
||
sentence boundaries for smaller context use cases. This module is not used
|
||
for tokenization within the language model during inference.
|
||
"""
|
||
|
||
import abc
|
||
from collections.abc import Sequence, Set
|
||
import dataclasses
|
||
import enum
|
||
import functools
|
||
import unicodedata
|
||
|
||
import regex
|
||
|
||
from langextract.core import debug_utils
|
||
from langextract.core import exceptions
|
||
|
||
__all__ = [
|
||
"BaseTokenizerError",
|
||
"InvalidTokenIntervalError",
|
||
"SentenceRangeError",
|
||
"CharInterval",
|
||
"TokenInterval",
|
||
"TokenType",
|
||
"Token",
|
||
"TokenizedText",
|
||
"Tokenizer",
|
||
"RegexTokenizer",
|
||
"UnicodeTokenizer",
|
||
"tokenize",
|
||
"tokens_text",
|
||
"find_sentence_range",
|
||
]
|
||
|
||
|
||
class BaseTokenizerError(exceptions.LangExtractError):
|
||
"""Base class for all tokenizer-related errors."""
|
||
|
||
|
||
class InvalidTokenIntervalError(BaseTokenizerError):
|
||
"""Error raised when a token interval is invalid or out of range."""
|
||
|
||
|
||
class SentenceRangeError(BaseTokenizerError):
|
||
"""Error raised when the start token index for a sentence is out of range."""
|
||
|
||
|
||
@dataclasses.dataclass(slots=True)
|
||
class CharInterval:
|
||
"""Represents a range of character positions in the original text.
|
||
|
||
Attributes:
|
||
start_pos: The starting character index (inclusive).
|
||
end_pos: The ending character index (exclusive).
|
||
"""
|
||
|
||
start_pos: int
|
||
end_pos: int
|
||
|
||
|
||
@dataclasses.dataclass(slots=True)
|
||
class TokenInterval:
|
||
"""Represents an interval over tokens in tokenized text.
|
||
|
||
The interval is defined by a start index (inclusive) and an end index
|
||
(exclusive).
|
||
|
||
Attributes:
|
||
start_index: The index of the first token in the interval.
|
||
end_index: The index one past the last token in the interval.
|
||
"""
|
||
|
||
start_index: int = 0
|
||
end_index: int = 0
|
||
|
||
|
||
class TokenType(enum.IntEnum):
|
||
"""Enumeration of token types produced during tokenization.
|
||
|
||
Attributes:
|
||
WORD: Represents an alphabetical word token.
|
||
NUMBER: Represents a numeric token.
|
||
PUNCTUATION: Represents punctuation characters.
|
||
"""
|
||
|
||
WORD = 0
|
||
NUMBER = 1
|
||
PUNCTUATION = 2
|
||
|
||
|
||
@dataclasses.dataclass(slots=True)
|
||
class Token:
|
||
"""Represents a token extracted from text.
|
||
|
||
Each token is assigned an index and classified into a type (word, number,
|
||
punctuation, or acronym). The token also records the range of characters
|
||
(its CharInterval) that correspond to the substring from the original text.
|
||
Additionally, it tracks whether it follows a newline.
|
||
|
||
Attributes:
|
||
index: The position of the token in the sequence of tokens.
|
||
token_type: The type of the token, as defined by TokenType.
|
||
char_interval: The character interval within the original text that this
|
||
token spans.
|
||
first_token_after_newline: True if the token immediately follows a newline
|
||
or carriage return.
|
||
"""
|
||
|
||
index: int
|
||
token_type: TokenType
|
||
char_interval: CharInterval = dataclasses.field(
|
||
default_factory=lambda: CharInterval(0, 0)
|
||
)
|
||
first_token_after_newline: bool = False
|
||
|
||
|
||
@dataclasses.dataclass
|
||
class TokenizedText:
|
||
"""Holds the result of tokenizing a text string.
|
||
|
||
Attributes:
|
||
text: The text that was tokenized. For UnicodeTokenizer, this is
|
||
NOT normalized to NFC (to preserve indices).
|
||
tokens: A list of Token objects extracted from the text.
|
||
"""
|
||
|
||
text: str
|
||
tokens: list[Token] = dataclasses.field(default_factory=list)
|
||
|
||
|
||
_LETTERS_PATTERN = r"[^\W\d_]+"
|
||
_DIGITS_PATTERN = r"\d+"
|
||
# Group identical symbols (e.g. "!!") but split mixed ones.
|
||
_SYMBOLS_PATTERN = r"([^\w\s]|_)\1*"
|
||
_END_OF_SENTENCE_PATTERN = regex.compile(r"[.?!。!?\u0964][\"'”’»)\]}]*$")
|
||
|
||
_TOKEN_PATTERN = regex.compile(
|
||
rf"{_LETTERS_PATTERN}|{_DIGITS_PATTERN}|{_SYMBOLS_PATTERN}"
|
||
)
|
||
_WORD_PATTERN = regex.compile(rf"(?:{_LETTERS_PATTERN}|{_DIGITS_PATTERN})\Z")
|
||
|
||
# Abbreviations that do not end sentences.
|
||
# TODO: Evaluate removal for large-context use cases.
|
||
_KNOWN_ABBREVIATIONS = frozenset({"Mr.", "Mrs.", "Ms.", "Dr.", "Prof.", "St."})
|
||
_CLOSING_PUNCTUATION = frozenset({'"', "'", "”", "’", "»", ")", "]", "}"})
|
||
|
||
|
||
class Tokenizer(abc.ABC):
|
||
"""Abstract base class for tokenizers."""
|
||
|
||
@abc.abstractmethod
|
||
def tokenize(self, text: str) -> TokenizedText:
|
||
"""Splits text into tokens.
|
||
|
||
Args:
|
||
text: The text to tokenize.
|
||
|
||
Returns:
|
||
A TokenizedText object.
|
||
"""
|
||
|
||
|
||
class RegexTokenizer(Tokenizer):
|
||
"""Regex-based tokenizer (default).
|
||
|
||
The RegexTokenizer is faster than UnicodeTokenizer for English text because it
|
||
skips involved Unicode handling.
|
||
"""
|
||
|
||
@debug_utils.debug_log_calls
|
||
def tokenize(self, text: str) -> TokenizedText:
|
||
"""Splits text into tokens (words, digits, or punctuation).
|
||
|
||
Each token is annotated with its character position and type. Tokens
|
||
following a newline or carriage return have `first_token_after_newline`
|
||
set to True.
|
||
|
||
Args:
|
||
text: The text to tokenize.
|
||
|
||
Returns:
|
||
A TokenizedText object containing all extracted tokens.
|
||
"""
|
||
tokenized = TokenizedText(text=text)
|
||
previous_end = 0
|
||
for token_index, match in enumerate(_TOKEN_PATTERN.finditer(text)):
|
||
start_pos, end_pos = match.span()
|
||
matched_text = match.group()
|
||
token = Token(
|
||
index=token_index,
|
||
char_interval=CharInterval(start_pos=start_pos, end_pos=end_pos),
|
||
token_type=TokenType.WORD,
|
||
first_token_after_newline=False,
|
||
)
|
||
if token_index > 0:
|
||
# Optimization: Check gap without slicing.
|
||
has_newline = text.find("\n", previous_end, start_pos) != -1
|
||
if not has_newline:
|
||
has_newline = text.find("\r", previous_end, start_pos) != -1
|
||
if has_newline:
|
||
token.first_token_after_newline = True
|
||
if regex.fullmatch(_DIGITS_PATTERN, matched_text):
|
||
token.token_type = TokenType.NUMBER
|
||
elif _WORD_PATTERN.fullmatch(matched_text):
|
||
token.token_type = TokenType.WORD
|
||
else:
|
||
token.token_type = TokenType.PUNCTUATION
|
||
tokenized.tokens.append(token)
|
||
previous_end = end_pos
|
||
return tokenized
|
||
|
||
|
||
# Default tokenizer instance for backward compatibility
|
||
_DEFAULT_TOKENIZER = RegexTokenizer()
|
||
|
||
|
||
def tokenize(
|
||
text: str, tokenizer: Tokenizer = _DEFAULT_TOKENIZER
|
||
) -> TokenizedText:
|
||
"""Splits text into tokens using the provided tokenizer (default: RegexTokenizer).
|
||
|
||
Args:
|
||
text: The text to tokenize.
|
||
tokenizer: The tokenizer instance to use.
|
||
|
||
Returns:
|
||
A TokenizedText object.
|
||
"""
|
||
return tokenizer.tokenize(text)
|
||
|
||
|
||
_CJK_PATTERN = regex.compile(
|
||
r"\p{Is_Han}|\p{Is_Hiragana}|\p{Is_Katakana}|\p{Is_Hangul}"
|
||
)
|
||
_NON_SPACED_PATTERN = regex.compile(
|
||
r"\p{Is_Thai}|\p{Is_Lao}|\p{Is_Khmer}|\p{Is_Myanmar}"
|
||
)
|
||
|
||
|
||
class Sentinel:
|
||
"""Sentinel class for unique object identification."""
|
||
|
||
def __init__(self, name: str):
|
||
self.name = name
|
||
|
||
def __repr__(self) -> str:
|
||
return f"<{self.name}>"
|
||
|
||
|
||
_NO_GROUP_SCRIPT = Sentinel("NO_GROUP")
|
||
_UNKNOWN_SCRIPT = Sentinel("UNKNOWN")
|
||
_LATIN_SCRIPT = "Latin"
|
||
|
||
|
||
# Optimization: Direct mapping for common scripts avoids regex overhead.
|
||
def _get_script_fast(char: str) -> str | Sentinel:
|
||
# Fast path for ASCII: Avoids regex and unicodedata lookups.
|
||
if ord(char) < 128:
|
||
return _LATIN_SCRIPT
|
||
|
||
# Fallback to the robust regex method
|
||
return _get_common_script_cached(char)
|
||
|
||
|
||
def _classify_grapheme(g: str) -> TokenType:
|
||
if not g:
|
||
return TokenType.PUNCTUATION
|
||
c = g[0]
|
||
cat = unicodedata.category(c)
|
||
if cat.startswith("L"):
|
||
return TokenType.WORD
|
||
if cat.startswith("N"):
|
||
return TokenType.NUMBER
|
||
return TokenType.PUNCTUATION
|
||
|
||
|
||
_COMMON_SCRIPTS = [
|
||
"Latin",
|
||
"Cyrillic",
|
||
"Greek",
|
||
"Arabic",
|
||
"Hebrew",
|
||
"Devanagari",
|
||
]
|
||
|
||
_COMMON_SCRIPTS_PATTERN = regex.compile(
|
||
"|".join(
|
||
rf"(?P<{script}>\p{{Script={script}}})" for script in _COMMON_SCRIPTS
|
||
)
|
||
)
|
||
|
||
_GRAPHEME_CLUSTER_PATTERN = regex.compile(r"\X")
|
||
|
||
|
||
@functools.lru_cache(maxsize=4096)
|
||
def _get_common_script_cached(c: str) -> str | Sentinel:
|
||
"""Determines script using regex, cached for performance."""
|
||
match = _COMMON_SCRIPTS_PATTERN.match(c)
|
||
if match:
|
||
return match.lastgroup
|
||
return _UNKNOWN_SCRIPT
|
||
|
||
|
||
class UnicodeTokenizer(Tokenizer):
|
||
"""Unicode-aware tokenizer for better non-English support.
|
||
|
||
This tokenizer uses Unicode character properties (Unicode Standard Annex #29)
|
||
via the `regex` library's `\\X` pattern to correctly handle grapheme clusters
|
||
like Emojis and Hangul.
|
||
|
||
|
||
Unlike some Unicode tokenizers, this class does NOT normalize text to NFC.
|
||
This ensures that token indices exactly match the original input string.
|
||
|
||
Note: Grapheme clustering makes this tokenizer slower than RegexTokenizer.
|
||
"""
|
||
|
||
@debug_utils.debug_log_calls
|
||
def tokenize(self, text: str) -> TokenizedText:
|
||
"""Splits text into tokens using Unicode properties.
|
||
|
||
Args:
|
||
text: The text to tokenize.
|
||
|
||
Returns:
|
||
A TokenizedText object.
|
||
"""
|
||
tokens: list[Token] = []
|
||
|
||
current_start = 0
|
||
current_type = None
|
||
current_script = None
|
||
previous_end = 0
|
||
|
||
for match in regex.finditer(r"\X", text):
|
||
grapheme = match.group()
|
||
start, _ = match.span()
|
||
|
||
# 1. Handle Whitespace
|
||
if grapheme.isspace():
|
||
if current_type is not None:
|
||
self._emit_token(
|
||
tokens, text, current_start, start, current_type, previous_end
|
||
)
|
||
previous_end = start
|
||
current_type = None
|
||
current_script = None
|
||
# Keep `previous_end` to detect newlines within the whitespace gap.
|
||
continue
|
||
|
||
g_type = _classify_grapheme(grapheme)
|
||
|
||
# 2. Determine if we should merge with the current token
|
||
should_merge = False
|
||
if current_type is not None:
|
||
if current_type == g_type:
|
||
if current_type == TokenType.WORD:
|
||
# Script Check
|
||
first_char = grapheme[0]
|
||
|
||
# Fast path: Explicit NO_GROUP (CJK/Thai) never merges.
|
||
if current_script is _NO_GROUP_SCRIPT:
|
||
should_merge = False
|
||
|
||
# CJK and Non-Spaced scripts require fragmentation.
|
||
elif _CJK_PATTERN.match(first_char) or _NON_SPACED_PATTERN.match(
|
||
first_char
|
||
):
|
||
should_merge = False
|
||
|
||
else:
|
||
g_script = _get_script_fast(first_char)
|
||
# Safety: Do not merge distinct unknown scripts.
|
||
if (
|
||
current_script == g_script
|
||
and current_script is not _UNKNOWN_SCRIPT
|
||
):
|
||
should_merge = True
|
||
|
||
elif current_type == TokenType.NUMBER:
|
||
should_merge = True
|
||
|
||
elif current_type == TokenType.PUNCTUATION:
|
||
# Heuristic: Merge punctuation only if identical (e.g. "!!").
|
||
last_grapheme = text[current_start:start]
|
||
if last_grapheme == grapheme:
|
||
should_merge = True
|
||
elif len(last_grapheme) >= len(grapheme) and last_grapheme.endswith(
|
||
grapheme
|
||
):
|
||
should_merge = True
|
||
|
||
# 3. State Transition
|
||
if should_merge:
|
||
# Extend current token
|
||
pass
|
||
else:
|
||
# Flush previous token if exists
|
||
if current_type is not None:
|
||
self._emit_token(
|
||
tokens, text, current_start, start, current_type, previous_end
|
||
)
|
||
previous_end = start
|
||
|
||
# Start new token
|
||
current_start = start
|
||
current_type = g_type
|
||
|
||
# Determine script for the new token
|
||
if current_type == TokenType.WORD:
|
||
c = grapheme[0]
|
||
if _CJK_PATTERN.match(c) or _NON_SPACED_PATTERN.match(c):
|
||
current_script = _NO_GROUP_SCRIPT
|
||
else:
|
||
current_script = _get_script_fast(c)
|
||
else:
|
||
current_script = None
|
||
|
||
# 4. Flush final token
|
||
if current_type is not None:
|
||
self._emit_token(
|
||
tokens, text, current_start, len(text), current_type, previous_end
|
||
)
|
||
|
||
return TokenizedText(text=text, tokens=tokens)
|
||
|
||
def _emit_token(
|
||
self,
|
||
tokens: list[Token],
|
||
text: str,
|
||
start: int,
|
||
end: int,
|
||
token_type: TokenType,
|
||
previous_end: int,
|
||
):
|
||
"""Helper to create and append a token."""
|
||
token = Token(
|
||
index=len(tokens),
|
||
char_interval=CharInterval(start_pos=start, end_pos=end),
|
||
token_type=token_type,
|
||
first_token_after_newline=False,
|
||
)
|
||
|
||
# Check for newlines in the gap between the previous token and this one
|
||
if start > previous_end:
|
||
gap = text[previous_end:start]
|
||
if "\n" in gap or "\r" in gap:
|
||
token.first_token_after_newline = True
|
||
|
||
tokens.append(token)
|
||
|
||
|
||
def tokens_text(
|
||
tokenized_text: TokenizedText,
|
||
token_interval: TokenInterval,
|
||
) -> str:
|
||
"""Reconstructs the substring of the original text spanning a given token interval.
|
||
|
||
Args:
|
||
tokenized_text: A TokenizedText object containing token data.
|
||
token_interval: The interval specifying the range [start_index, end_index)
|
||
of tokens.
|
||
|
||
Returns:
|
||
The exact substring of the original text corresponding to the token
|
||
interval.
|
||
|
||
Raises:
|
||
InvalidTokenIntervalError: If the token_interval is invalid or out of range.
|
||
"""
|
||
if token_interval.start_index == token_interval.end_index:
|
||
return ""
|
||
|
||
if (
|
||
token_interval.start_index < 0
|
||
or token_interval.end_index > len(tokenized_text.tokens)
|
||
or token_interval.start_index > token_interval.end_index
|
||
):
|
||
|
||
raise InvalidTokenIntervalError(
|
||
f"Invalid token interval. start_index={token_interval.start_index}, "
|
||
f"end_index={token_interval.end_index}, "
|
||
f"total_tokens={len(tokenized_text.tokens)}."
|
||
)
|
||
|
||
start_token = tokenized_text.tokens[token_interval.start_index]
|
||
end_token = tokenized_text.tokens[token_interval.end_index - 1]
|
||
return tokenized_text.text[
|
||
start_token.char_interval.start_pos : end_token.char_interval.end_pos
|
||
]
|
||
|
||
|
||
def _is_end_of_sentence_token(
|
||
text: str,
|
||
tokens: Sequence[Token],
|
||
current_idx: int,
|
||
known_abbreviations: Set[str] = _KNOWN_ABBREVIATIONS,
|
||
) -> bool:
|
||
"""Checks if the punctuation token at `current_idx` ends a sentence.
|
||
|
||
A token is considered a sentence terminator and is not part of a known
|
||
abbreviation. Only searches the text corresponding to the current token.
|
||
|
||
Args:
|
||
text: The entire input text.
|
||
tokens: The sequence of Token objects.
|
||
current_idx: The current token index to check.
|
||
known_abbreviations: Abbreviations that should not count as sentence enders
|
||
(e.g., "Dr.").
|
||
|
||
Returns:
|
||
True if the token at `current_idx` ends a sentence, otherwise False.
|
||
"""
|
||
current_token_text = text[
|
||
tokens[current_idx]
|
||
.char_interval.start_pos : tokens[current_idx]
|
||
.char_interval.end_pos
|
||
]
|
||
if _END_OF_SENTENCE_PATTERN.search(current_token_text):
|
||
if current_idx > 0:
|
||
prev_token_text = text[
|
||
tokens[current_idx - 1]
|
||
.char_interval.start_pos : tokens[current_idx - 1]
|
||
.char_interval.end_pos
|
||
]
|
||
if f"{prev_token_text}{current_token_text}" in known_abbreviations:
|
||
return False
|
||
return True
|
||
return False
|
||
|
||
|
||
def _is_sentence_break_after_newline(
|
||
text: str,
|
||
tokens: Sequence[Token],
|
||
current_idx: int,
|
||
) -> bool:
|
||
"""Checks if the next token starts uppercase and follows a newline.
|
||
|
||
Args:
|
||
text: The entire input text.
|
||
tokens: The sequence of Token objects.
|
||
current_idx: The current token index.
|
||
|
||
Returns:
|
||
True if a newline is found between current_idx and current_idx+1, and
|
||
the next token (if any) begins with an uppercase character.
|
||
"""
|
||
if current_idx + 1 >= len(tokens):
|
||
return False
|
||
|
||
next_token = tokens[current_idx + 1]
|
||
|
||
if not next_token.first_token_after_newline:
|
||
return False
|
||
|
||
next_token_text = text[
|
||
next_token.char_interval.start_pos : next_token.char_interval.end_pos
|
||
]
|
||
# Assume break unless lowercase (covers numbers/quotes).
|
||
return bool(next_token_text) and not next_token_text[0].islower()
|
||
|
||
|
||
def find_sentence_range(
|
||
text: str,
|
||
tokens: Sequence[Token],
|
||
start_token_index: int,
|
||
known_abbreviations: Set[str] = _KNOWN_ABBREVIATIONS,
|
||
) -> TokenInterval:
|
||
"""Finds a 'sentence' interval from a given start index.
|
||
|
||
Sentence boundaries are defined by:
|
||
- punctuation tokens in _END_OF_SENTENCE_PATTERN
|
||
- newline breaks followed by an uppercase letter
|
||
- not abbreviations in _KNOWN_ABBREVIATIONS (e.g., "Dr.")
|
||
|
||
This favors terminating a sentence prematurely over missing a sentence
|
||
boundary, and will terminate a sentence early if the first line ends with new
|
||
line and the second line begins with a capital letter.
|
||
|
||
Args:
|
||
text: The text to analyze.
|
||
tokens: The tokens that make up `text`.
|
||
Note: For UnicodeTokenizer, use normalized text.
|
||
start_token_index: The index of the token to start the sentence from.
|
||
known_abbreviations: A set of strings that are known abbreviations and
|
||
should not be treated as sentence boundaries.
|
||
|
||
|
||
Returns:
|
||
A TokenInterval representing the sentence range [start_token_index, end). If
|
||
no sentence boundary is found, the end index will be the length of
|
||
`tokens`.
|
||
|
||
Raises:
|
||
SentenceRangeError: If `start_token_index` is out of range.
|
||
"""
|
||
if not tokens:
|
||
return TokenInterval(0, 0)
|
||
|
||
if start_token_index < 0 or start_token_index >= len(tokens):
|
||
raise SentenceRangeError(
|
||
f"start_token_index={start_token_index} out of range. "
|
||
f"Total tokens: {len(tokens)}."
|
||
)
|
||
|
||
i = start_token_index
|
||
while i < len(tokens):
|
||
if tokens[i].token_type == TokenType.PUNCTUATION:
|
||
if _is_end_of_sentence_token(text, tokens, i, known_abbreviations):
|
||
end_index = i + 1
|
||
# Consume any trailing closing punctuation (e.g. quotes, parens)
|
||
while end_index < len(tokens):
|
||
next_token_text = text[
|
||
tokens[end_index]
|
||
.char_interval.start_pos : tokens[end_index]
|
||
.char_interval.end_pos
|
||
]
|
||
if (
|
||
tokens[end_index].token_type == TokenType.PUNCTUATION
|
||
and next_token_text in _CLOSING_PUNCTUATION
|
||
):
|
||
end_index += 1
|
||
else:
|
||
break
|
||
return TokenInterval(start_index=start_token_index, end_index=end_index)
|
||
if _is_sentence_break_after_newline(text, tokens, i):
|
||
return TokenInterval(start_index=start_token_index, end_index=i + 1)
|
||
i += 1
|
||
|
||
return TokenInterval(start_index=start_token_index, end_index=len(tokens))
|