Coverage for src/local_deep_research/web_search_engines/relevance_filter.py: 95%
194 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""LLM-based relevance filter using plain text output.
3Filters search previews by asking the LLM to return a list of relevant
4indices as plain text (e.g. ``0, 2, 5``). We parse the response with a
5regex over integers, which is robust to wrappers like "Indices: 0, 2"
6or "[0, 2, 5]" and dodges all the structured-output provider quirks
7(qwen prose-mode, function_calling latency, schema bikeshedding).
9Design notes:
10- A response with no integers in it is treated as a valid judgment
11 ("none of these results are relevant"). We do not second-guess the
12 model: if the filter says reject all, we reject all, and log a
13 warning so users can notice if their chosen model is misbehaving.
14- An empty completion (a string that is empty or whitespace only) is
15 retried once. If it is still empty it is read as "none relevant",
16 like a response with no integers, rather than as a failure: falling
17 back to unfiltered previews would fill the context with results the
18 filter was meant to remove. The warning records the provider's
19 finish reason and extra message fields so these runs can be told
20 apart in the log. A list of content blocks is read through its text
21 blocks, so a reply whose blocks hold no text (thinking only) counts
22 as empty and gets the same retry. If the retry raises, the first
23 attempt's empty judgment is kept.
24- On exception (network error, parse failure, provider outage) the
25 filter is considered unavailable, not "reject all". In that case we
26 fall back to a capped slice of the original previews so downstream
27 processing is not overwhelmed by unfiltered results.
28- The filter can split large preview lists into smaller ``batch_size``
29 chunks. Smaller batches are faster per call and tend to be more
30 reliable on weaker models which struggle to track many indices in a
31 single context. A failed individual batch is skipped (logged); only
32 a hard exception falls back to the capped slice.
33- Partial success is the common outcome under batching: some batches
34 return valid judgments, others raise or time out. Successful batches
35 are kept; failed/timed-out batches contribute nothing. The capped
36 fallback only fires when *every* batch failed to produce a result.
37"""
39import re
40import time
41from concurrent.futures import (
42 ThreadPoolExecutor,
43 TimeoutError as FuturesTimeoutError,
44 as_completed,
45)
46from datetime import UTC, datetime
47from typing import Any, Callable, Dict, List, Optional
49from langchain_ollama import ChatOllama
51from ..config.constants import DEFAULT_MAX_FILTERED_RESULTS
52from ..security.log_sanitizer import scrub_error
53from ..security.secure_logging import logger
54from ..utilities.json_utils import get_llm_response_text
56# Real wrapper chains (RateLimitedLLMWrapper -> ProcessingLLMWrapper -> base)
57# are at most 2-3 levels deep. A depth limit avoids spurious infinite chains
58# on test mocks where every getattr produces a new child object.
59_MAX_UNWRAP_DEPTH = 10
61# Matches standalone integers in the LLM response. ``\b`` ensures we
62# don't match digits glued to letters ("v2"), and the negative lookbehind
63# for ``.`` rejects the fractional part of decimals ("0.5" → just "0").
64# Combined with the prompt instruction to output ONLY the numbers, this
65# is robust against prose like "The top 3 results from 2024 are ...".
66_INT_RE = re.compile(r"(?<![\w.])\d+\b")
68# Wall-clock timeout passed to ``as_completed`` when batches run in
69# parallel. Note: this bounds the time from iteration start until all
70# parallel batches complete, not per-batch. A single batch (≤ 10
71# previews on a 9B-class local model) typically completes in 5-15s.
72# When Ollama serializes requests (OLLAMA_NUM_PARALLEL=1, the default)
73# 10 parallel submissions effectively run sequentially server-side, so
74# 10 × ~15s already brushes against the old 120s bound. 300s leaves
75# room for slow-but-progressing work on serialized backends while still
76# flagging genuine hangs. The sequential (workers==1) path does not
77# apply this — only the parallel branch uses ``as_completed``.
78# Without a bound here a stuck Ollama socket would block the pipeline
79# indefinitely, because langchain-ollama's httpx client has no default
80# socket timeout.
81_FILTER_WALL_TIMEOUT_S = 300.0
83# Calls made before an empty completion is read as "none relevant".
84_EMPTY_COMPLETION_ATTEMPTS = 2
86# Finish reasons meaning the model ran out of output tokens. Re-sending
87# the same prompt with the same limit cannot produce an answer.
88_TOKEN_LIMIT_FINISH_REASONS = frozenset({"length", "max_tokens"})
91# Per-preview snippet character cap. The snippet field often carries the
92# paper abstract for academic engines (arxiv, semantic scholar, pubmed).
93# 200 was too tight — it truncated abstracts before the judge could tell
94# whether a paper's primary topic actually matches the query, leading to
95# "paper mentions LLMs in passing" sources leaking past the filter. 800
96# comfortably fits a typical abstract opening while keeping total prompt
97# size bounded (≈16KB for 20 previews).
98_SNIPPET_CHAR_CAP = 800
101_RELEVANCE_PROMPT_TEMPLATE = """This is a relevance-filtering step. Kept results move forward and may be used in the final answer; dropped ones are excluded from further processing.
103Query: "{query}"
104Current date: {current_date}
106Search results:
107{preview_text}
109Direct topic match matters more than keyword match — results that just mention the query terms usually don't help.
111Output ONLY the 0-based indices of relevant results as a comma-separated list, nothing else.
112Example: 0, 2, 5""" # noqa: S608
115def _unwrap_llm(llm):
116 """Unwrap known LLM wrapper chains to get the base LangChain LLM.
118 Walks ``.base_llm`` attributes up to ``_MAX_UNWRAP_DEPTH`` levels.
119 The depth limit guards against test mocks (e.g. ``unittest.mock.Mock``)
120 that lazily create a fresh child object on every attribute access.
121 """
122 probe = llm
123 for _ in range(_MAX_UNWRAP_DEPTH):
124 inner = getattr(probe, "base_llm", None)
125 if inner is None or inner is probe:
126 return probe
127 probe = inner
128 return probe
131def _finish_reason(result):
132 """Return the provider's finish reason, or None.
134 OpenAI-compatible providers report ``finish_reason``, Ollama reports
135 ``done_reason`` and Anthropic reports ``stop_reason``.
136 """
137 metadata = getattr(result, "response_metadata", None)
138 if not isinstance(metadata, dict):
139 return None
140 for key in ("finish_reason", "done_reason", "stop_reason"):
141 if metadata.get(key) is not None:
142 return metadata[key]
143 return None
146def _empty_completion_is_deterministic(result) -> bool:
147 """Whether a retry of this empty completion cannot succeed.
149 True when the model hit its output token limit, or when it put its
150 output in ``reasoning_content`` instead of the message content.
151 """
152 if _finish_reason(result) in _TOKEN_LIMIT_FINISH_REASONS:
153 return True
154 extra = getattr(result, "additional_kwargs", None)
155 return isinstance(extra, dict) and bool(extra.get("reasoning_content"))
158def _describe_empty_completion(result) -> str:
159 """Summarise why a completion may be empty, without logging any text.
161 Reports the provider's finish reason, token usage, and the names of
162 any extra message fields (e.g. ``reasoning_content``, where some
163 OpenAI-compatible endpoints put the whole answer).
164 """
165 usage = getattr(result, "usage_metadata", None)
166 extra = getattr(result, "additional_kwargs", None)
167 finish_reason = _finish_reason(result)
168 return (
169 f"finish_reason={finish_reason!r}, "
170 f"usage={usage if isinstance(usage, dict) else None!r}, "
171 f"extra_fields={sorted(extra, key=str) if isinstance(extra, dict) else []}"
172 )
175def _build_batch_prompt(
176 query: str,
177 batch: List[Dict[str, Any]],
178 total_in_full: int,
179 prompt_template: str,
180) -> str:
181 """Build the relevance prompt for a single batch of previews.
183 Indices in the prompt are local to the batch (0..len(batch)-1).
184 ``total_in_full`` is the size of the original full preview list,
185 shown to the model for context — it doesn't affect the index range.
186 ``prompt_template`` is passed in (defaulting at the public entry
187 point) rather than read from a module global, so eval harnesses can
188 test variants without monkey-patching module state.
189 """
190 preview_lines = []
191 for i, preview in enumerate(batch):
192 title = preview.get("title", "Untitled").strip()
193 snippet = preview.get("snippet", "").strip()
194 if len(snippet) > _SNIPPET_CHAR_CAP:
195 snippet = snippet[:_SNIPPET_CHAR_CAP] + "..."
196 preview_lines.append(f"[{i}] {title}\n {snippet}")
198 return prompt_template.format(
199 query=query,
200 current_date=datetime.now(UTC).strftime("%Y-%m-%d"),
201 preview_text="\n\n".join(preview_lines),
202 )
205def _run_batch(
206 llm,
207 batch: List[Dict[str, Any]],
208 query: str,
209 total_in_full: int,
210 engine_name: str,
211 prompt_template: str,
212 deadline: Optional[float] = None,
213 on_empty_completion: Optional[Callable[[], None]] = None,
214) -> List[int]:
215 """Invoke the LLM on a single batch and return the parsed local indices.
217 Empty list = "none relevant" (valid judgment). Raises on LLM
218 exceptions; the caller falls back to a capped slice in that case.
219 ``deadline`` and ``on_empty_completion`` are passed to ``_invoke_text``.
220 """
221 prompt = _build_batch_prompt(query, batch, total_in_full, prompt_template)
222 return _invoke_text(
223 llm,
224 prompt,
225 engine_name,
226 deadline=deadline,
227 on_empty_completion=on_empty_completion,
228 )
231def filter_previews_for_relevance(
232 llm,
233 previews: List[Dict[str, Any]],
234 query: str,
235 max_filtered_results: Optional[int] = None,
236 engine_name: str = "",
237 batch_size: Optional[int] = None,
238 max_parallel_batches: int = 1,
239 prompt_template: Optional[str] = None,
240) -> List[Dict[str, Any]]:
241 """Filter search previews for relevance via plain-text LLM output.
243 Args:
244 llm: LangChain LLM instance (may be wrapped)
245 previews: List of preview dicts with title/snippet/url
246 query: The search query
247 max_filtered_results: Optional cap on the final result count
248 (None = LLM decides)
249 engine_name: Engine class name for log messages
250 batch_size: If set and smaller than ``len(previews)``, the LLM
251 is called once per batch of this many previews. Smaller
252 batches are faster per call and more reliable on weaker
253 models. None or 0 disables batching (single-call mode).
254 max_parallel_batches: Number of batches to dispatch concurrently
255 against the LLM (via a thread pool). 1 = sequential.
256 Most providers (Ollama with OLLAMA_NUM_PARALLEL>1, OpenAI,
257 Anthropic) handle concurrent requests fine. Ignored when
258 there is only one batch.
259 prompt_template: Override the built-in ``_RELEVANCE_PROMPT_TEMPLATE``.
260 Used by eval harnesses that want to compare variants without
261 mutating module state. None (default) uses the shipping
262 template.
264 Returns:
265 Filtered list of preview dicts (subset of input). Order matches
266 the original preview order across batches.
267 """
268 if prompt_template is None: 268 ↛ 270line 268 didn't jump to line 270 because the condition on line 268 was always true
269 prompt_template = _RELEVANCE_PROMPT_TEMPLATE
270 if not previews: 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true
271 return []
273 for i, preview in enumerate(previews):
274 title = preview.get("title", "Untitled").strip()
275 logger.debug(f"[{engine_name}] INPUT [{i}]: {title[:80]}")
277 # Cap used when the filter is unavailable (LLM exception) so we
278 # don't flood downstream processing with unfiltered results.
279 unavailable_cap = max_filtered_results or DEFAULT_MAX_FILTERED_RESULTS
281 # Determine batch boundaries. A batch_size of None or 0 means
282 # "single batch" — process all previews in one LLM call.
283 effective_batch = (
284 batch_size if (batch_size and batch_size > 0) else len(previews)
285 )
286 batch_starts = list(range(0, len(previews), effective_batch))
287 batches = [previews[s : s + effective_batch] for s in batch_starts]
289 workers = max(1, min(max_parallel_batches, len(batches)))
290 logger.debug(
291 f"[{engine_name}] Dispatching {len(batches)} batch(es) of "
292 f"<= {effective_batch} previews each, {workers} parallel worker(s)"
293 )
295 t0 = time.monotonic()
296 # None marks "batch never completed" (exception or timeout). A
297 # completed batch is List[int], possibly empty (valid "none relevant"
298 # judgment). We distinguish the two so an all-None outcome falls
299 # back to the capped-slice while all-[] is kept as a valid decision.
300 results_per_batch: List[Optional[List[int]]] = [None for _ in batches]
302 if workers == 1:
303 for i, batch in enumerate(batches):
304 try:
305 results_per_batch[i] = _run_batch(
306 llm,
307 batch,
308 query,
309 len(previews),
310 engine_name,
311 prompt_template,
312 )
313 except Exception as e:
314 safe_msg = scrub_error(e)
315 logger.exception(
316 f"[{engine_name}] batch {i} failed — skipping its results ({type(e).__name__}): {safe_msg}"
317 )
318 else:
319 # Not using ``with ThreadPoolExecutor(...) as pool:`` on purpose.
320 # ``__exit__`` calls ``shutdown(wait=True)`` which blocks on any
321 # still-running worker — defeating the whole point of the
322 # timeout when a worker is stuck on an Ollama HTTP call. We
323 # manage the pool lifetime explicitly and always shut down with
324 # ``wait=False`` so timed-out batches are orphaned (they'll
325 # error out when the socket eventually fails) rather than
326 # blocking the caller.
327 #
328 # A batch whose first completion was empty already has a valid
329 # "none relevant" judgment; its retry is optional. If that retry
330 # is still running at the wall timeout, the batch keeps the
331 # empty judgment instead of being counted as failed, so an
332 # abandoned retry cannot bring back the unfiltered fallback.
333 # Retries are also not started when they would not fit before
334 # the shared deadline.
335 deadline = t0 + _FILTER_WALL_TIMEOUT_S
336 judged_empty = [False for _ in batches]
337 collected: set = set()
339 def _mark_judged_empty(i: int) -> Callable[[], None]:
340 def _mark() -> None:
341 judged_empty[i] = True
343 return _mark
345 pool = ThreadPoolExecutor(max_workers=workers)
346 try:
347 futures_to_idx = {
348 pool.submit(
349 _run_batch,
350 llm,
351 batch,
352 query,
353 len(previews),
354 engine_name,
355 prompt_template,
356 deadline,
357 _mark_judged_empty(i),
358 ): i
359 for i, batch in enumerate(batches)
360 }
361 try:
362 for fut in as_completed(
363 futures_to_idx, timeout=_FILTER_WALL_TIMEOUT_S
364 ):
365 i = futures_to_idx[fut]
366 collected.add(i)
367 try:
368 results_per_batch[i] = fut.result()
369 except Exception as e:
370 safe_msg = scrub_error(e)
371 logger.exception(
372 f"[{engine_name}] batch {i} failed — skipping ({type(e).__name__}): {safe_msg}"
373 )
374 except FuturesTimeoutError:
375 # A future can finish between the iterator timing out and
376 # this scan. Take the result of any that did, then treat
377 # every batch that is still unresolved the same way,
378 # whether or not its future has finished since.
379 for f, i in futures_to_idx.items():
380 if i in collected or not f.done() or f.exception():
381 continue
382 collected.add(i)
383 results_per_batch[i] = f.result()
384 pending = [i for i in range(len(batches)) if i not in collected]
385 logger.warning(
386 f"[{engine_name}] {len(pending)} batch(es) unresolved "
387 f"after {_FILTER_WALL_TIMEOUT_S}s, abandoning them "
388 f"(pending indices: {pending})"
389 )
390 for i in pending:
391 if judged_empty[i]:
392 results_per_batch[i] = []
393 logger.warning(
394 f"[{engine_name}] batch {i}: retry of an empty "
395 f"completion abandoned; keeping the first "
396 f"attempt's empty judgment (no relevant results)"
397 )
398 finally:
399 # ``cancel_futures=True`` only cancels queued futures the
400 # executor hasn't started yet; already-running workers stay
401 # alive until their blocking call returns. We accept that —
402 # the alternative is hanging forever.
403 pool.shutdown(wait=False, cancel_futures=True)
404 total_elapsed = time.monotonic() - t0
406 # If no batch even completed, fall back to an unfiltered capped slice
407 # — otherwise downstream would receive zero results from a filter
408 # outage rather than from a valid "all irrelevant" judgment.
409 if all(r is None for r in results_per_batch):
410 logger.error(
411 f"[{engine_name}] every relevance-filter batch failed — "
412 f"returning first {unavailable_cap} previews as fallback"
413 )
414 return previews[:unavailable_cap]
416 # Normalise None → [] for the aggregation pass below; a batch that
417 # was cancelled or errored just contributes nothing. Assign to a
418 # new variable with a narrower type so mypy can prove the list
419 # elements are no longer Optional.
420 completed_batches: List[List[int]] = [
421 [] if r is None else r for r in results_per_batch
422 ]
424 # Aggregate results in original batch order so the final list
425 # mirrors the input ordering across batches.
426 ranked_results: List[Dict[str, Any]] = []
427 kept_indices: List[int] = []
428 seen: set = set()
430 for batch_start, batch_result in zip(batch_starts, completed_batches):
431 batch_len = min(effective_batch, len(previews) - batch_start)
432 for li in batch_result:
433 if not (0 <= li < batch_len):
434 logger.warning(
435 f"[{engine_name}] Relevance filter returned out-of-range index: "
436 f"{li} for batch starting at {batch_start} (batch size {batch_len})"
437 )
438 continue
439 global_idx = batch_start + li
440 if global_idx in seen:
441 continue
442 seen.add(global_idx)
443 ranked_results.append(previews[global_idx])
444 kept_indices.append(global_idx)
446 logger.info(
447 f"[{engine_name}] LLM relevance filter took {total_elapsed:.1f}s "
448 f"across {len(batches)} batch(es) ({workers} parallel) "
449 f"for {len(previews)} previews"
450 )
452 # Empty result is a valid LLM judgment ("none relevant"). Log a
453 # warning/error on larger batches so users can notice a misbehaving model,
454 # but do not override the decision.
455 if not ranked_results and len(previews) > 2:
456 completed_non_none = [r for r in results_per_batch if r is not None]
457 empty_parses_count = sum(1 for r in completed_non_none if len(r) == 0)
458 logger.error(
459 f"[{engine_name}] LLM relevance filter parser returned empty list for all completed batches: "
460 f"kept 0 of {len(previews)} across {empty_parses_count}/{len(batches)} empty parses. "
461 f"If this is unexpected, verify your model returns the index list as requested (see prior log lines at DEBUG level for raw response)."
462 )
464 # Apply cap if set, keeping ranked_results and kept_indices aligned.
465 if (
466 max_filtered_results is not None
467 and len(ranked_results) > max_filtered_results
468 ):
469 ranked_results = ranked_results[:max_filtered_results]
470 kept_indices = kept_indices[:max_filtered_results]
472 # Log kept/removed/skipped. A preview is "skipped" when its batch
473 # raised or timed out — the judge never saw it — which is distinct
474 # from "removed" (judge returned a verdict that dropped it).
475 skipped_indices: set = set()
476 for i, (batch_start, r) in enumerate(zip(batch_starts, results_per_batch)):
477 if r is None: 477 ↛ 478line 477 didn't jump to line 478 because the condition on line 477 was never true
478 batch_len = min(effective_batch, len(previews) - batch_start)
479 skipped_indices.update(range(batch_start, batch_start + batch_len))
480 removed_indices = (
481 set(range(len(previews))) - set(kept_indices) - skipped_indices
482 )
483 logger.info(
484 f"[{engine_name}] Relevance filter: "
485 f"kept {len(ranked_results)} of {len(previews)} results"
486 )
487 for idx in kept_indices:
488 title = previews[idx].get("title", "Untitled")[:80]
489 logger.debug(f"[{engine_name}] KEPT [{idx}]: {title}")
490 for idx in sorted(removed_indices):
491 title = previews[idx].get("title", "Untitled")[:80]
492 logger.debug(f"[{engine_name}] REMOVED [{idx}]: {title}")
493 for idx in sorted(skipped_indices): 493 ↛ 494line 493 didn't jump to line 494 because the loop on line 493 never started
494 title = previews[idx].get("title", "Untitled")[:80]
495 logger.debug(f"[{engine_name}] SKIPPED [{idx}]: {title}")
497 return ranked_results
500def _invoke_text(
501 llm,
502 prompt: str,
503 engine_name: str,
504 deadline: Optional[float] = None,
505 on_empty_completion: Optional[Callable[[], None]] = None,
506) -> List[int]:
507 """Invoke the LLM with a plain text prompt and parse out integer indices.
509 Returns the list of parsed ints (empty list = "no integers found",
510 treated as a valid "none relevant" judgment by the caller).
511 Range/dedup validation happens in ``filter_previews_for_relevance``.
513 An empty completion is retried once; if every attempt is empty the
514 result is an empty list, the same "none relevant" judgment. The retry
515 is skipped when it cannot succeed (token limit reached, or the output
516 went to ``reasoning_content``), or when ``deadline`` (a
517 ``time.monotonic()`` value) leaves less time than the empty attempt
518 took. If the retry raises, the first attempt's empty judgment is kept
519 and the result is an empty list; an exception on the first attempt is
520 raised. ``on_empty_completion`` is called once the first empty
521 completion has been read, before any retry: from then on the batch has
522 an empty judgment even if the retry never finishes.
523 """
524 # Ollama thinking-by-default models (qwen3 dense variants, etc.)
525 # burn 30-60s on CoT before emitting the answer. Index selection does
526 # not benefit from reasoning, so suppress it on Ollama where supported.
527 base_llm = _unwrap_llm(llm)
528 invoke_kwargs = {}
529 if isinstance(base_llm, ChatOllama): 529 ↛ 530line 529 didn't jump to line 530 because the condition on line 529 was never true
530 invoke_kwargs["reasoning"] = False
532 for attempt in range(1, _EMPTY_COMPLETION_ATTEMPTS + 1):
533 started = time.monotonic()
534 try:
535 result = llm.invoke(prompt, **invoke_kwargs)
536 except Exception as exc:
537 safe_msg = scrub_error(exc)
538 if attempt == 1:
539 logger.warning(
540 f"[{engine_name}] LLM invocation failed ({type(exc).__name__}): {safe_msg}"
541 )
542 raise
543 # Only the retry of an empty completion gets here, so the first
544 # attempt already judged the batch empty; keep that judgment.
545 # logger.exception() keeps the traceback the batch failure
546 # handlers used to log for this case, behind the same gate.
547 logger.exception(
548 f"[{engine_name}] the retry of an empty completion failed "
549 f"({type(exc).__name__}): {safe_msg}; treating it as no "
550 f"relevant results"
551 )
552 return []
554 # LangChain chat models return a Message; LLMs return a string. A
555 # thinking-capable model (Anthropic extended-thinking, some Ollama
556 # reasoning models) can return ``.content`` as a list of blocks
557 # (``[{"type": "thinking", ...}, {"type": "text", "text": "0, 2"}]``)
558 # rather than a plain string. get_llm_response_text() handles both
559 # shapes, joining text blocks and dropping thinking/tool_use ones,
560 # so a reply with no text block counts as an empty completion.
561 text = get_llm_response_text(result)
562 if text.strip():
563 break
564 elapsed = time.monotonic() - started
565 logger.warning(
566 f"[{engine_name}] LLM returned an empty completion "
567 f"(attempt {attempt} of {_EMPTY_COMPLETION_ATTEMPTS}, "
568 f"{elapsed:.1f}s; {_describe_empty_completion(result)})"
569 )
570 if _empty_completion_is_deterministic(result):
571 logger.warning(
572 f"[{engine_name}] not retrying: the same prompt and token "
573 f"limit cannot produce an answer; treating it as no "
574 f"relevant results"
575 )
576 return []
577 if on_empty_completion is not None and attempt == 1:
578 on_empty_completion()
579 if deadline is not None and time.monotonic() + elapsed > deadline:
580 logger.warning(
581 f"[{engine_name}] not retrying: the attempt took "
582 f"{elapsed:.1f}s and less than that is left before the "
583 f"{_FILTER_WALL_TIMEOUT_S:.0f}s filter deadline; treating "
584 f"it as no relevant results"
585 )
586 return []
587 else:
588 logger.warning(
589 f"[{engine_name}] LLM returned an empty completion "
590 f"{_EMPTY_COMPLETION_ATTEMPTS} times; treating it as no "
591 f"relevant results"
592 )
593 return []
595 logger.info(f"[{engine_name}] LLM call returned {len(text)} chars")
597 indices = [int(m) for m in _INT_RE.findall(text)]
598 if not indices:
599 logger.warning(
600 f"[{engine_name}] Parser found no integers in LLM response."
601 )
602 safe_preview = scrub_error(text)[:300]
603 logger.debug(
604 f"[{engine_name}] Sanitized response that failed parsing: "
605 f"{safe_preview!r}"
606 )
607 logger.debug(
608 f"[{engine_name}] Text output parsed {len(indices)} indices: {indices}"
609 )
610 return indices