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

1"""LLM-based relevance filter using plain text output. 

2 

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). 

8 

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""" 

38 

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 

48 

49from langchain_ollama import ChatOllama 

50 

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 

55 

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 

60 

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") 

67 

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 

82 

83# Calls made before an empty completion is read as "none relevant". 

84_EMPTY_COMPLETION_ATTEMPTS = 2 

85 

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"}) 

89 

90 

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 

99 

100 

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. 

102 

103Query: "{query}" 

104Current date: {current_date} 

105 

106Search results: 

107{preview_text} 

108 

109Direct topic match matters more than keyword match — results that just mention the query terms usually don't help. 

110 

111Output ONLY the 0-based indices of relevant results as a comma-separated list, nothing else. 

112Example: 0, 2, 5""" # noqa: S608 

113 

114 

115def _unwrap_llm(llm): 

116 """Unwrap known LLM wrapper chains to get the base LangChain LLM. 

117 

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 

129 

130 

131def _finish_reason(result): 

132 """Return the provider's finish reason, or None. 

133 

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 

144 

145 

146def _empty_completion_is_deterministic(result) -> bool: 

147 """Whether a retry of this empty completion cannot succeed. 

148 

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")) 

156 

157 

158def _describe_empty_completion(result) -> str: 

159 """Summarise why a completion may be empty, without logging any text. 

160 

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 ) 

173 

174 

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. 

182 

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}") 

197 

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 ) 

203 

204 

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. 

216 

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 ) 

229 

230 

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. 

242 

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. 

263 

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 [] 

272 

273 for i, preview in enumerate(previews): 

274 title = preview.get("title", "Untitled").strip() 

275 logger.debug(f"[{engine_name}] INPUT [{i}]: {title[:80]}") 

276 

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 

280 

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] 

288 

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 ) 

294 

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] 

301 

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() 

338 

339 def _mark_judged_empty(i: int) -> Callable[[], None]: 

340 def _mark() -> None: 

341 judged_empty[i] = True 

342 

343 return _mark 

344 

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 

405 

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] 

415 

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 ] 

423 

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() 

429 

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) 

445 

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 ) 

451 

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 ) 

463 

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] 

471 

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}") 

496 

497 return ranked_results 

498 

499 

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. 

508 

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``. 

512 

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 

531 

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 [] 

553 

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 [] 

594 

595 logger.info(f"[{engine_name}] LLM call returned {len(text)} chars") 

596 

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