---
tags: [llm, prompts, json, extraction, reconciliation, timeout]
modules: [src/arandu/write/, src/arandu/read/, src/arandu/background/]
applies_to: [handlers, services]
confidence: inferred
---
# Pattern: LLM Interaction

<!-- vibeflow:auto:start -->
## What
All LLM calls follow a consistent pattern: system prompt + user prompt, JSON response format, markdown fence stripping, structured parsing, and fail-safe error handling. `LLMProvider.complete()` returns `LLMResult(text, usage)` — never raw `str`.

## Where
- `src/arandu/write/extract.py` — Extraction prompts (entity scan, fact extraction, relation extraction)
- `src/arandu/write/reconcile.py` — Reconciliation prompt
- `src/arandu/write/entity_resolution.py` — Entity resolution LLM fallback
- `src/arandu/read/retrieval_agent.py` — Retrieval planning
- `src/arandu/read/reranker.py` — LLM-based reranking (multiplicative blend + min_reranker_score)
- `src/arandu/background/consolidation.py` — Pattern detection, emotion tagging

## The Pattern
1. **Prompts are module-level constants** (e.g., `FACT_EXTRACTION_PROMPT`, `RECONCILIATION_SYSTEM_PROMPT`).
2. **Call signature**: `llm.complete(messages=[...], temperature=0, response_format={"type": "json_object"})` → returns `LLMResult`.
3. **Text extraction**: `result.text` to get the response string. `result.usage` for optional token tracking.
4. **Markdown fence stripping**: `strip_markdown_fences(result.text)` removes ` ```json\n...\n``` ` wrappers.
5. **JSON parsing**: `json.loads(cleaned)` then Pydantic `.model_validate(data)` for typed schemas.
6. **Timeout enforcement**: `asyncio.wait_for(coro, timeout=timeout_sec)` wrapping the LLM call.
7. **Fail-safe**: On parse/timeout/error → return empty result or safe default (e.g., `ExtractionOutput()`, all ADD).
8. **Token accumulation**: Pipelines accumulate `result.usage` across calls via `TokenUsage.__add__`.
9. **Reranker blend**: Multiplicative blend `formula × (floor + w × reranker)` with `min_reranker_score` gate.
10. **Post-extraction dedup**: Exact dedup (strip punctuation) + semantic dedup (embedding cosine > 0.85).

## Rules
- Always use `temperature=0` for deterministic extraction/reconciliation.
- Always request `response_format={"type": "json_object"}` when expecting JSON.
- Never raise exceptions from LLM failures — return empty/default results.
- Prompts are domain-agnostic — no domain-specific categories or jargon.
- Extraction is multi-pass only (single-pass was removed): entity scan → fact extraction (all entities, 1 call) → relation extraction (concurrent via `asyncio.gather`).
- Prompts instruct: self-contained facts (include entity name), unambiguous context, subject-centric (no reformulations), and relationship→fact pairing.
- Entity types are free-form strings, normalized to lowercase. No enum.

## Examples from this codebase
File: src/arandu/write/extract.py
```python
async def _call_llm(llm, system_prompt, user_prompt, call_type, timeout_sec=None):
    try:
        coro = llm.complete(
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt},
            ],
            temperature=0,
            response_format={"type": "json_object"},
        )
        if timeout_sec and timeout_sec > 0:
            result = await asyncio.wait_for(coro, timeout=timeout_sec)
        else:
            result = await coro
        return result.text  # LLMResult → str
    except TimeoutError:
        logger.warning("extraction: LLM call timed out after %.1fs", timeout_sec)
        return None
    except Exception as e:
        logger.warning("extraction: LLM call failed (%s): %s", call_type, e)
        return None
```

File: src/arandu/write/reconcile.py
```python
# On LLM failure → default to ADD (prefer duplicates over lost data)
except Exception as e:
    return [ReconciliationDecision(..., action="ADD") for fc in fact_candidates]
```
<!-- vibeflow:auto:end -->
