Interactive story-narration · play a character inside a book you upload

Build an AI narrator that lets you play any character in a book.

A build-along reference for a narrator that turns a novel you own into a world you can step inside. It reads the book, maps the cast, relationships, and plot spine, then lets you pick a character — Neville instead of Harry — and play the story from their side. Timeline, personalities, and the events that must occur all hold; how they unfold bends around your choices. Read it, code it, play it, tick it off.

IngestCastGraphSpinePlayGovernJudge
12milestones
Claudeprimary · provider seam
RAG + Neo4jgrounded & queryable
canonheld · choices diverge
M00

Foundations: the model seam, config, structured outputs

Why the swappable LLM every later stage plugs into  ·  Write ~4 small files  ·  New a provider port, structured outputs, a fake for tests

Loreplay turns a book you own into a world you can play. Upload a novel; the system reads it, maps its cast, relationships, and plot spine, then lets you pick a character — Neville instead of Harry — and live the story from their side. The canon still holds: the timeline, the personalities, the events that must occur. But how they occur bends around the choices you make.

text
book text  ──▶  ingest (chunk + embed + index)     ┐
                extract cast → character cards      │  the STORY BIBLE
                extract relationships → graph       │  (built once per book)
                extract spine → beats + anchors     ┘
                                     │
player picks a character ──▶  TURN LOOP  ──▶  narrate → choose → resolve → advance
                                     │            (grounded in the bible + world state)
                              GOVERNOR: keep the crux, spend a divergence budget

Everything downstream depends only on an LLM port, never on a concrete vendor. That is the one seam that makes the whole thing testable: tests inject a fake so they run in milliseconds with no keys and no network. The default real provider is Anthropic Claude.

bash
mkdir loreplay && cd loreplay
uv init backend && cd backend
mkdir -p app/{ingest,world,play,govern,serve} tests data/books data/bibles
uv add anthropic pydantic pydantic-settings numpy neo4j
uv add sentence-transformers faiss-cpu        # local embeddings + vector index
uv add ebooklib beautifulsoup4 pypdf          # parse EPUB / HTML / PDF
uv add --dev pytest

# the world model lives in Neo4j — run one locally (Docker):
docker run -p 7687:7687 -p 7474:7474 -e NEO4J_AUTH=neo4j/loreplay neo4j:5

app/llm.py — the provider port

A tiny port — a typing.Protocol, not an abstract base class — with three shapes we will reuse everywhere: complete for free-form text, parse for structured outputs (schema-validated JSON), and stream for live narration. Because it is a Protocol, any class that has these three methods is an LLM as far as a type checker is concerned — the concrete providers below never subclass it, they just match its shape (structural typing). That is deliberate: it keeps vendors out of the interface.

python
from typing import Protocol, Type, TypeVar
from pydantic import BaseModel

T = TypeVar("T", bound=BaseModel)

class LLM(Protocol):
    def complete(self, system: str, messages: list[dict], *, max_tokens: int = 2000) -> str: ...
    def parse(self, system: str, prompt: str, schema: Type[T]) -> T: ...
    def stream(self, system: str, messages: list[dict], *, max_tokens: int = 2000): ...  # yields str

app/llm.py, continued — a real provider and a fake

Same file, right below the port. ClaudeLLM is the default real provider (Anthropic’s claude-opus-5); FakeLLM is what tests inject. Notice neither writes class ClaudeLLM(LLM) — they don’t inherit from the port, they conform to it. Each has the same three methods with matching signatures, and that is all a Protocol asks for. A type checker will accept either one anywhere the pipeline expects an LLM.

python
import anthropic

class ClaudeLLM:
    def __init__(self, model: str = "claude-opus-5"):
        self.client = anthropic.Anthropic()      # reads ANTHROPIC_API_KEY / ant profile
        self.model = model

    def complete(self, system, messages, *, max_tokens=2000) -> str:
        r = self.client.messages.create(
            model=self.model, max_tokens=max_tokens, system=system,
            messages=messages, thinking={"type": "adaptive"})
        return next(b.text for b in r.content if b.type == "text")

    def parse(self, system, prompt, schema):
        # structured outputs: the response is validated against the pydantic schema
        r = self.client.messages.parse(
            model=self.model, max_tokens=4000, system=system,
            messages=[{"role": "user", "content": prompt}], output_format=schema)
        return r.parsed_output

    def stream(self, system, messages, *, max_tokens=2000):
        with self.client.messages.stream(
                model=self.model, max_tokens=max_tokens,
                system=system, messages=messages) as s:
            yield from s.text_stream

class FakeLLM:                                     # tests inject this — no keys, no network
    def __init__(self, canned: dict | None = None): self.canned = canned or {}
    def complete(self, system, messages, **k): return self.canned.get("text", "...")
    def parse(self, system, prompt, schema): return self.canned["obj"]
    def stream(self, system, messages, **k):  yield self.canned.get("text", "...")
M01

Ingest the book: parse, segment into scenes, embed, index

Why the grounding substrate every later stage retrieves from  ·  New scene-aware chunking, FAISS  ·  Output a searchable index over the raw text

Extraction and narration are only as grounded as the text you feed them. Parse the book to clean text, then split on scene boundaries (chapter breaks, POV shifts, scene dividers) rather than fixed windows — a scene is the natural unit a story turns on.

python
from dataclasses import dataclass

@dataclass
class Scene:
    id: str            # "ch03-s02"
    chapter: int
    order: int         # global position in the book — this is the timeline axis
    text: str

def segment(raw: str) -> list[Scene]:
    scenes, order = [], 0
    for ch_i, chapter in enumerate(split_chapters(raw)):        # your EPUB/PDF splitter
        for s_i, body in enumerate(split_scenes(chapter)):      # blank-line / divider heuristic
            scenes.append(Scene(id=f"ch{ch_i:02d}-s{s_i:02d}",
                                chapter=ch_i, order=order, text=body.strip()))
            order += 1
    return scenes

Embed + index the scenes

python
import faiss, numpy as np
from sentence_transformers import SentenceTransformer

class SceneIndex:
    def __init__(self, scenes: list[Scene], model="all-MiniLM-L6-v2"):
        self.scenes = scenes
        self.embedder = SentenceTransformer(model)
        vecs = self.embedder.encode([s.text for s in scenes], normalize_embeddings=True)
        self.index = faiss.IndexFlatIP(vecs.shape[1])           # cosine via inner product
        self.index.add(np.asarray(vecs, dtype="float32"))

    def search(self, query: str, k: int = 5) -> list[Scene]:
        q = self.embedder.encode([query], normalize_embeddings=True)
        _, idx = self.index.search(np.asarray(q, dtype="float32"), k)
        return [self.scenes[i] for i in idx[0]]
M02

Extract the cast: character cards with voice and a knowledge horizon

Why personas that stay in character  ·  New structured extraction with evidence  ·  Output one card per character

A character the player can inhabit — or talk to — needs a card: who they are, how they talk, what they want, and crucially what they could plausibly know. We build cards with structured outputs so the model returns schema-valid JSON, and we demand evidence (scene ids) for each trait so cards stay grounded, not invented.

python
from pydantic import BaseModel, Field

class CharacterCard(BaseModel):
    name: str
    aliases: list[str] = Field(description="every name/epithet the text uses for them")
    traits: list[str]  = Field(description="stable personality traits, 3-7 items")
    voice: str         = Field(description="how they speak: diction, cadence, tics — 1-2 sentences")
    wants: list[str]   = Field(description="their driving goals across the book")
    first_seen_order: int = Field(description="scene.order where they first appear")
    evidence: list[str]   = Field(description="scene ids supporting this card")

EXTRACT_SYS = (
    "You are a literary analyst. Build an accurate character card ONLY from the "
    "provided scenes. Do not invent traits the text does not support. Cite the "
    "scene ids you relied on in `evidence`.")

def build_card(llm, name: str, scenes: list[Scene]) -> CharacterCard:
    context = "\n\n".join(f"[{s.id}] {s.text}" for s in scenes if name in s.text)
    return llm.parse(EXTRACT_SYS, f"Character: {name}\n\nScenes:\n{context}", CharacterCard)

The knowledge horizon — what a character can know

Playing Neville means Neville does not know what happens in rooms he was never in. We attach a knowledge horizon to each card: the set of scenes (by order) the character witnessed or could have learned about. This is what later stops the player from metagaming — acting on the book's outcome instead of their character's limited view.

python
class KnowledgeHorizon(BaseModel):
    character: str
    witnessed_orders: list[int]     # scenes they were present in
    hearsay_orders: list[int]       # scenes they plausibly heard about later

def horizon_for(name: str, scenes: list[Scene]) -> KnowledgeHorizon:
    present = [s.order for s in scenes if name in s.text]
    return KnowledgeHorizon(character=name, witnessed_orders=present, hearsay_orders=[])
M03

Map relationships: a knowledge graph that changes over time

Why reactions depend on who-feels-what-about-whom  ·  New relation extraction, a temporal graph  ·  Output a queryable relationship graph

How a character responds to the player turns on their relationships: ally, rival, mentor, crush, the-one-they-fear. Extract directed relationships from the text and store them in a graph keyed by time, so ‘how does Ron feel about Hermione at beat 6?’ has an answer that can differ from beat 20.

python
from pydantic import BaseModel

class Relationship(BaseModel):
    source: str
    target: str
    kind: str            # ally | rival | mentor | family | fears | loves | distrusts
    valence: float       # -1.0 hostile ... +1.0 warm
    since_order: int     # when this state begins
    evidence: list[str]

REL_SYS = ("Extract directed relationships between named characters from these scenes. "
           "One entry per ordered pair per state change. Cite scene ids.")

Store it in Neo4j — a relationship is literally an edge

Characters are nodes; a relationship is a directed edge carrying its kind, valence, and the since_order it began. A graph database makes the temporal and multi-hop queries the narrator needs — ‘as of beat 14, who does the player's closest ally distrust?’ — a one-line Cypher match instead of hand-rolled traversal.

python
from neo4j import GraphDatabase

class WorldGraph:
    def __init__(self, uri="bolt://localhost:7687", auth=("neo4j", "loreplay")):
        self.driver = GraphDatabase.driver(uri, auth=auth)

    def add(self, r: Relationship):                 # upsert one relationship state
        self.driver.execute_query(
            """
            MERGE (a:Character {name: $source})
            MERGE (b:Character {name: $target})
            MERGE (a)-[e:REL {kind: $kind, since_order: $since}]->(b)
            SET e.valence = $valence, e.evidence = $evidence
            """,
            source=r.source, target=r.target, kind=r.kind,
            since=r.since_order, valence=r.valence, evidence=r.evidence)

    def state(self, a: str, b: str, at_order: int) -> Relationship | None:
        records, _, _ = self.driver.execute_query(
            """
            MATCH (:Character {name: $a})-[e:REL]->(:Character {name: $b})
            WHERE e.since_order <= $at
            RETURN e.kind AS kind, e.valence AS valence, e.since_order AS since
            ORDER BY e.since_order DESC LIMIT 1       // newest state at/before the beat
            """,
            a=a, b=b, at=at_order)
        if not records: return None
        row = records[0]
        return Relationship(source=a, target=b, kind=row["kind"],
                            valence=row["valence"], since_order=row["since"], evidence=[])

The multi-hop questions the narrator actually asks become plain Cypher — the payoff of a graph database over a flat table of pairs:

cypher
// as of beat 14, who does Neville's closest ally distrust?
MATCH (me:Character {name: 'Neville'})-[a:REL {kind: 'ally'}]->(ally)
WHERE a.since_order <= 14
MATCH (ally)-[d:REL {kind: 'distrusts'}]->(x)
WHERE d.since_order <= 14
RETURN ally.name, x.name
M04

Extract the spine: plot beats, and which are load-bearing anchors

Why the crux that must survive the player's choices  ·  New beat extraction, anchor vs soft classification  ·  Output an ordered beat list with preconditions

The spine is the ordered list of plot beats. Some are anchors — the crux, the events that make this this story (the villain is revealed; the hero descends into the vault; the sacrifice is made). Others are soft scenes that can reshuffle, be reached differently, or be skipped. We classify them, and record each anchor's preconditions so the governor can still deliver it after divergence.

python
from pydantic import BaseModel
from typing import Literal

class Beat(BaseModel):
    id: str
    order: int
    summary: str
    kind: Literal["anchor", "soft"]
    preconditions: list[str] = []   # what must be true for this beat to make sense
    canonical_participants: list[str] = []
    scene_ids: list[str] = []

class Spine(BaseModel):
    beats: list[Beat]

SPINE_SYS = (
    "You are a story editor. Segment the book into plot beats in order. Mark a beat "
    "`anchor` if removing it would break the story's identity or ending; otherwise "
    "`soft`. For each anchor, list preconditions — facts that must hold for the beat "
    "to still land — so it can be reached even if earlier scenes played out differently.")
M05

World state & memory: the running playthrough

Why the story-so-far the narrator must stay consistent with  ·  New a mutable state, a choice log, compaction  ·  Output a save-able play state

A playthrough is a fork of the book. We track it in a world state: which beat we are on, what the player chose, which relationships have shifted from canon, what each character now knows, and a running summary of the fork so far. This is the memory the narrator conditions on every turn.

python
from pydantic import BaseModel, Field

class Choice(BaseModel):
    beat_order: int
    prompt: str
    chosen: str
    ripple: str = ""          # one-line consequence the governor recorded

class WorldState(BaseModel):
    book_id: str
    playing_as: str           # the character the player inhabits (e.g. "Neville")
    current_order: int = 0    # position on the timeline
    choices: list[Choice] = []
    relationship_deltas: dict[str, float] = Field(default_factory=dict)  # "Ron->Harry": +0.2
    known_facts: dict[str, list[str]] = Field(default_factory=dict)      # per character
    summary_so_far: str = ""  # compacted narrative memory
    divergence_spent: float = 0.0

Compaction — keep memory bounded

A long playthrough will not fit in one prompt. Every few turns, summarize the fork so far into summary_so_far — preserving choices, relationship shifts, and open threads — and drop the verbatim turn history. This is the same discipline as server-side compaction, done in your own state so the summary is inspectable and save-able.

python
COMPACT_SYS = ("Summarize this playthrough so a narrator can continue seamlessly. Preserve: the "
               "player's choices and their consequences, every relationship that shifted from canon, "
               "unresolved threads, and what the player-character currently knows. Be concise.")

def compact(llm, state: WorldState, recent_turns: list[str]) -> str:
    joined = state.summary_so_far + "\n\n" + "\n\n".join(recent_turns)
    return llm.complete(COMPACT_SYS, [{"role": "user", "content": joined}], max_tokens=1200)

Persist the fork next to the canon graph: a :Playthrough node anchors the player's choices and relationship deltas, so a save is a small subgraph, not a copy of the book.

cypher
// record one choice against the active playthrough
MATCH (pt:Playthrough {id: $playId})
CREATE (pt)-[:MADE {order: $beat}]->(:Choice {prompt: $prompt, chosen: $chosen, ripple: $ripple})

// layer a player-caused relationship delta over canon (does not touch the canonical :REL edges)
MATCH (pt:Playthrough {id: $playId}), (a:Character {name: $src}), (b:Character {name: $dst})
MERGE (pt)-[d:DELTA {source: $src, target: $dst}]->(a)
SET d.valence_shift = $shift, d.since_order = $beat
M06

The turn loop: narrate → choose → resolve → advance

Why the beating heart of play  ·  New structured scenes, grounded narration, streaming  ·  Output one playable turn

A turn: retrieve the relevant canon, narrate the scene from the player-character's vantage, offer choices (or accept free text), then resolve the choice into consequences and advance the timeline. The narration streams for immediacy; the choices come back as structured output so the UI can render them.

python
from pydantic import BaseModel

class Turn(BaseModel):
    narration: str            # what just happened, in the character's POV
    choices: list[str]        # 2-4 in-character options; free text also allowed
    beat_order: int

def narrate(llm, state: WorldState, bible, index: SceneIndex):
    beat = bible.spine.beats_by_order[state.current_order]
    canon = index.search(beat.summary, k=4)                 # ground in the real text
    card  = bible.cards[state.playing_as]

    system = build_bible_prompt(bible, card, state)         # cached — see M11
    user = (f"Current beat: {beat.summary}\n"
            f"Grounding scenes:\n" + "\n".join(f"[{s.id}] {s.text}" for s in canon) +
            f"\n\nNarrate this beat from {state.playing_as}'s point of view, then offer choices. "
            f"Only reveal what {state.playing_as} could perceive or know.")

    # stream the prose to the player as it generates
    buf = []
    for chunk in llm.stream(system, [{"role": "user", "content": user}], max_tokens=1200):
        buf.append(chunk); yield chunk
    # then get the structured choices in a second, cheap call
    turn = llm.parse(system, "".join(buf) + "\n\nList 2-4 in-character choices.", Turn)
    return turn
python
def resolve(llm, state: WorldState, choice: str, bible) -> Choice:
    # ask the model for the immediate consequence, constrained by the governor (M08)
    ripple = llm.complete(RESOLVE_SYS,
        [{"role": "user", "content": f"Player ({state.playing_as}) chose: {choice}. "
                                     f"Give the immediate in-world consequence in one line."}],
        max_tokens=200)
    state.current_order += 1
    return Choice(beat_order=state.current_order, prompt="", chosen=choice, ripple=ripple)
M07

Personas in play: stay in voice, and no metagaming

Why other characters must feel like themselves  ·  New persona conditioning, knowledge-boundary filtering  ·  Output in-character NPC responses

When the player talks to Ron, Ron must answer like Ron — using his card's voice, wants, and his relationship to the player at this beat — and must not know things Ron could not know. Persona conditioning + a knowledge filter is what keeps the cast feeling authored rather than generic.

python
def persona_prompt(card: CharacterCard, rel: Relationship | None, state: WorldState) -> str:
    stance = f"Your stance toward {state.playing_as}: {rel.kind} (valence {rel.valence:+.1f})." if rel else ""
    return (f"You are {card.name}. Voice: {card.voice}. Traits: {', '.join(card.traits)}. "
            f"You want: {', '.join(card.wants)}. {stance} "
            f"Stay strictly in character. Never break the fourth wall or reference being an AI.")

def knowledge_gate(card_name: str, horizon: KnowledgeHorizon, candidate_facts: list[dict]) -> list[dict]:
    # drop any fact whose source scene the character never witnessed or heard of — no metagaming
    allowed = set(horizon.witnessed_orders) | set(horizon.hearsay_orders)
    return [f for f in candidate_facts if f["order"] in allowed]

Optional: let the player re-shape a personality

The player asked for freedom, so allow an opt-in personality shift — ‘play a braver Neville’ — recorded as an explicit override on the card, tracked as divergence spend (M08). Default is faithful; deviation is a choice the player makes and the system remembers.

python
class PersonaOverride(BaseModel):
    character: str
    change: str            # "more assertive", "secretly resentful of Harry"
    since_order: int

def effective_voice(card: CharacterCard, overrides: list[PersonaOverride], at: int) -> str:
    live = [o.change for o in overrides if o.character == card.name and o.since_order <= at]
    return card.voice + ("" if not live else "  Now also: " + "; ".join(live))
M08

Canon rails & the divergence budget: keep the crux, spend the rest

Why freedom that still lands as this story  ·  New anchor scheduling, a canon-distance score, drift guards  ·  Output a governed turn

This is the heart of the promise. The player may reroute soft scenes freely, but every anchor must still be reached — possibly by a different road. We score how far the fork has drifted, spend a divergence budget on soft choices, and when an anchor's window arrives, we bend the narrative back toward its preconditions.

python
def canon_distance(state: WorldState, bible) -> float:
    # 0.0 = on-canon, 1.0 = wholly diverged. Cheap, interpretable heuristic:
    missed_anchors = sum(1 for b in bible.spine.anchors
                         if b.order < state.current_order and b.order not in state.reached_anchors)
    rel_drift = sum(abs(v) for v in state.relationship_deltas.values()) / max(1, len(bible.cards))
    return min(1.0, 0.5 * missed_anchors + 0.5 * rel_drift)

def next_anchor(state: WorldState, bible):
    return min((b for b in bible.spine.anchors if b.order >= state.current_order),
               key=lambda b: b.order, default=None)

Steering back toward an anchor

When the player nears an anchor, the governor injects its preconditions into the narrator's instructions — not to railroad, but to make the world conspire so the beat can still occur in a way that honors the choices made. The trapdoor still opens; who opens it, and why, can change.

python
GOVERN_SYS = (
    "You are the story governor. The player has diverged from canon. An upcoming ANCHOR must still "
    "occur, honoring its preconditions, but it may be reached differently given the player's choices. "
    "Rewrite the narrator's brief so the world plausibly converges on the anchor WITHOUT contradicting "
    "what the player has already established. Do not undo player choices; re-route around them.")

def steer(llm, state: WorldState, bible) -> str:
    anchor = next_anchor(state, bible)
    if anchor is None or canon_distance(state, bible) < 0.25:
        return ""                                   # on track — no steering needed
    brief = (f"Anchor to reach: {anchor.summary}\n"
             f"Preconditions: {anchor.preconditions}\n"
             f"Player choices so far: {[c.chosen for c in state.choices]}")
    return llm.complete(GOVERN_SYS, [{"role": "user", "content": brief}],
                        max_tokens=400)             # injected into the next narrate() system prompt
python
BUDGET = 1.0
def spend(state: WorldState, cost: float) -> bool:
    if state.divergence_spent + cost > BUDGET:      # soft cap: pull harder toward canon
        return False
    state.divergence_spent += cost
    return True
M09

The director: narrator, continuity, and character actors

Why one prompt cannot do everything well  ·  New role decomposition, handoffs, a continuity check  ·  Output a coordinated turn

Asking a single call to narrate, stay in canon, voice every NPC, and enforce knowledge boundaries is asking for mush. Split the turn into roles that hand off, each with a tight brief. This is a small multi-agent pipeline — the same idea as a director coordinating actors and a continuity supervisor on set.

  • Director — plans the turn: which beat, is an anchor near, invoke the governor (M08), assemble briefs.
  • Narrator — writes the prose from the player-character's POV, grounded in retrieved canon.
  • Actors — one persona-conditioned call per speaking NPC, knowledge-gated (M07).
  • Continuity — a cheap check that the draft contradicts neither the world state nor the character horizons; flags leaks (metagaming, out-of-voice, canon breaks).
python
def run_turn(llm, state, bible, index, player_input: str | None):
    steer_brief = steer(llm, state, bible)                       # Director → Governor
    turn = yield from narrate(llm, state, bible, index)          # Narrator (streams prose)
    replies = [act(llm, npc, state, bible) for npc in speaking_npcs(turn)]  # Actors
    report = continuity_check(llm, turn, replies, state, bible)  # Continuity
    if report.has_violation:
        turn = repair(llm, turn, report)                         # one bounded retry
    return turn, replies
python
class ContinuityReport(BaseModel):
    has_violation: bool
    kinds: list[str]     # "metagaming" | "out_of_voice" | "canon_break" | "timeline"
    notes: str

CONTINUITY_SYS = (
    "You are a continuity supervisor. Given the world state, character knowledge horizons, and a draft "
    "turn, flag any place the draft: reveals what the POV character cannot know; makes an NPC act out "
    "of their established voice; contradicts an anchor already reached; or breaks the timeline. "
    "Return structured findings only.")
M10

Evaluation & safety: fidelity vs agency, and guardrails

Why ‘is it still the book, and is it fun?’ is measurable  ·  New an LLM-judge rubric, safety framing  ·  Output a scored playthrough

Two forces pull against each other: canon fidelity (it still feels like the book) and player agency (choices matter). You cannot tune the divergence budget or the governor without measuring both. Score playthroughs with an LLM judge against an explicit rubric, plus cheap programmatic checks.

python
class TurnScore(BaseModel):
    canon_fidelity: int      # 1-5: consistent with timeline, anchors, established facts
    persona_adherence: int   # 1-5: NPCs sound like themselves
    knowledge_integrity: int # 1-5: no metagaming leaks
    agency: int              # 1-5: the choice visibly mattered
    notes: str

JUDGE_SYS = (
    "You are evaluating one turn of an interactive re-telling. Score 1-5 on each rubric item. Be "
    "strict about knowledge integrity: any fact the POV character could not know is an automatic 1. "
    "Justify each score in one clause.")

def judge(llm, turn, state, bible) -> TurnScore:
    ctx = f"State: {state.model_dump_json()}\n\nTurn:\n{turn.narration}"
    return llm.parse(JUDGE_SYS, ctx, TurnScore)
  • Programmatic guards — assert every anchor reached before its window closes; assert no scene above the POV horizon leaked into narration; assert divergence_spent ≤ BUDGET.
  • Golden playthroughs — keep a few scripted choice sequences and re-judge them whenever you change a prompt, so regressions are visible.
M11

Serve & persist: an API, save/load, and a cached bible

Why a playable app, cheaply  ·  New streaming endpoints, prompt caching  ·  Output a running Loreplay

Wrap the loop in a thin FastAPI service: upload a book (build the bible once), start a playthrough, and stream turns to a minimal text UI. Persist the fork as a :Playthrough subgraph in Neo4j so a player can resume. The single biggest cost lever is prompt caching the story bible.

python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()

@app.post("/books")                 # ingest + build bible (M01-M04), save under data/bibles/
def upload(book: UploadFile): ...

@app.post("/play")                  # start a playthrough: pick book + character
def start(book_id: str, playing_as: str) -> WorldState: ...

@app.post("/turn")                  # stream one governed turn
def turn(state: WorldState, choice: str | None = None):
    def gen():
        yield from run_turn(llm, state, load_bible(state.book_id), load_index(state.book_id), choice)
    return StreamingResponse(gen(), media_type="text/plain")

Cache the bible — it is the same on every turn

The character cards, spine, and relevant graph slice are large and identical across the whole playthrough. Put them in a cached system block so every turn after the first pays a fraction of the input cost. Keep the volatile part — current beat, recent choices — after the cache breakpoint so the prefix stays byte-stable.

python
def build_bible_prompt(bible, card, state):
    frozen = render_bible(bible, card)          # cards + spine + rules — stable all playthrough
    return [
        {"type": "text", "text": STORY_RULES},                      # narrator rules (stable)
        {"type": "text", "text": frozen, "cache_control": {"type": "ephemeral"}},  # cached prefix
    ]
    # volatile turn context (beat, choices, retrieved scenes) goes in the USER message,
    # after the breakpoint — so the cached prefix is reused turn after turn.
A

Worked example: playing Neville through the vault

Optional one anchor, one divergence, end to end

Concrete makes it click. Take a book with a well-known climax — the trio descending past a guardian into a vault to stop a thief. The player chooses to inhabit Neville, a minor character in that scene. Here is how the machine keeps the crux while honoring the choice.

text
BIBLE (built once)
  cards:   Harry, Ron, Hermione, Neville(minor, timid, loyal, wants: to belong)
  spine:   ... beat 21 [ANCHOR] "the descent past the guardian into the vault"
                        preconditions: [thief is unmasked at the vault, hero reaches the guardian]
  horizon: Neville witnessed the common-room scenes, NOT the vault (in canon)

PLAY AS Neville
  beat 19 [soft]  Neville catches the trio sneaking out. CHOICE:
     (a) let them pass   (b) insist on coming   (c) try to stop them   > player picks (b)
  governor: soft scene — spend 0.2 budget. relationship_delta {"Harry->Neville": +0.3}
  ripple:  "Neville, knees shaking, follows them down."

  beat 21 [ANCHOR] window opens. canon_distance = 0.18 (< 0.25) — light steering.
  steer:   anchor preconditions still hold; Neville is now PRESENT at the vault.
  narrate: the descent occurs (anchor kept) — but from Neville's POV, and his presence
           changes a soft detail: he is the one who freezes the guardian, not Hermione.
  continuity: no metagaming (Neville only narrates what he sees); anchor reached; PASS.

The timeline held (the vault descent is still beat 21). The personalities held (Neville is timid but loyal; his choice to come is in character). The event still occurred (the anchor fired) — but its manner bent around the player: a background character stepped into the light, and one soft detail rerouted. That is the entire thesis of Loreplay in a single beat.