Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 58ce99bf53 fix(engine): remove multiplicative retry layers in fact extraction (#1412)
Structured-output extraction had three nested retry loops that
multiplied on deterministic failures, burning up to 36 LLM calls
per chunk (inner 4 × middle 3 × outer 3).

- Remove outermost _extract_chunk_with_retry wrapper: its broad
  except-Exception added a 3× multiplier on top of already-bounded
  inner retries.
- Remove json_validate_failed retry from middle layer: the inner
  provider loop already retries 400 errors; re-entering the full
  LLM call for the same schema failure is wasted quota.
- Fix claude_code_llm.py: ValidationError was caught by a broad
  except-Exception and retried instead of raising immediately.
  Same input produces the same schema-violating output.
2026-05-07 17:03:38 +02:00
2 changed files with 32 additions and 51 deletions
@@ -12,6 +12,8 @@ import logging
import time
from typing import Any
from pydantic import ValidationError
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -278,6 +280,12 @@ class ClaudeCodeLLM(LLMInterface):
return result
except ValidationError:
# Pydantic schema validation failure — retrying with the same
# input won't produce a different schema. Raise immediately
# instead of burning quota on identical calls (#1412).
raise
except Exception as e:
last_exception = e
@@ -1382,14 +1382,9 @@ async def _extract_facts_from_chunk(
f" (current value: {config.retain_max_completion_tokens}, must be > RETAIN_CHUNK_SIZE={config.retain_chunk_size})"
) from e
if "json_validate_failed" in str(e):
logger.warning(
f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{llm_max_retries} failed with JSON validation error: {e}"
)
if attempt < llm_max_retries - 1:
logger.info(f" [1.3.{chunk_index + 1}] Retrying...")
continue
# If it's not a JSON validation error or we're out of retries, re-raise
# Don't retry json_validate_failed here — the inner provider
# loop already retried the 400 error. Re-entering the LLM call
# with the same input just multiplies wasted calls.
raise
# If we exhausted all retries, raise the last error or a descriptive fallback
@@ -1562,47 +1557,25 @@ async def extract_facts_from_text(
f"chunk_size={config.retain_chunk_size:,}) - starting parallel LLM extraction"
)
# Per-chunk retry wrapper: each chunk gets up to MAX_CHUNK_RETRIES attempts.
# This handles transient LLM failures (timeouts, rate limits, malformed responses)
# without discarding the entire batch. If a chunk still fails after all retries,
# the ENTIRE retain fails — we do not accept partial extraction.
MAX_CHUNK_RETRIES = 3
CHUNK_RETRY_BASE_DELAY = 2.0 # seconds, doubles each retry
async def _extract_chunk_with_retry(chunk: str, chunk_index: int) -> tuple:
"""Extract facts from a single chunk with retries on failure."""
last_exception = None
for attempt in range(MAX_CHUNK_RETRIES):
try:
return await _extract_facts_with_auto_split(
chunk=chunk,
chunk_index=chunk_index,
total_chunks=len(chunks),
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
metadata=metadata,
)
except Exception as e:
last_exception = e
if attempt < MAX_CHUNK_RETRIES - 1:
delay = CHUNK_RETRY_BASE_DELAY * (2**attempt)
logger.warning(
f"Chunk {chunk_index}/{len(chunks)} extraction failed "
f"(attempt {attempt + 1}/{MAX_CHUNK_RETRIES}): "
f"{type(e).__name__}. Retrying in {delay:.0f}s..."
)
await asyncio.sleep(delay)
else:
logger.error(
f"Chunk {chunk_index}/{len(chunks)} extraction failed after "
f"{MAX_CHUNK_RETRIES} attempts: {type(e).__name__}: {e}"
)
raise last_exception
tasks = [_extract_chunk_with_retry(chunk, i) for i, chunk in enumerate(chunks)]
# Transient LLM failures (timeouts, rate limits) are already retried inside
# the provider's inner loop. Content-quality retries (malformed facts) are
# handled by the middle loop in _extract_facts_from_chunk. Adding a third
# retry layer here would multiply wasted calls on deterministic failures
# (see https://github.com/vectorize-io/hindsight/issues/1412).
tasks = [
_extract_facts_with_auto_split(
chunk=chunk,
chunk_index=i,
total_chunks=len(chunks),
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
metadata=metadata,
)
for i, chunk in enumerate(chunks)
]
# return_exceptions=True so we can collect all results even if some chunks
# exhausted their retries. We check for failures below and fail the retain
@@ -1628,8 +1601,8 @@ async def extract_facts_from_text(
# hasn't committed yet. The worker poller will retry the entire task.
failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}" for idx, err in failed_chunks[:5])
raise RuntimeError(
f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed "
f"after {MAX_CHUNK_RETRIES} retries each. First failures: {failed_summary}"
f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed. "
f"First failures: {failed_summary}"
)
return all_facts, chunk_metadata, total_usage