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

## Where
- `src/arandu/write/extract.py` — Extraction prompts (single-pass + multi-pass)
- `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
- `src/arandu/background/consolidation.py` — Pattern detection, emotion tagging

## The Pattern
1. **Prompts are module-level constants** (e.g., `EXTRACTION_SYSTEM_PROMPT`, `RECONCILIATION_SYSTEM_PROMPT`).
2. **Call signature**: `llm.complete(messages=[{"role": "system", ...}, {"role": "user", ...}], temperature=0, response_format={"type": "json_object"})`.
3. **Markdown fence stripping**: `_strip_markdown_fences()` removes ` ```json\n...\n``` ` wrappers.
4. **JSON parsing**: `json.loads(cleaned)` then Pydantic `.model_validate(data)` for typed schemas.
5. **Timeout enforcement**: `asyncio.wait_for(coro, timeout=timeout_sec)` wrapping the LLM call.
6. **Fail-safe**: On parse/timeout/error → return empty result or safe default (e.g., `ExtractionOutput()`, all ADD).

## 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 instruct "Respond in JSON" with explicit schema examples.
- Multi-pass extraction: entity scan → fact extraction per batch → relation extraction (all concurrent via `asyncio.gather`).
- Reflexion pass only runs for single-pass mode.

## Examples from this codebase
File: src/arandu/write/extract.py
```python
EXTRACTION_SYSTEM_PROMPT = """You are a personal knowledge extractor...
Respond in JSON with keys: entities, facts, relations."""

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:
            return await asyncio.wait_for(coro, timeout=timeout_sec)
        return await coro
    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
RECONCILIATION_SYSTEM_PROMPT = """You are a memory reconciliation engine...
Respond as JSON: {"decisions": [...]}"""

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