cd /news/ai-agents/how-i-built-a-spoiler-safe-ai-reader… · home › topics › ai-agents › article
[ARTICLE · art-167642] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

How I built a spoiler-safe AI reader for your eBooks with GraphRAG

A developer built LORE, an open-source e-reader that converts owned EPUB and PDF books into a knowledge graph and answers reader questions using GraphRAG, citing every sentence and filtering out anything past the reader's current chapter. Spoiler protection is enforced as a retrieval prefilter on vector searches, graph queries and character profiles rather than a prompt, and each pipeline change ships only after passing a blind paired evaluation with a sign test. The developer also described a bug where a capped search returned a misleading "not in the books" message, fixed by running one uncapped vector search to distinguish missing content from content the reader hasn't reached yet.

by read5 min views2 publishedNov 11, 2026

I was halfway through a mystery novel and couldn't remember who a minor character was. I searched for it. The first result spoiled the ending.

That's the whole origin story of LORE, an open-source reader that turns the books you own into a knowledge graph and answers questions about them with every sentence cited, and without spoiling anything past the chapter you're on.

This post is about how it works, and about the bugs that taught me the most.

You can ask a general chatbot about a famous book, and it'll answer from memory. That breaks down in three ways:

So the rule for LORE was: answer only from the text, cite every sentence, and never look past the reader's position.

When you upload an EPUB or PDF, LORE reads it once:

IDENTIFIED_AS links.nomic-embed-text through Ollama. Then, when you ask a question:

question

→ split into the parts that need answering

→ find starting entities (vector search + the graph)

→ walk the graph for the relationships that matter

→ pull the exact passages behind those relationships

→ extract claims, each with a quote from a passage

→ check every claim against its quote; cut what fails

→ write the answer as prose, every sentence cited

The graph is what makes multi-book questions possible: "how does this character change across the series?" is a walk over one entity's relationships in story order, not a hope that the right passages land in the top 10 of a vector search.

The core data structure is boring on purpose:

class Claim(BaseModel):
    text: str      # "Stamford introduces Watson to Holmes."
    passage: int   # which retrieved passage it stands on
    quote: str     # the exact words from that passage

A claim with no quote, or a quote that isn't in the passage it cites, never reaches the reader. A second pass then checks that the passage actually supports the claim, not just that the quote exists. Only the survivors get written up as prose.

Getting the checker right was its own project. Some of what went wrong:

Each fix ships behind a switch, and each switch has a blind paired eval: the same questions answered with the switch on and off, graded without knowing which is which, compared with a sign test. If a change doesn't win on held-out questions, it doesn't ship. (It's very easy to "improve" a RAG system on the five questions you keep testing by hand.)

Spoiler protection is a filter, not a prompt

The tempting way to avoid spoilers is a prompt: "don't reveal anything after chapter 4." That doesn't work. If the evidence is in the context, it leaks.

So LORE never retrieves it. Your reading progress becomes a per-book chapter cap, and that cap is applied as a prefilter on every vector search, every graph query and every character profile:

caps = {"sign-of-four": 6}   # chapter_seq the reader has reached
where = "book_id IN ('study-in-scarlet', 'sign-of-four') AND " \
        "(book_id != 'sign-of-four' OR chapter_seq <= 6)"
rows = table.search(vector).where(where, prefilter=True).limit(k)

The model can't spoil what it never sees.

The bug that looked like an outage

While recording the demo, I asked "who is the killer?" with my progress at chapter 4. The answer came back instantly: "The books don't contain enough to answer that." No graph, no passages, zero model calls.

It looked like something was broken. It wasn't. The best passage within chapters 1 to 4 scored 0.618 against a relevance floor of 0.62, so the pipeline correctly stopped before spending a single request. The answer lives in chapter 7 (score 0.679), exactly where it should be hidden.

The behaviour was right; the message was wrong. "The books don't contain it" is false. "You haven't got there yet" is true. So now, when the capped search comes up empty, LORE runs one more vector search without the cap. That's about 50 ms, with no model call and nothing sent to a model. If something relevant is further on, it says:

You haven't reached the part of the book that answers this yet. Tick "Show spoilers for this question" to see it anyway.

A one-line React bug: Stop that re-asked the question

I added a Stop button. Pressing it cancelled the answer... and then asked the same question again a second later.

The Stop and Ask buttons were the same element, with React switching its type from button to submit. Clicking Stop updated state, React re-rendered during the click, and by the time the browser ran the click's default action the button was a submit button, so the form submitted the question that had just been put back in the box.

// before: one element whose type changes mid-click
{busy ? <button type="button" onClick={stop}>Stop</button>
      : <button type="submit">Ask</button>}

// after: two elements; React never reuses one for the other
{busy ? <button key="stop" type="button" onClick={stop}>Stop</button>
      : <button key="ask" type="submit">Ask</button>}

The fun part: a test's synthetic click can't reproduce it, because the microtask that re-renders runs after the whole dispatch. So the test checks the structure instead: after Stop, the Ask button must be a different element.

Use it from Claude

LORE also exposes an MCP server (with OAuth), so you can add your library to Claude as a custom connector and ask questions about your books from inside Claude. You get the same citations, and the same reading position applies.

Stack

Try it

It's rough in places. Answers are only as good as the extraction, and long series take a while to process. I'd love feedback, especially examples where it gets something wrong. What book would you throw at it first?

── more in #ai-agents 4 stories · sorted by recency
── more on @lore 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-i-built-a-spoile…] indexed:0 read:5min 2026-11-11 · —