diff --git a/CHANGELOG.md b/CHANGELOG.md index 55bb558c0..e809cea3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) --- +## [Unreleased] + +### Fixed +- **Citations**: `CitationMixin` now escapes substring quotes before fuzzy-matching them against the context, so quotes containing regex metacharacters (e.g. unbalanced parentheses or brackets) resolve their span instead of raising `regex.error` during validation. + +--- + ## [1.15.5] - 2026-06-28 ### Fixed diff --git a/instructor/v2/dsl/citation.py b/instructor/v2/dsl/citation.py index c5ccd4f04..f10454d04 100644 --- a/instructor/v2/dsl/citation.py +++ b/instructor/v2/dsl/citation.py @@ -80,7 +80,10 @@ def _get_span( ) -> Generator[tuple[int, int], None, None]: import regex - minor = quote + # Escape the quote so regex metacharacters in LLM-generated text + # (e.g. unbalanced parentheses or brackets) are matched literally + # instead of crashing the fuzzy search with a regex compile error. + minor = regex.escape(quote) major = context errs_ = 0 diff --git a/tests/v2/test_citation.py b/tests/v2/test_citation.py new file mode 100644 index 000000000..0b4558e07 --- /dev/null +++ b/tests/v2/test_citation.py @@ -0,0 +1,82 @@ +"""Tests for CitationMixin span resolution. + +Covers the regex-escaping behavior in ``_get_span``: LLM-generated quotes may +contain regex metacharacters, which must be matched literally rather than +compiled as a pattern. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("regex") + +from instructor.v2.dsl.citation import CitationMixin # noqa: E402 + + +class Answer(CitationMixin): + pass + + +def test_quote_with_regex_metacharacters_does_not_crash() -> None: + """A quote with unbalanced parentheses must resolve, not raise regex.error.""" + context = "The margin is 50% (approx) this quarter." + + answer = Answer.model_validate( + {"substring_quotes": ["50% (approx"]}, + context={"context": context}, + ) + + # The span is found and normalized back to the exact context substring. + assert answer.substring_quotes == ["50% (approx"] + + +@pytest.mark.parametrize( + "quote", + [ + "cost [USD]", # brackets + "a+b*c", # quantifiers + "path\\to\\file", # backslashes + "who? (maybe)", # optional + parens + ], +) +def test_various_metacharacter_quotes_resolve(quote: str) -> None: + context = f"prefix {quote} suffix" + + answer = Answer.model_validate( + {"substring_quotes": [quote]}, + context={"context": context}, + ) + + assert answer.substring_quotes == [quote] + + +def test_non_matching_quote_is_dropped() -> None: + """Quotes absent from the context are removed rather than kept.""" + context = "Nothing relevant here." + + answer = Answer.model_validate( + {"substring_quotes": ["(totally unrelated)"]}, + context={"context": context}, + ) + + assert answer.substring_quotes == [] + + +def test_fuzzy_matching_still_works_after_escaping() -> None: + """Escaping the literal quote must not disable fuzzy (edit-distance) matching.""" + context = "The margin is 50% (approx) this quarter." + + # One-character typo ("aprox") within the default edit budget. + answer = Answer.model_validate( + {"substring_quotes": ["50% (aprox)"]}, + context={"context": context}, + ) + + assert answer.substring_quotes == ["50% (approx)"] + + +def test_no_context_leaves_quotes_untouched() -> None: + answer = Answer.model_validate({"substring_quotes": ["anything (raw"]}) + + assert answer.substring_quotes == ["anything (raw"]