| title | bookmarked |
|---|---|
| emoji | 📚 |
| colorFrom | orange |
| colorTo | blue |
| sdk | docker |
| app_port | 7860 |
| pinned | false |
A personal reading companion: ask questions across everything you've read, get recommendations with real reasoning, draft posts grounded in your own library, and browse a character graph that never shows you anything past the chapter you're on.
Full project plan: reading-companion-agent-plan.md
Uses a project-local virtual environment — this project's dependencies (LangGraph
especially) move fast and will conflict with other Python projects on your machine
if installed into a shared/base environment. Always run things through .venv,
not your system python3.
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env # add your GEMINI_API_KEY (free — see Models below)
# Try it with the sample library first
cp data/samples/book-notes.sample.md data/books/thinking-in-systems.md
cp data/samples/article.sample.md data/articles/second-order-thinking.md
cp data/samples/novel.sample.txt data/texts/sample-novel.txt
.venv/bin/python -m backend.scripts.ingest
.venv/bin/uvicorn backend.main:app --reloadOr activate the venv once per shell (source .venv/bin/activate) and drop the
.venv/bin/ prefix for the rest of the session.
Open http://127.0.0.1:8000 and ask "What have I read about second-order thinking?"
The first ingest downloads a ~80 MB local embedding model (once, cached in
~/.cache/chroma). No API key needed for embedding — only for answering.
Three folders, three formats. The folder decides how the file is parsed, so put each file in the right one.
The main RAG corpus. One file per book, chapters as ## Chapter N headings.
---
title: Thinking in Systems
author: Donella Meadows
---
## Chapter 2
- Feedback loops are where behaviour comes from.
- Second-order thinking means tracing a decision's ripple effects before acting.
## Chapter 6
- The strongest leverage point is the paradigm the system arises from.Frontmatter is optional (title falls back to the filename). Chapter headings are optional too, but without them everything lands at position 0 and you lose the ability to scope answers by reading progress.
15–20 highlights per book is plenty to make the Ask flow work well.
Only needed for the character graph, which requires continuous prose rather than
sparse highlights. Chapters are detected from the text itself — ## Chapter 3,
CHAPTER III, CHAPTER ONE, or CHAPTER V: A Title all work.
From a PDF, use the importer rather than pasting text in by hand:
# Always dry-run first — check what it detected before writing
python -m backend.scripts.import_pdf ~/Downloads/*.pdf --dry-run
python -m backend.scripts.import_pdf ~/Downloads/book.pdf
python -m backend.scripts.ingestIt strips running headers/footers, rejoins PDF line wrapping into real paragraphs, skips the table of contents, and assigns positions:
- Chapter positions when the book's body has detectable chapter headings. The
book's own numbers are kept when they run strictly upward, so "chapter 10" in the
UI is chapter 10 in your hands. Books that restart numbering inside each PART
(Crime and Punishment) fall back to a running count, because a position that goes
backwards would break the
position <= reader_positionbound. - Page positions when it can't find reliable chapter markers — plenty of commercial ebook PDFs mark chapters with styling that leaves no textual trace. The plan's "chapter/page position" (NFR1) is meant literally; page bounds are just as spoiler-safe, and arguably easier to set from a physical book.
Force either with --mode chapters / --mode pages. Scanned PDFs are rejected
with a clear error — they'd need OCR, which this importer doesn't do.
Project Gutenberg .txt files also work directly, and are cleaner than any PDF
extraction — worth preferring for public-domain titles.
---
title: Second-Order Thinking
author: Farnam Street
url: https://fs.blog/second-order-thinking/
---
Article text here…Articles have no chapters, so they're always visible regardless of reading position.
python -m backend.scripts.ingest # everything under data/
python -m backend.scripts.ingest data/books/foo.md # just one fileRe-running is safe: a source is deleted and rewritten, never duplicated. Your library files are gitignored — the samples and folder structure are not.
This is the design constraint the whole project is built around, so it's enforced in the data layer, not the prompt:
- Every chunk is tagged with its chapter at ingestion time
(
ingestion/loaders.py). - Retrieval takes a
max_positionargument that becomes a hardwhereclause on the vector query (store/vector_store.py). - Graph nodes and edges carry
introduced_at, andview(position)filters on it (store/graph_store.py). - When a reading
positionis set, the agent'ssearch_webtool is never constructed at all (agent/tools.py) — the model can't reach the live web for a spoiler-scoped question because the tool doesn't exist in that turn, not because it was told not to use it.
A later chapter is never retrieved, so the model can't leak it even if asked to. The graph merge is incremental and keeps the earliest introduction for an entity — a character met in chapter 2 doesn't get pushed to chapter 9 when they reappear.
This held up under a live test worth knowing about: with a position set, the model
still tried calling search_web once (an earlier prompt draft mentioned it
unconditionally). The framework rejected the call — "not a valid tool" — before it
ever ran, so nothing actually reached the web. The prompt is now built dynamically
per request and only mentions tools that actually exist for that call, so the model
doesn't waste a turn on a call that's guaranteed to fail.
Confirmed automatically now, not just by hand: the eval harness (below) includes regression cases for exactly this — a chapter-scoped question that can't be answered from what's available must not reach for the web, and a citation must never exceed the position bound. All 15 cases pass, including these.
python -m backend.scripts.extract_graph the-great-gatsby
python -m backend.scripts.extract_graph anne-of-green-gables --through 20 # partialOne structured-output LLM call over the book's full text
(agent/extract_agent.py), returning every
character and relationship along with the position each is first established at.
Two things worth knowing:
- The extraction call sees the whole book; the reader never does. Spoiler
safety doesn't depend on the model extracting less — it depends on
GraphStore.view(position)only returning nodes/edges whoseintroduced_atis at or before the reader's position (same principle as Ask's retrieval filter). Seeing the whole book in one pass is more accurate and far cheaper than one call per chapter, and it's just as safe, because what the model saw while extracting has no bearing on what the view layer later shows. - A relationship graph isn't a cast list. The first real run over Anne of
Green Gables asked for "every named character, however minor" and got back
86 — 58 of them one-off classmates from a school roster, connected to nothing.
Fixed two ways: the prompt now asks specifically for characters with an
established relationship (or the protagonist), and
extract()drops any non-main character with zero edges as a structural backstop regardless of whether the model complies. Re-running produced 14 real, connected characters.
Safe to re-run or extend: GraphStore.merge() keeps each entity's earliest
seen position rather than overwriting, so extracting through chapter 20 after
already extracting through chapter 10 adds new characters without disturbing
ones already placed.
FR7 asks for tracked reading progress per book. This is what actually makes the
spoiler-safe Ask flow reachable at all: for a while the backend fully supported
source_id/position scoping, but the Ask tab's UI never exposed a way to set
either — only the Graph tab's slider did, and that reset to chapter 1 every
reload since nothing was persisted.
Now data/progress.json holds one position per book
(store/progress_store.py), and both tabs
read/write the same value: pick a book in Ask, and its saved position (or 1, if
none yet) pre-fills the position field; move the Graph slider for that book and
Ask's field reflects it next time you open that book there, and vice versa. Set
it in Ask and ask a spoiler-sensitive question — the same position-filtered
retrieval and web-search lockout from How the spoiler safety works now apply,
because the position reaching ask() is the persisted one, not a value that
only ever lived in one tab's local state.
python -m backend.scripts.eval # all cases
python -m backend.scripts.eval --id gatsby-narrator # just oneA JSON file of test cases (data/eval/cases.json) plus
a scoring script (backend/scripts/eval.py) —
deliberately not RAGAS or another framework (NFR3 explicitly asks for something
lightweight and explainable, not a heavy dependency). Each case can assert:
expect_keywords/expect_no_keywords— answer faithfulnessexpect_web— did the agent correctly decide to search the web or notexpect_max_citation_position— the spoiler bound, checked automatically
15/15 currently pass. Two cases needed fixing during development in a way
worth knowing about if you add more: a case asserting expect_no_keywords: ["Gilbert"] failed when the question itself asked "what happens with Gilbert
Blythe" — the model correctly said it didn't know, but naturally echoed the
name back from the question, which isn't a real leak. Rephrasing questions to
not contain the forbidden keyword themselves makes the assertion mean what it's
supposed to mean.
backend/
config.py paths, model ids, chunking knobs
main.py FastAPI app + routes (also serves the frontend)
schemas.py request models
trace.py JSONL agent traces -> logs/traces.jsonl
cache.py file-based response cache (NFR2)
ingestion/ loaders (chapter detection) -> chunker -> pipeline
store/ vector_store.py (Chroma), graph_store.py (NetworkX),
progress_store.py (reading position, FR7)
llm/ raw single-completion path (providers.py) — used where
no tool-calling is needed, e.g. scripts/check_llm.py
tools/ web_search.py — Tavily client, shared by the agents
agent/ model.py picks the LangChain chat model per provider;
tools.py builds search_library/search_web per request;
ask_agent.py Flow A — ReAct loop + citations
recommend_agent.py Flow B — structured recommendations
write_agent.py Flow C — structured outline drafts
extract_agent.py character/relationship extraction
scripts/
ingest.py embed everything under data/
import_pdf.py convert a book PDF to position-marked text
extract_graph.py run character extraction for one book
eval.py run the eval harness (data/eval/cases.json)
check_llm.py verify the model/API-key setup
clear_cache.py bust the response cache
frontend/
index.html, styles.css, js/{api,app}.js
data/
books/ texts/ articles/ your library (gitignored)
samples/ format examples
graphs/ one JSON character graph per book
eval/cases.json eval harness test cases
progress.json reading position per book (gitignored)
cache/ cached responses (gitignored)
Every feature in the plan is real — nothing left stubbed.
| Feature | Endpoint | Notes |
|---|---|---|
| Ask my library, agentic (Flow A, 2.1–2.3) | POST /api/ask |
The model decides for itself whether to search the library, the web, or both |
| Recommendations (2.5) | POST /api/recommend |
Checks the library for notes on the liked book first, then searches the web for candidates; structured {title, author, reason} output |
| Writing assist (2.4) | POST /api/write/outline |
Same pattern — library + web, structured outline + sources |
| Character graph (2.6) | GET /api/graph/{id} |
Extraction is a separate offline step — see below — not automatic on ingest |
| Reading progress (FR7) | GET/PUT /api/progress/{id} |
Persisted per book, shared between the Ask and Graph tabs — see below |
| Library listing | GET /api/sources |
Run python -m backend.scripts.extract_graph <source_id> per book you want
graphed — it's one LLM call, not part of ingest, matching NFR6 ("async/background
acceptable for character extraction"). Two are extracted already as a demo set
(the-great-gatsby, anne-of-green-gables); run it on any other ingested book.
Embeddings run locally and are free — the provider below only affects answer generation, which is the only part that can cost anything.
# .env
LLM_PROVIDER=gemini # gemini (free tier) | groq (free tier) | anthropic
GEMINI_API_KEY=... # free key: https://aistudio.google.com/apikey
TAVILY_API_KEY=... # free key (1000/month): https://app.tavily.comVerify the setup before running the app:
python -m backend.scripts.check_llm # sends one tiny test prompt
python -m backend.scripts.check_llm --list # models your key can use| Provider | Default answer model | Notes |
|---|---|---|
gemini |
gemini-flash-lite-latest |
Free tier. gemini-flash-latest looks like the obvious default but currently resolves to a model with a 20-requests/day free quota — confirmed by hitting it during development. The lite variant handled sustained testing fine; bump ANSWER_MODEL if you have paid quota. |
groq |
openai/gpt-oss-120b |
Free tier, added as a Gemini fallback — tested and not a clean win. The free "on_demand" tier caps at 8000 tokens/minute per request: Recommend's request (system prompt + two tool schemas + retrieved chunks + its output schema) exceeds that on its own, on both the 120b and 20b models, regardless of timing — not a rate limit that clears, a request that's too big. Also hit an intermittent malformed tool call in testing, and combining tools with response_format needs ToolStrategy explicitly (Groq 400s on the auto-selected native-JSON-mode path — see agent/write_agent.py). Ask is the most likely feature to actually fit under the cap. |
anthropic |
claude-opus-5 |
Paid (~$0.01/question). Best quality. |
Override either model with ANSWER_MODEL / EXTRACTION_MODEL. EXTRACTION_MODEL
runs in background jobs where quality matters more than latency, because
extraction errors compound into the character graph.
Two separate model-calling paths exist on purpose:
backend/llm/providers.py is a plain single-completion
call (used by check_llm and anything that doesn't need tools); the Ask agent
uses LangChain chat model wrappers instead (backend/agent/model.py)
because create_agent requires one. LLM_PROVIDER/ANSWER_MODEL in .env drive
both paths identically — provider selection is still one flag for the whole app.
Ask, Recommend, and Write each cache their response for 24h
(backend/cache.py), keyed on the request plus which model
answered it — NFR2's "cache repeated queries... to control cost." An identical
repeated question skips the LLM call entirely; the response carries
"cached": true so you can tell (the UI shows a ⚡ next to it).
One JSON file per cache entry under data/cache/<namespace>/, matching the
project's existing pattern for small persistent state (progress, graphs) —
deliberately not Redis or an in-memory cache, since a personal single-user app
restarts occasionally and a cache that doesn't survive that isn't earning its
keep. TTL-based rather than invalidated by library changes: the tradeoff NFR2
actually asks for is cost control, not perfect freshness.
python -m backend.scripts.clear_cache # everything
python -m backend.scripts.clear_cache ask # just one namespaceRun this after re-ingesting a book whose answers you know changed, or if you want a genuinely fresh web search on something you already asked about today — TTL alone won't catch either case.
Every Ask, Recommend, Write, and extraction run writes one line to
logs/traces.jsonl: what was asked, how many times each tool was called,
what got cited, the answer, and latency.
tail -1 logs/traces.jsonl | python3 -m json.toolEvery FR/NFR in the plan is now built, including the two gaps found after the initial build-order pass: the Ask tab not actually exposing book/position scoping (fixed — see Reading progress), and NFR2's caching requirement, which had been skipped entirely (fixed — see Response caching). What's left is content and hardening, not missing features:
- Run
extract_graph.pyon more of your ingested books — only two have a character graph so far. - Add real AI/GenAI articles to
data/articles/— Write and Recommend work today, but without library coverage on your actual writing topics they lean entirely on the web tool rather than drawing on anything you've actually read. - Grow
data/eval/cases.jsonpast 15 cases as you find real questions that trip up an answer — that's what turns the harness from a one-time check into an actual regression suite. - NFR6's latency target (~10s for simple queries) depends heavily on Gemini's
free-tier responsiveness, which has been genuinely degraded more than once
during development —
agent/model.py's bounded timeout/retries keep a bad day from hanging or corrupting output, but a slow provider is still slow. Worth revisiting if you move to paid quota or a different provider.