Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion instructor/v2/dsl/citation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions tests/v2/test_citation.py
Original file line number Diff line number Diff line change
@@ -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"]