Skip to content

Commit cd17e53

Browse files
Sanjays2402adaamko
andauthored
fix: localize repeated LLM spans separately (#86)
Reserve each verbatim answer occurrence before applying judge filters so repeated span strings keep distinct offsets and metadata. Add direct and token-level regressions for duplicate, filtered, overlapping, and excess items. Co-authored-by: Kovács Ádám <adaam.ko@gmail.com>
1 parent 000db8f commit cd17e53

3 files changed

Lines changed: 61 additions & 5 deletions

File tree

‎lettucedetect/detectors/llm.py‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -288,24 +288,32 @@ def _to_spans(items: list[str | dict], answer: str, min_confidence: float = 0.0)
288288
:returns: List of spans.
289289
"""
290290
spans = []
291+
used: list[tuple[int, int]] = []
291292
for item in items:
292293
if not isinstance(item, (str, dict)):
293294
logger.warning("Skipping malformed hallucination item: %r", item)
294295
continue
295296
sub = item.get("text") if isinstance(item, dict) else item
296297
if not sub:
297298
continue
299+
match = next(
300+
(
301+
match
302+
for match in re.finditer(re.escape(sub), answer)
303+
if not any(match.start() < end and start < match.end() for start, end in used)
304+
),
305+
None,
306+
)
307+
if not match:
308+
logger.debug("Dropping span without an unused verbatim match in answer: %r", sub)
309+
continue
310+
used.append((match.start(), match.end()))
298311
if isinstance(item, dict):
299312
if item.get("is_hallucination") is False:
300313
continue
301314
confidence = item.get("confidence")
302315
if confidence is not None and confidence < min_confidence:
303316
continue
304-
# Use regex for more reliable matching
305-
match = re.search(re.escape(sub), answer)
306-
if not match:
307-
logger.debug("Dropping span not found verbatim in answer: %r", sub)
308-
continue
309317
span = {"start": match.start(), "end": match.end(), "text": sub}
310318
if isinstance(item, dict):
311319
for key in ("confidence", "reasoning", "category", "subcategory"):

‎lettucedetect/prompts/hallucination_detection.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ A substring that:
2323
## How to choose spans
2424
- Flag the **minimal** substring carrying the unsupported information; do not pad with surrounding supported text.
2525
- Copy each span **verbatim** from the answer — character for character, including punctuation, casing, and spacing. Do not paraphrase, translate, fix typos, or normalize. A span that is not an exact substring of the answer is discarded.
26+
- Return spans in their left-to-right order in the answer, with at most one item for each distinct occurrence.
2627
- If one sentence mixes supported and unsupported content, flag only the unsupported part.
2728

2829
## Steps

‎tests/test_llm_detector_pytest.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,50 @@ def test_spans_output_unchanged(self, cache_file):
148148
"text": "Spain",
149149
}
150150
]
151+
152+
153+
class TestRepeatedSpanLocalization:
154+
"""Repeated judge spans must map to distinct answer occurrences."""
155+
156+
def test_repeated_spans_keep_distinct_offsets_and_metadata(self):
157+
"""Each repeated item keeps its own occurrence and metadata."""
158+
answer = "Paris is mentioned; Paris is repeated."
159+
items: list[str | dict] = [
160+
{"text": "Paris", "confidence": 0.7, "category": "first"},
161+
{"text": "Paris", "confidence": 0.9, "category": "second"},
162+
]
163+
164+
spans = LLMDetector._to_spans(items, answer)
165+
166+
assert [(span["start"], span["category"]) for span in spans] == [
167+
(0, "first"),
168+
(20, "second"),
169+
]
170+
assert [span["confidence"] for span in spans] == [0.7, 0.9]
171+
172+
def test_filtered_item_still_reserves_its_occurrence(self):
173+
"""Filtering an item must not shift a later item's location."""
174+
answer = "Paris is mentioned; Paris is repeated."
175+
items: list[str | dict] = [
176+
{"text": "Paris", "is_hallucination": False},
177+
{"text": "Paris", "is_hallucination": True},
178+
]
179+
180+
spans = LLMDetector._to_spans(items, answer)
181+
182+
assert [(span["start"], span["end"]) for span in spans] == [(20, 25)]
183+
184+
def test_overlapping_and_excess_items_do_not_reuse_offsets(self):
185+
"""Items without a free non-overlapping occurrence are dropped."""
186+
spans = LLMDetector._to_spans(["abc", "bc", "abc"], "abc")
187+
188+
assert spans == [{"start": 0, "end": 3, "text": "abc"}]
189+
190+
def test_token_output_flags_both_repeated_occurrences(self, cache_file):
191+
"""Token projection flags every separately localized occurrence."""
192+
answer = "Paris appears and Paris repeats."
193+
detector = make_detector('{"hallucination_list": ["Paris", "Paris"]}', cache_file)
194+
195+
tokens = detector.predict_prompt("p", answer, output_format="tokens")
196+
197+
assert [token["pred"] for token in tokens] == [1, 0, 0, 1, 0]

0 commit comments

Comments
 (0)