Source code for axiom.reflect

"""LLM generation of mental models for living memory mode (Hindsight §7.8).

Turns the beliefs held about one subject (a character, or the world) into a short
**mental model**: a 2-4 sentence profile of who that subject is *now*. The decision
discipline — base it only on the beliefs, no invention, no arithmetic, capture
change and relationships — is adapted from Hindsight's ``reflect/`` prompts,
reimplemented on our :class:`~axiom.backends.base.LLMBackend`.

Design rules (same as :mod:`axiom.factextract` / :mod:`axiom.consolidate`):

- **Background only**: callers run this off the turn loop; it must never block play.
- **Graceful**: any failure (LLM down, empty answer) yields ``""`` — the profile is
  simply not refreshed this pass, the game keeps running.
- **No persistence here**: returns the summary string; the deterministic UPSERT
  lives in :func:`axiom.mental_models.upsert_mental_model`.
"""

from __future__ import annotations

from axiom.backends.base import LLMBackend, LLMMessage
from axiom.observations import Observation

# Don't bother modelling a subject backed by fewer beliefs than this — a single
# belief is already its own one-liner; a model adds value once memory accumulates.
MIN_BELIEFS_FOR_MODEL = 3
# Cap the beliefs fed to one reflection so the prompt stays bounded on a subject
# that has hundreds of them (most-recent-first slice).
_MAX_BELIEFS_PER_REFLECT = 40

_DEFAULT_MISSION = (
    "Capture who this subject is now: their relationships, goals, grudges, "
    "reputation and how they have changed."
)

_SYSTEM_PROMPT = """\
You maintain living character/world profiles for a narrative game. Given the \
durable beliefs currently held about one subject, write a concise profile of who \
they are now.

Rules:
- 2 to 4 sentences, plain prose. No headers, no lists, no preamble.
- Base it ONLY on the beliefs given. Do not invent facts or do arithmetic.
- Capture relationships, goals, grudges, reputation and any change over time.
- Write in the third person, present tense. Output ONLY the profile text."""


[docs] def affected_subjects(actions) -> list[str]: """Subjects whose beliefs changed in a consolidation batch (order-preserving). Reads the ``subject`` of each create/update action (delete actions drop a belief and don't name a subject worth re-modelling here). De-duplicated case-insensitively, keeping first-seen spelling. World beliefs (``subject=""``) are included (the world model). """ seen: set[str] = set() out: list[str] = [] for a in actions or []: if getattr(a, "kind", "") not in ("create", "update"): continue subj = (getattr(a, "subject", "") or "").strip() key = subj.lower() if key in seen: continue seen.add(key) out.append(subj) return out
def _belief_line(o: Observation) -> str: return f"- {o.statement} (proof={o.proof_count})" def _build_messages(subject: str, beliefs: list[Observation], mission: str) -> list[LLMMessage]: who = subject.strip() or "the world" lines = [_belief_line(o) for o in beliefs[:_MAX_BELIEFS_PER_REFLECT]] user = ( f"Subject: {who}\n" f"Guidance — what to capture: {mission}\n\n" f"Beliefs currently held about {who}:\n" + "\n".join(lines) ) return [ {"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user}, ]
[docs] def reflect( llm: LLMBackend, subject: str, beliefs: list[Observation], *, mission: str | None = None, ) -> str: """Write a mental-model summary for ``subject`` from its ``beliefs``. Returns ``""`` when there are too few beliefs to be worth modelling, on empty input, or on any backend failure (never raises) — so a living-mode background job can call it fire-and-forget. """ usable = [o for o in beliefs if (o.statement or "").strip()] if len(usable) < MIN_BELIEFS_FOR_MODEL: return "" messages = _build_messages(subject, usable, mission or _DEFAULT_MISSION) try: resp = llm.complete(messages, temperature=0.3) except Exception: return "" return (getattr(resp, "narrative_text", "") or "").strip()