Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:37:14 +08:00

1405 lines
50 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.
"""Library for resolving LLM output.
In the context of this module, a "resolver" is a component designed to parse and
transform the textual output of an LLM into structured data.
"""
from __future__ import annotations
import abc
import bisect
import collections
from collections.abc import Iterator, Mapping, Sequence
import difflib
import functools
import itertools
import math
import operator
import typing
from typing import Final
import warnings
from absl import logging
from langextract.core import data
from langextract.core import exceptions
from langextract.core import format_handler as fh
from langextract.core import schema
from langextract.core import tokenizer as tokenizer_lib
class LcsSpan(typing.NamedTuple):
"""Result of _best_lcs_span: matched token count and source span."""
matches: int
start: int
end: int
@property
def span_len(self) -> int:
return 0 if self.start < 0 else self.end - self.start + 1
_FUZZY_ALIGNMENT_MIN_THRESHOLD = 0.75
_FUZZY_ALIGNMENT_MIN_DENSITY = 1 / 3
_FUZZY_ALGORITHM_LCS = "lcs"
_FUZZY_ALGORITHM_LEGACY = "legacy"
_DEFAULT_FUZZY_ALGORITHM = _FUZZY_ALGORITHM_LCS
_VALID_FUZZY_ALGORITHMS: Final[frozenset[str]] = frozenset({
_FUZZY_ALGORITHM_LCS,
_FUZZY_ALGORITHM_LEGACY,
})
_EXACT_ALGORITHM_DP = "dp"
_EXACT_ALGORITHM_DIFFLIB = "difflib"
_DEFAULT_EXACT_ALGORITHM = _EXACT_ALGORITHM_DP
_VALID_EXACT_ALGORITHMS: Final[frozenset[str]] = frozenset({
_EXACT_ALGORITHM_DP,
_EXACT_ALGORITHM_DIFFLIB,
})
# Default suffix for extraction index keys (e.g., "entity_index")
DEFAULT_INDEX_SUFFIX = "_index" # Suffix for index fields in extraction sorting
ALIGNMENT_PARAM_KEYS: Final[frozenset[str]] = frozenset({
"enable_fuzzy_alignment",
"fuzzy_alignment_threshold",
"fuzzy_alignment_algorithm",
"fuzzy_alignment_min_density",
"exact_alignment_algorithm",
"accept_match_lesser",
"suppress_parse_errors",
})
class AbstractResolver(abc.ABC):
"""Resolves LLM text outputs into structured data."""
# TODO: Review value and requirements for abstract class.
def __init__(
self,
fence_output: bool = True,
constraint: schema.Constraint = schema.Constraint(),
format_type: data.FormatType = data.FormatType.JSON,
):
"""Initializes the BaseResolver.
Delimiters are used for parsing text blocks, and are used primarily for
models that do not have constrained-decoding support.
Args:
fence_output: Whether to expect/generate fenced output (```json or
```yaml). When True, the model is prompted to generate fenced output and
the resolver expects it. When False, raw JSON/YAML is expected. If your
model utilizes schema constraints, this can generally be set to False
unless the constraint also accounts for code fence delimiters.
constraint: Applies constraint when decoding the output. Defaults to no
constraint.
format_type: The format type for the output (JSON or YAML).
"""
self._fence_output = fence_output
self._constraint = constraint
self._format_type = format_type
@property
def fence_output(self) -> bool:
"""Returns whether fenced output is expected."""
return self._fence_output
@fence_output.setter
def fence_output(self, fence_output: bool) -> None:
"""Sets whether fenced output is expected.
Args:
fence_output: Whether to expect fenced output.
"""
self._fence_output = fence_output
@property
def format_type(self) -> data.FormatType:
"""Returns the format type."""
return self._format_type
@format_type.setter
def format_type(self, new_format_type: data.FormatType) -> None:
"""Sets a new format type."""
self._format_type = new_format_type
@abc.abstractmethod
def resolve(
self,
input_text: str,
**kwargs,
) -> Sequence[data.Extraction]:
"""Run resolve function on input text.
Args:
input_text: The input text to be processed.
**kwargs: Additional arguments for subclass implementations.
Returns:
Annotated text in the form of Extractions.
"""
@abc.abstractmethod
def align(
self,
extractions: Sequence[data.Extraction],
source_text: str,
token_offset: int,
char_offset: int | None = None,
enable_fuzzy_alignment: bool = True,
fuzzy_alignment_threshold: float = _FUZZY_ALIGNMENT_MIN_THRESHOLD,
accept_match_lesser: bool = True,
*,
fuzzy_alignment_algorithm: str = _DEFAULT_FUZZY_ALGORITHM,
fuzzy_alignment_min_density: float = _FUZZY_ALIGNMENT_MIN_DENSITY,
**kwargs,
) -> Iterator[data.Extraction]:
"""Aligns extractions with source text, setting token/char intervals and alignment status.
Uses exact matching first (difflib), then fuzzy alignment fallback if
enabled.
Alignment Status Results:
- MATCH_EXACT: Perfect token-level match
- MATCH_LESSER: Partial exact match (extraction longer than matched text)
- MATCH_FUZZY: Best overlap window meets threshold (>=
fuzzy_alignment_threshold)
- None: No alignment found
Args:
extractions: Annotated extractions to align with the source text.
source_text: The text in which to align the extractions.
token_offset: The token_offset corresponding to the starting token index
of the chunk.
char_offset: The char_offset corresponding to the starting character index
of the chunk.
enable_fuzzy_alignment: Whether to use fuzzy alignment when exact matching
fails.
fuzzy_alignment_threshold: Minimum fraction of extraction tokens that
must be matched (0-1). Default 0.75.
accept_match_lesser: Whether to accept partial exact matches (MATCH_LESSER
status).
fuzzy_alignment_algorithm: "lcs" (default) or "legacy" (deprecated,
will be removed in a future release). Keyword-only.
fuzzy_alignment_min_density: Minimum ratio of matched tokens to source
span length (LCS only). Keyword-only.
**kwargs: Additional keyword arguments for provider-specific alignment.
Yields:
Aligned extractions with updated token intervals and alignment status.
"""
class ResolverParsingError(exceptions.LangExtractError):
"""Error raised when content cannot be parsed as the given format."""
class Resolver(AbstractResolver):
"""Resolver for YAML/JSON-based information extraction.
By default, extractions are returned in the order they appear in the model
output. To enable index-based sorting, set extraction_index_suffix to a
value like "_index" (the DEFAULT_INDEX_SUFFIX constant). This will sort
extractions by fields ending with that suffix (e.g., "entity_index").
Uses FormatHandler for parsing model output into extractions.
"""
def __init__(
self,
format_handler: fh.FormatHandler | None = None,
extraction_index_suffix: str | None = None,
**kwargs, # Collect legacy parameters
):
"""Constructor.
Args:
format_handler: The format handler that knows how to parse output.
extraction_index_suffix: Suffix identifying index keys that determine the
ordering of extractions.
**kwargs: Legacy parameters (fence_output, format_type, etc.) for backward
compatibility. These will be used to create a FormatHandler if one is not
provided. Support for these parameters will be removed in v2.0.0.
"""
constraint = kwargs.pop("constraint", None)
extraction_attributes_suffix = kwargs.pop(
"extraction_attributes_suffix", None
)
if format_handler is None:
if kwargs or extraction_attributes_suffix is not None:
handler_kwargs = dict(kwargs)
if extraction_attributes_suffix is not None:
handler_kwargs["attribute_suffix"] = extraction_attributes_suffix
format_handler = fh.FormatHandler.from_kwargs(**handler_kwargs)
for param in [
"fence_output",
"format_type",
"strict_fences",
"require_extractions_key",
"attribute_suffix",
]:
kwargs.pop(param, None)
else:
format_handler = fh.FormatHandler()
if kwargs:
raise TypeError(
f"got an unexpected keyword argument '{list(kwargs.keys())[0]}'"
)
constraint = constraint or schema.Constraint()
super().__init__(
fence_output=format_handler.use_fences,
format_type=format_handler.format_type,
constraint=constraint,
)
self.format_handler = format_handler
self.extraction_index_suffix = extraction_index_suffix
self._constraint = constraint
def resolve(
self,
input_text: str,
suppress_parse_errors: bool = False,
**kwargs,
) -> Sequence[data.Extraction]:
"""Runs resolve function on text with YAML/JSON extraction data.
Args:
input_text: The input text to be processed.
suppress_parse_errors: When True, logs a warning and returns []
on parse failures (FormatError) or schema/type errors (ValueError)
instead of raising.
**kwargs: Additional keyword arguments.
Returns:
Annotated text in the form of a sequence of data.Extraction objects.
Raises:
ResolverParsingError: If the content within the string cannot be parsed
due to formatting errors, or if the parsed content is not as expected.
"""
logging.debug("Starting resolver process for input text.")
logging.debug("Input Text: %s", input_text)
try:
constraint = getattr(self, "_constraint", schema.Constraint())
strict = getattr(constraint, "strict", False)
extraction_data = self.format_handler.parse_output(
input_text, strict=strict
)
logging.debug("Parsed content: %s", extraction_data)
except exceptions.FormatError as e:
if suppress_parse_errors:
logging.warning("Skipping chunk: parse error: %s", e)
return []
raise ResolverParsingError(str(e)) from e
try:
processed_extractions = self.extract_ordered_extractions(extraction_data)
except ValueError as e:
if suppress_parse_errors:
logging.warning("Skipping chunk: schema error: %s", e)
return []
raise ResolverParsingError(str(e)) from e
logging.debug("Completed the resolver process.")
return processed_extractions
def align(
self,
extractions: Sequence[data.Extraction],
source_text: str,
token_offset: int,
char_offset: int | None = None,
enable_fuzzy_alignment: bool = True,
fuzzy_alignment_threshold: float = _FUZZY_ALIGNMENT_MIN_THRESHOLD,
accept_match_lesser: bool = True,
tokenizer_inst: tokenizer_lib.Tokenizer | None = None,
*,
fuzzy_alignment_algorithm: str = _DEFAULT_FUZZY_ALGORITHM,
fuzzy_alignment_min_density: float = _FUZZY_ALIGNMENT_MIN_DENSITY,
exact_alignment_algorithm: str = _DEFAULT_EXACT_ALGORITHM,
**kwargs,
) -> Iterator[data.Extraction]:
"""Aligns annotated extractions with source text.
Args:
extractions: Annotated extractions.
source_text: The text chunk in which to align the extractions.
token_offset: The starting token index of the chunk.
char_offset: The starting character index of the chunk.
enable_fuzzy_alignment: Whether to enable fuzzy alignment fallback.
fuzzy_alignment_threshold: Minimum fraction of extraction tokens that
must be matched (0-1). Default 0.75.
accept_match_lesser: Whether to accept partial exact matches.
tokenizer_inst: Optional tokenizer instance.
fuzzy_alignment_algorithm: "lcs" (default) or "legacy" (deprecated,
will be removed in a future release). Keyword-only.
fuzzy_alignment_min_density: Minimum ratio of matched tokens to source
span length (LCS only). Keyword-only.
exact_alignment_algorithm: "dp" (default) or "difflib" (legacy).
Keyword-only.
**kwargs: Additional parameters.
Yields:
Iterator on aligned extractions.
"""
logging.debug("Starting alignment process for provided chunk text.")
if not extractions:
logging.debug(
"No extractions found in the annotated text; exiting alignment"
" process."
)
return
else:
extractions_group = [extractions]
aligner = WordAligner()
aligned_yaml_extractions = aligner.align_extractions(
extractions_group,
source_text,
token_offset,
char_offset or 0,
enable_fuzzy_alignment=enable_fuzzy_alignment,
fuzzy_alignment_threshold=fuzzy_alignment_threshold,
fuzzy_alignment_algorithm=fuzzy_alignment_algorithm,
fuzzy_alignment_min_density=fuzzy_alignment_min_density,
exact_alignment_algorithm=exact_alignment_algorithm,
accept_match_lesser=accept_match_lesser,
tokenizer_impl=tokenizer_inst,
)
logging.debug(
"Aligned extractions count: %d",
sum(len(group) for group in aligned_yaml_extractions),
)
for extraction in itertools.chain(*aligned_yaml_extractions):
logging.debug("Yielding aligned extraction: %s", extraction)
yield extraction
logging.debug("Completed alignment process for the provided source_text.")
def string_to_extraction_data(
self,
input_string: str,
) -> Sequence[Mapping[str, fh.ExtractionValueType]]:
"""Parses a YAML or JSON-formatted string into extraction data.
This method is kept for backward compatibility with tests.
It delegates to the FormatHandler for actual parsing.
Args:
input_string: A string containing YAML or JSON content.
Returns:
Sequence[Mapping[str, fh.ExtractionValueType]]: A sequence of parsed objects.
Raises:
ResolverParsingError: If the content within the string cannot be parsed.
ValueError: If the input is invalid or does not contain expected format.
"""
if not input_string or not isinstance(input_string, str):
logging.error("Input string must be a non-empty string.")
raise ValueError("Input string must be a non-empty string.")
try:
constraint = getattr(self, "_constraint", schema.Constraint())
strict = getattr(constraint, "strict", False)
return self.format_handler.parse_output(input_string, strict=strict)
except exceptions.FormatError as e:
raise ResolverParsingError(str(e)) from e
except Exception as e:
logging.exception("Failed to parse content.")
raise ResolverParsingError("Failed to parse content.") from e
def extract_ordered_extractions(
self,
extraction_data: Sequence[Mapping[str, fh.ExtractionValueType]],
) -> Sequence[data.Extraction]:
"""Extracts and orders extraction data based on their associated indexes.
This function processes a list of dictionaries, each containing pairs of
extraction class keys and their corresponding values, along with optionally
associated index keys (identified by the index_suffix). It sorts these pairs
by their indices in ascending order and excludes pairs without an index key,
returning a list of lists of tuples (extraction_class: str, extraction_text:
str).
Args:
extraction_data: A list of dictionaries. Each dictionary contains pairs
of extraction class keys and their values, along with optional index
keys.
Returns:
Extractions sorted by the index attribute or by order of appearance. If
two
extractions have the same index, their group order dictates the sorting
order.
Raises:
ValueError: If an index is not an integer, attributes are not a dict
or None, or extraction text is not a string, integer, or float.
"""
logging.debug("Starting to extract and order extractions from data.")
if not extraction_data:
logging.debug("Received empty extraction data.")
processed_extractions = []
extraction_index = 0
index_suffix = self.extraction_index_suffix
attributes_suffix = self.format_handler.attribute_suffix
for group_index, group in enumerate(extraction_data):
for extraction_class, extraction_value in group.items():
if index_suffix and extraction_class.endswith(index_suffix):
if not isinstance(extraction_value, int):
logging.debug(
"Index must be an integer. Found: %s",
type(extraction_value),
)
raise ValueError("Index must be an integer.")
continue
if attributes_suffix and extraction_class.endswith(attributes_suffix):
if not isinstance(extraction_value, (dict, type(None))):
logging.debug(
"Attributes must be a dict or None. Found: %s",
type(extraction_value),
)
raise ValueError(
"Extraction value must be a dict or None for attributes."
)
continue
if not isinstance(extraction_value, (str, int, float)):
logging.debug(
"Extraction text must be a string, integer, or float. Found: %s",
type(extraction_value),
)
raise ValueError(
"Extraction text must be a string, integer, or float."
)
if not isinstance(extraction_value, str):
extraction_value = str(extraction_value)
if index_suffix:
index_key = extraction_class + index_suffix
extraction_index = group.get(index_key, None)
if extraction_index is None:
logging.debug(
"No index value for %s. Skipping extraction.", extraction_class
)
continue
else:
extraction_index += 1
attributes = None
if attributes_suffix:
attributes_key = extraction_class + attributes_suffix
attributes = group.get(attributes_key, None)
processed_extractions.append(
data.Extraction(
extraction_class=extraction_class,
extraction_text=extraction_value,
extraction_index=extraction_index,
group_index=group_index,
attributes=attributes,
)
)
processed_extractions.sort(key=operator.attrgetter("extraction_index"))
logging.debug("Completed extraction and ordering of extractions.")
return processed_extractions
class WordAligner:
"""Aligns words between two sequences of tokens using Python's difflib."""
def __init__(self):
"""Initialize the WordAligner with difflib SequenceMatcher."""
self.matcher = difflib.SequenceMatcher(autojunk=False)
self.source_tokens: Sequence[str] | None = None
self.extraction_tokens: Sequence[str] | None = None
def _set_seqs(
self,
source_tokens: Sequence[str] | Iterator[str],
extraction_tokens: Sequence[str] | Iterator[str],
):
"""Sets the source and extraction tokens for alignment.
Args:
source_tokens: A nonempty sequence or iterator of word-level tokens from
source text.
extraction_tokens: A nonempty sequence or iterator of extraction tokens in
order for matching to the source.
"""
if isinstance(source_tokens, Iterator):
source_tokens = list(source_tokens)
if isinstance(extraction_tokens, Iterator):
extraction_tokens = list(extraction_tokens)
if not source_tokens or not extraction_tokens:
raise ValueError("Source tokens and extraction tokens cannot be empty.")
self.source_tokens = source_tokens
self.extraction_tokens = extraction_tokens
self.matcher.set_seqs(a=source_tokens, b=extraction_tokens)
def _get_matching_blocks(self) -> Sequence[tuple[int, int, int]]:
"""Utilizes difflib SequenceMatcher and returns matching blocks of tokens.
Returns:
Sequence of matching blocks between source_tokens (S) and
extraction_tokens
(E). Each block (i, j, n) conforms to: S[i:i+n] == E[j:j+n], guaranteed to
be monotonically increasing in j. Final entry is a dummy with value
(len(S), len(E), 0).
"""
if self.source_tokens is None or self.extraction_tokens is None:
raise ValueError(
"Source tokens and extraction tokens must be set before getting"
" matching blocks."
)
return self.matcher.get_matching_blocks()
def _fuzzy_align_extraction(
self,
extraction: data.Extraction,
source_tokens: list[str],
tokenized_text: tokenizer_lib.TokenizedText,
token_offset: int,
char_offset: int,
fuzzy_alignment_threshold: float = _FUZZY_ALIGNMENT_MIN_THRESHOLD,
tokenizer_impl: tokenizer_lib.Tokenizer | None = None,
) -> data.Extraction | None:
"""Fuzzy-align an extraction using difflib.SequenceMatcher on tokens.
The algorithm scans every candidate window in `source_tokens` and selects
the window with the highest SequenceMatcher `ratio`. It uses an efficient
token-count intersection as a fast pre-check to discard windows that cannot
meet the alignment threshold. A match is accepted when the ratio is ≥
`fuzzy_alignment_threshold`. This only runs on unmatched extractions, which
is usually a small subset of the total extractions.
Args:
extraction: The extraction to align.
source_tokens: The tokens from the source text.
tokenized_text: The tokenized source text.
token_offset: The token offset of the current chunk.
char_offset: The character offset of the current chunk.
fuzzy_alignment_threshold: The minimum ratio for a fuzzy match.
tokenizer_impl: Optional tokenizer instance.
Returns:
The aligned data.Extraction if successful, None otherwise.
"""
extraction_tokens = list(
_tokenize_with_lowercase(
extraction.extraction_text, tokenizer_inst=tokenizer_impl
)
)
# Work with lightly stemmed tokens so pluralisation doesn't block alignment
extraction_tokens_norm = [_normalize_token(t) for t in extraction_tokens]
if not extraction_tokens:
return None
logging.debug(
"Fuzzy aligning %r (%d tokens)",
extraction.extraction_text,
len(extraction_tokens),
)
best_ratio = 0.0
best_span: tuple[int, int] | None = None # (start_idx, window_size)
len_e = len(extraction_tokens)
max_window = len(source_tokens)
extraction_counts = collections.Counter(extraction_tokens_norm)
min_overlap = int(len_e * fuzzy_alignment_threshold)
matcher = difflib.SequenceMatcher(autojunk=False, b=extraction_tokens_norm)
for window_size in range(len_e, max_window + 1):
if window_size > len(source_tokens):
break
# Initialize for sliding window
window_deque = collections.deque(source_tokens[0:window_size])
window_counts = collections.Counter(
[_normalize_token(t) for t in window_deque]
)
for start_idx in range(len(source_tokens) - window_size + 1):
# Optimization: check if enough overlapping tokens exist before expensive
# sequence matching. This is an upper bound on the match count.
if (extraction_counts & window_counts).total() >= min_overlap:
window_tokens_norm = [_normalize_token(t) for t in window_deque]
matcher.set_seq1(window_tokens_norm)
matches = sum(size for _, _, size in matcher.get_matching_blocks())
if len_e > 0:
ratio = matches / len_e
else:
ratio = 0.0
if ratio > best_ratio:
best_ratio = ratio
best_span = (start_idx, window_size)
# Slide the window to the right
if start_idx + window_size < len(source_tokens):
# Remove the leftmost token from the count
old_token = window_deque.popleft()
old_token_norm = _normalize_token(old_token)
window_counts[old_token_norm] -= 1
if window_counts[old_token_norm] == 0:
del window_counts[old_token_norm]
# Add the new rightmost token to the deque and count
new_token = source_tokens[start_idx + window_size]
window_deque.append(new_token)
new_token_norm = _normalize_token(new_token)
window_counts[new_token_norm] += 1
if best_span and best_ratio >= fuzzy_alignment_threshold:
start_idx, window_size = best_span
try:
extraction.token_interval = tokenizer_lib.TokenInterval(
start_index=start_idx + token_offset,
end_index=start_idx + window_size + token_offset,
)
start_token = tokenized_text.tokens[start_idx]
end_token = tokenized_text.tokens[start_idx + window_size - 1]
extraction.char_interval = data.CharInterval(
start_pos=char_offset + start_token.char_interval.start_pos,
end_pos=char_offset + end_token.char_interval.end_pos,
)
extraction.alignment_status = data.AlignmentStatus.MATCH_FUZZY
return extraction
except IndexError:
logging.exception(
"Index error while setting intervals during fuzzy alignment."
)
return None
return None
def _lcs_fuzzy_align_extraction(
self,
extraction: data.Extraction,
source_tokens_norm: list[str],
tokenized_text: tokenizer_lib.TokenizedText,
token_offset: int,
char_offset: int,
fuzzy_alignment_threshold: float = _FUZZY_ALIGNMENT_MIN_THRESHOLD,
fuzzy_alignment_min_density: float = _FUZZY_ALIGNMENT_MIN_DENSITY,
tokenizer_impl: tokenizer_lib.Tokenizer | None = None,
) -> data.Extraction | None:
"""Fuzzy-align an extraction using LCS DP with coverage/density gates.
Args:
extraction: The extraction to align.
source_tokens_norm: Pre-normalized source tokens.
tokenized_text: The tokenized source text.
token_offset: Token offset of the current chunk.
char_offset: Character offset of the current chunk.
fuzzy_alignment_threshold: Minimum coverage fraction for acceptance.
fuzzy_alignment_min_density: Minimum matched-to-span density.
tokenizer_impl: Optional tokenizer instance.
Returns:
The aligned extraction on success, None otherwise.
"""
extraction_tokens = list(
_tokenize_with_lowercase(
extraction.extraction_text, tokenizer_inst=tokenizer_impl
)
)
if not extraction_tokens:
return None
extraction_tokens_norm = [_normalize_token(t) for t in extraction_tokens]
logging.debug(
"LCS fuzzy aligning %r (%d tokens)",
extraction.extraction_text,
len(extraction_tokens),
)
# Try spans by decreasing match count: a sparse max-match span may fail
# the density gate while a denser sub-match span still passes coverage.
spans = _best_lcs_spans(source_tokens_norm, extraction_tokens_norm)
accepted: LcsSpan | None = None
for k in sorted(spans.keys(), reverse=True):
candidate = spans[k]
if _accept_lcs_match(
candidate,
len(extraction_tokens_norm),
threshold=fuzzy_alignment_threshold,
min_density=fuzzy_alignment_min_density,
):
accepted = candidate
break
if accepted is None:
return None
extraction.token_interval = tokenizer_lib.TokenInterval(
start_index=accepted.start + token_offset,
end_index=accepted.end + 1 + token_offset,
)
start_token = tokenized_text.tokens[accepted.start]
end_token = tokenized_text.tokens[accepted.end]
extraction.char_interval = data.CharInterval(
start_pos=char_offset + start_token.char_interval.start_pos,
end_pos=char_offset + end_token.char_interval.end_pos,
)
extraction.alignment_status = data.AlignmentStatus.MATCH_FUZZY
return extraction
def align_extractions(
self,
extraction_groups: Sequence[Sequence[data.Extraction]],
source_text: str,
token_offset: int = 0,
char_offset: int = 0,
delim: str = "\u241F", # Unicode Symbol for unit separator
enable_fuzzy_alignment: bool = True,
fuzzy_alignment_threshold: float = _FUZZY_ALIGNMENT_MIN_THRESHOLD,
accept_match_lesser: bool = True,
tokenizer_impl: tokenizer_lib.Tokenizer | None = None,
*,
fuzzy_alignment_algorithm: str = _DEFAULT_FUZZY_ALGORITHM,
fuzzy_alignment_min_density: float = _FUZZY_ALIGNMENT_MIN_DENSITY,
exact_alignment_algorithm: str = _DEFAULT_EXACT_ALGORITHM,
) -> Sequence[Sequence[data.Extraction]]:
"""Aligns extractions with their positions in the source text.
This method takes a sequence of extractions and the source text, aligning
each extraction with its corresponding position in the source text. It
returns a sequence of extractions along with token intervals indicating the
start and
end positions of each extraction in the source text. If an extraction cannot
be
aligned, its token interval is set to None.
Args:
extraction_groups: A sequence of sequences, where each inner sequence
contains an Extraction object.
source_text: The source text against which extractions are to be aligned.
token_offset: The offset to add to the start and end indices of the token
intervals.
char_offset: The offset to add to the start and end positions of the
character intervals.
delim: Token used to separate multi-token extractions.
enable_fuzzy_alignment: Whether to use fuzzy alignment when exact matching
fails.
fuzzy_alignment_threshold: Minimum fraction of extraction tokens that
must be matched (0-1). Default 0.75.
accept_match_lesser: Whether to accept partial exact matches (MATCH_LESSER
status).
tokenizer_impl: Optional tokenizer instance.
fuzzy_alignment_algorithm: "lcs" (default) or "legacy" (deprecated,
will be removed in a future release). Keyword-only.
fuzzy_alignment_min_density: Minimum ratio of matched tokens to source
span length (LCS only). Keyword-only.
exact_alignment_algorithm: "dp" (default) aligns exact matches via
an order-preserving occurrence DP, so repeated mentions map to
successive occurrences and spans of different extractions may
overlap. "difflib" restores the legacy greedy behavior with
non-overlapping exact spans. Keyword-only.
Returns:
A sequence of extractions aligned with the source text, including token
intervals.
"""
if exact_alignment_algorithm not in _VALID_EXACT_ALGORITHMS:
raise ValueError(
f"Invalid exact_alignment_algorithm {exact_alignment_algorithm!r};"
f" expected one of {sorted(_VALID_EXACT_ALGORITHMS)}."
)
if enable_fuzzy_alignment:
if fuzzy_alignment_algorithm not in _VALID_FUZZY_ALGORITHMS:
raise ValueError(
f"Invalid fuzzy_alignment_algorithm {fuzzy_alignment_algorithm!r};"
f" expected one of {sorted(_VALID_FUZZY_ALGORITHMS)}."
)
if not 0.0 <= fuzzy_alignment_threshold <= 1.0:
raise ValueError(
"fuzzy_alignment_threshold must be in [0, 1]; got"
f" {fuzzy_alignment_threshold!r}."
)
if not 0.0 <= fuzzy_alignment_min_density <= 1.0:
raise ValueError(
"fuzzy_alignment_min_density must be in [0, 1]; got"
f" {fuzzy_alignment_min_density!r}."
)
if fuzzy_alignment_algorithm == _FUZZY_ALGORITHM_LEGACY:
warnings.warn(
"fuzzy_alignment_algorithm='legacy' is deprecated and will be"
" removed in a future release. Use the default 'lcs' algorithm.",
DeprecationWarning,
stacklevel=2,
)
logging.debug(
"WordAligner: Starting alignment of extractions with the source text."
" Extraction groups to align: %s",
extraction_groups,
)
if not extraction_groups:
logging.info("No extraction groups provided; returning empty list.")
return []
source_tokens = list(
_tokenize_with_lowercase(source_text, tokenizer_inst=tokenizer_impl)
)
delim_len = len(
list(_tokenize_with_lowercase(delim, tokenizer_inst=tokenizer_impl))
)
if delim_len != 1:
raise ValueError(f"Delimiter {delim!r} must be a single token.")
logging.debug("Using delimiter %r for extraction alignment", delim)
extraction_tokens = list(
_tokenize_with_lowercase(
f" {delim} ".join(
extraction.extraction_text
for extraction in itertools.chain(*extraction_groups)
),
tokenizer_inst=tokenizer_impl,
)
)
self._set_seqs(source_tokens, extraction_tokens)
index_to_extraction_group = {}
extraction_index = 0
for group_index, group in enumerate(extraction_groups):
logging.debug(
"Processing extraction group %d with %d extractions.",
group_index,
len(group),
)
for extraction in group:
# Validate delimiter doesn't appear in extraction text
if delim in extraction.extraction_text:
raise ValueError(
f"Delimiter {delim!r} appears inside extraction text"
f" {extraction.extraction_text!r}. This would corrupt alignment"
" mapping."
)
index_to_extraction_group[extraction_index] = (extraction, group_index)
extraction_text_tokens = list(
_tokenize_with_lowercase(
extraction.extraction_text, tokenizer_inst=tokenizer_impl
)
)
extraction_index += len(extraction_text_tokens) + delim_len
aligned_extraction_groups: list[list[data.Extraction]] = [
[] for _ in extraction_groups
]
tokenized_text = (
tokenizer_impl.tokenize(source_text)
if tokenizer_impl
else tokenizer_lib.tokenize(source_text)
)
# Track which extractions were aligned in the exact matching phase
aligned_extractions = []
exact_matches = 0
lesser_matches = 0
# Track DP-aligned extractions by id(): duplicate extractions
# compare equal, so a value-based set would conflate them.
dp_matched_extraction_ids: set[int] = set()
if exact_alignment_algorithm == _EXACT_ALGORITHM_DP:
dp_matched = _apply_monotonic_exact_matches(
[ext for ext, _ in index_to_extraction_group.values()],
source_tokens,
tokenized_text,
token_offset,
char_offset,
tokenizer_impl=tokenizer_impl,
)
exact_matches += len(dp_matched)
aligned_extractions.extend(dp_matched)
dp_matched_extraction_ids = {id(ext) for ext in dp_matched}
# Exact matching phase
for i, j, n in self._get_matching_blocks()[:-1]:
extraction, _ = index_to_extraction_group.get(j, (None, None))
if extraction is None:
logging.debug(
"No clean start index found for extraction index=%d iterating"
" Difflib matching_blocks",
j,
)
continue
if id(extraction) in dp_matched_extraction_ids:
continue
extraction.token_interval = tokenizer_lib.TokenInterval(
start_index=i + token_offset,
end_index=i + n + token_offset,
)
try:
start_token = tokenized_text.tokens[i]
end_token = tokenized_text.tokens[i + n - 1]
extraction.char_interval = data.CharInterval(
start_pos=char_offset + start_token.char_interval.start_pos,
end_pos=char_offset + end_token.char_interval.end_pos,
)
except IndexError as e:
raise IndexError(
"Failed to align extraction with source text. Extraction token"
f" interval {extraction.token_interval} does not match source text"
f" tokens {tokenized_text.tokens}."
) from e
extraction_text_len = len(
list(
_tokenize_with_lowercase(
extraction.extraction_text, tokenizer_inst=tokenizer_impl
)
)
)
if extraction_text_len < n:
raise ValueError(
"Delimiter prevents blocks greater than extraction length: "
f"extraction_text_len={extraction_text_len}, block_size={n}"
)
if extraction_text_len == n:
extraction.alignment_status = data.AlignmentStatus.MATCH_EXACT
exact_matches += 1
aligned_extractions.append(extraction)
else:
# Partial match (extraction longer than matched text)
if accept_match_lesser:
extraction.alignment_status = data.AlignmentStatus.MATCH_LESSER
lesser_matches += 1
aligned_extractions.append(extraction)
else:
# Reset intervals when not accepting lesser matches
extraction.token_interval = None
extraction.char_interval = None
extraction.alignment_status = None
# Collect unaligned extractions
unaligned_extractions = []
for extraction, _ in index_to_extraction_group.values():
if extraction not in aligned_extractions:
unaligned_extractions.append(extraction)
if enable_fuzzy_alignment and unaligned_extractions:
logging.debug(
"Starting fuzzy alignment (%s) for %d unaligned extractions",
fuzzy_alignment_algorithm,
len(unaligned_extractions),
)
src_norm = (
[_normalize_token(t) for t in source_tokens]
if fuzzy_alignment_algorithm == _FUZZY_ALGORITHM_LCS
else None
)
for extraction in unaligned_extractions:
if fuzzy_alignment_algorithm == _FUZZY_ALGORITHM_LCS:
aligned_extraction = self._lcs_fuzzy_align_extraction(
extraction,
src_norm,
tokenized_text,
token_offset,
char_offset,
fuzzy_alignment_threshold=fuzzy_alignment_threshold,
fuzzy_alignment_min_density=fuzzy_alignment_min_density,
tokenizer_impl=tokenizer_impl,
)
else:
aligned_extraction = self._fuzzy_align_extraction(
extraction,
source_tokens,
tokenized_text,
token_offset,
char_offset,
fuzzy_alignment_threshold,
tokenizer_impl=tokenizer_impl,
)
if aligned_extraction:
aligned_extractions.append(aligned_extraction)
logging.debug(
"Fuzzy alignment successful for extraction: %s",
extraction.extraction_text,
)
for extraction, group_index in index_to_extraction_group.values():
aligned_extraction_groups[group_index].append(extraction)
logging.debug(
"Final aligned extraction groups: %s", aligned_extraction_groups
)
return aligned_extraction_groups
def _find_token_occurrences(
source_tokens: Sequence[str],
extraction_tokens: Sequence[str],
) -> list[int]:
"""Returns all start indices of extraction_tokens within source_tokens."""
num_source = len(source_tokens)
num_extraction = len(extraction_tokens)
if num_extraction == 0 or num_source < num_extraction:
return []
# Normalize so the slice comparison works for any Sequence input.
expected = list(extraction_tokens)
first_token = expected[0]
return [
i
for i in range(num_source - num_extraction + 1)
# Cheap first-token filter before the O(m) slice compare.
if source_tokens[i] == first_token
and list(source_tokens[i : i + num_extraction]) == expected
]
class _ChainNode(typing.NamedTuple):
"""One selected occurrence in a monotonic match chain."""
extraction_index: int
start: int
parent: _ChainNode | None
class _FrontierEntry(typing.NamedTuple):
"""Pareto-optimal: no kept chain ends earlier with at least this weight."""
end: int
weight: int
node: _ChainNode
def _select_monotonic_matches(
occurrence_lists: Sequence[Sequence[int]],
extraction_lengths: Sequence[int],
) -> dict[int, int]:
"""Selects exact-match occurrences maximizing total matched tokens.
Chooses at most one occurrence per extraction, keeping selections in
extraction output order without overlap. Token-count weighting
preserves the legacy preference for longer extractions in contested
regions; ties prefer the earliest-ending chain, so repeated mentions
resolve to successive occurrences.
Args:
occurrence_lists: Per-extraction sorted start token indices, in
extraction output order.
extraction_lengths: Per-extraction token counts.
Returns:
Dict mapping extraction index to its selected start token index.
"""
# Frontier entries are strictly increasing in both end and weight.
frontier: list[_FrontierEntry] = []
frontier_end = operator.attrgetter("end")
def best_ending_at_or_before(position: int) -> _FrontierEntry | None:
idx = bisect.bisect_right(frontier, position, key=frontier_end)
return frontier[idx - 1] if idx else None
def insert_if_undominated(entry: _FrontierEntry) -> None:
covering = best_ending_at_or_before(entry.end)
if covering is not None and covering.weight >= entry.weight:
return
low = bisect.bisect_left(frontier, entry.end, key=frontier_end)
# Evict entries ending at or after entry.end with no more weight;
# they can never outscore entry as a predecessor.
dominated_end = low
while (
dominated_end < len(frontier)
and frontier[dominated_end].weight <= entry.weight
):
dominated_end += 1
frontier[low:dominated_end] = [entry]
for extraction_index, (occurrences, length) in enumerate(
zip(occurrence_lists, extraction_lengths, strict=True)
):
if not occurrences or length == 0:
continue
# Candidates are computed against the pre-insert frontier so an
# extraction cannot extend a chain that already contains it.
candidates = []
for start in occurrences:
predecessor = best_ending_at_or_before(start)
weight = length + (predecessor.weight if predecessor else 0)
parent = predecessor.node if predecessor else None
candidates.append(
_FrontierEntry(
start + length,
weight,
_ChainNode(extraction_index, start, parent),
)
)
for candidate in candidates:
insert_if_undominated(candidate)
if not frontier:
return {}
selection: dict[int, int] = {}
node: _ChainNode | None = frontier[-1].node
while node is not None:
selection[node.extraction_index] = node.start
node = node.parent
return selection
def _apply_monotonic_exact_matches(
ordered_extractions: Sequence[data.Extraction],
source_tokens: Sequence[str],
tokenized_text: tokenizer_lib.TokenizedText,
token_offset: int,
char_offset: int,
tokenizer_impl: tokenizer_lib.Tokenizer | None = None,
) -> list[data.Extraction]:
"""Aligns extractions exactly via the monotonic occurrence DP.
Extractions the DP cannot place are left untouched for the difflib
(lesser-match) and fuzzy fallback phases.
Args:
ordered_extractions: Extractions in model output order.
source_tokens: Lowercased source tokens.
tokenized_text: The tokenized source text, for interval lookup.
token_offset: Token offset of the current chunk.
char_offset: Character offset of the current chunk.
tokenizer_impl: Optional tokenizer instance.
Returns:
The extractions aligned by the DP.
"""
extraction_token_lists = [
list(
_tokenize_with_lowercase(
extraction.extraction_text, tokenizer_inst=tokenizer_impl
)
)
for extraction in ordered_extractions
]
selection = _select_monotonic_matches(
[
_find_token_occurrences(source_tokens, tokens)
for tokens in extraction_token_lists
],
[len(tokens) for tokens in extraction_token_lists],
)
matched: list[data.Extraction] = []
for extraction_index, start in selection.items():
extraction = ordered_extractions[extraction_index]
num_tokens = len(extraction_token_lists[extraction_index])
extraction.token_interval = tokenizer_lib.TokenInterval(
start_index=start + token_offset,
end_index=start + num_tokens + token_offset,
)
start_token = tokenized_text.tokens[start]
end_token = tokenized_text.tokens[start + num_tokens - 1]
extraction.char_interval = data.CharInterval(
start_pos=char_offset + start_token.char_interval.start_pos,
end_pos=char_offset + end_token.char_interval.end_pos,
)
extraction.alignment_status = data.AlignmentStatus.MATCH_EXACT
matched.append(extraction)
return matched
def _tokenize_with_lowercase(
text: str,
tokenizer_inst: tokenizer_lib.Tokenizer | None = None,
) -> Iterator[str]:
"""Extract and lowercase tokens from the input text into words.
This function utilizes the tokenizer module to tokenize text and yields
lowercased words.
Args:
text (str): The text to be tokenized.
tokenizer_inst: Optional tokenizer instance.
Yields:
Iterator[str]: An iterator over tokenized words.
"""
if tokenizer_inst is not None:
tokenized_pb2 = tokenizer_inst.tokenize(text)
else:
tokenized_pb2 = tokenizer_lib.tokenize(text)
original_text = tokenized_pb2.text
for token in tokenized_pb2.tokens:
start = token.char_interval.start_pos
end = token.char_interval.end_pos
token_str = original_text[start:end]
token_str = token_str.lower()
yield token_str
@functools.lru_cache(maxsize=10000)
def _normalize_token(token: str) -> str:
"""Lowercases and applies light pluralisation stemming."""
token = token.lower()
if len(token) > 3 and token.endswith("s") and not token.endswith("ss"):
token = token[:-1]
return token
_NO_MATCH = LcsSpan(matches=0, start=-1, end=-1)
def _best_lcs_spans(
source: Sequence[str],
extraction: Sequence[str],
) -> dict[int, LcsSpan]:
"""Finds the tightest source span for each achievable match count.
Runs an O(n * m^2) time / O(m^2) memory DP with rolling rows on the
i dimension. For each (i, j, k), tracks the latest source index s
such that source[s..i) contains at least k tokens of extraction[0..j)
as a subsequence. Preferring later starts produces minimum-span
results; among spans of equal length, the earliest start wins.
Tracking per match count lets the caller try a denser k-1 match if
the maximum-match span fails a density gate.
Args:
source: Normalized source tokens.
extraction: Normalized extraction tokens.
Returns:
Dict mapping each achievable match count k (1..m) to its tightest
LcsSpan. Empty dict if no matches found.
"""
n = len(source)
m = len(extraction)
if n == 0 or m == 0:
return {}
# Rolling rows: prev_row[j][k] holds span_start[i-1][j][k],
# curr_row[j][k] holds span_start[i][j][k]. k=0 is trivially
# achievable: at i, source[i..i) is empty, so s=i gives 0 matches.
prev_row = [[-1] * (m + 1) for _ in range(m + 1)]
curr_row = [[-1] * (m + 1) for _ in range(m + 1)]
for j in range(m + 1):
prev_row[j][0] = 0 # i=0 base: k=0 achievable with s=0
# best_per_k[k] = tightest span seen so far with exactly k matches.
best_per_k: dict[int, LcsSpan] = {}
for i in range(1, n + 1):
src_tok = source[i - 1]
# Initialize column j=0 of curr_row: k=0 achievable with s=i, k>0 not.
curr_row[0][0] = i
for k in range(1, m + 1):
curr_row[0][k] = -1
for j in range(1, m + 1):
curr_row[j][0] = i
matches_here = src_tok == extraction[j - 1]
for k in range(1, m + 1):
skip_source = prev_row[j][k]
skip_extraction = curr_row[j - 1][k]
best = skip_source if skip_source > skip_extraction else skip_extraction
if matches_here:
if k == 1:
match_cand = i - 1
else:
match_cand = prev_row[j - 1][k - 1]
best = max(best, match_cand)
curr_row[j][k] = best
end = i - 1
for k in range(1, m + 1):
s = curr_row[m][k]
if s < 0:
continue
existing = best_per_k.get(k)
if existing is None:
best_per_k[k] = LcsSpan(matches=k, start=s, end=end)
continue
new_len = end - s + 1
cur_len = existing.span_len
if new_len < cur_len or (new_len == cur_len and s < existing.start):
best_per_k[k] = LcsSpan(matches=k, start=s, end=end)
prev_row, curr_row = curr_row, prev_row
return best_per_k
def _best_lcs_span(
source: Sequence[str],
extraction: Sequence[str],
) -> LcsSpan:
"""Returns the max-match LCS span. Wrapper over _best_lcs_spans."""
spans = _best_lcs_spans(source, extraction)
if not spans:
return _NO_MATCH
return spans[max(spans.keys())]
def _accept_lcs_match(
span: LcsSpan,
extraction_len: int,
threshold: float = _FUZZY_ALIGNMENT_MIN_THRESHOLD,
min_density: float = _FUZZY_ALIGNMENT_MIN_DENSITY,
) -> bool:
"""Applies coverage and density gates to an LCS result.
Coverage gate (threshold): did we find enough of the extraction?
Requires matches >= ceil(extraction_len * threshold).
Density gate (min_density): is the match tight enough? Requires
matches / span_len >= min_density, rejecting scattered matches
where matched tokens are spread across too much noise.
Args:
span: LcsSpan result from _best_lcs_span.
extraction_len: Number of tokens in the extraction.
threshold: Minimum fraction of the extraction that must match.
min_density: Minimum ratio of matched tokens to span length.
"""
if span.matches == 0 or extraction_len == 0:
return False
needed = math.ceil(extraction_len * threshold)
if span.span_len <= 0:
return False
density = span.matches / span.span_len
return span.matches >= needed and density >= min_density