Personal Learning Assistant for Diego Sabajo.
LARA is two things bolted onto one Telegram bot:
- A study system — tracks topics using SM-2 spaced repetition, sends proactive
daily plans via Telegram, reads Google Calendar to plan around your real schedule,
generates focused study briefs via Claude, books
[Mock]mock-interview events on Google Calendar after confirmation, and runs a/discussflow that assesses whether a topic is ready to graduate out of active review. - A knowledge graph pipeline — on
/prepare <topic>, searches your configured blogs for the topic, synthesizes a dense "Concept Note" via Claude, and — once you approve it in Telegram — writes it into a Neo4j concept graph plus SQLite. It's a separate LangGraph graph from the study system today; the intent is for the graph to eventually enrich study briefs, but that link doesn't exist yet.
Both features share the same Telegram bot, the same SQLite database file, and the same FastAPI process, but run as two independent LangGraph graphs with independent checkpointers.
- SM-2 spaced repetition — topics ranked by tier and easiness factor; intervals grow automatically based on session quality
- Morning briefing — sent daily via Telegram with your calendar, free windows, and assigned study blocks
- On-demand study — send
/studyto generate a brief for the highest-priority due topic (defaults to 30 min unless a duration callback is provided) - Done flow — send
/doneafter studying; LARA asks how each session went, prompts for weak areas (topic-type-aware, two-question breakdown), logs everything, and updates SM-2 - In-progress graduation flow — send
/activate, pick an in-progress topic, and promote it to active with first review scheduled for tomorrow - Discuss / mock-readiness flow — send
/discuss; an external Claude session (via the MCP server) runs a live mock/discussion, then reports back a quality score and any repeated weak areas, and LARA decides whether the topic is ready to graduate, needs to go back to study, or isn't ready yet - Calendar safety — reads all events to plan around them and only creates new tagged
[Mock]and[Study]events; it does not modify unrelated calendar entries - Protected block — never sends messages or fires jobs during configured protected hours
- JIT article ingestion —
/prepare <topic>searches configured blog sources (Jina AI Search), reranks results (Voyage AI), and filters out low-quality extractions deterministically (no LLM) - Map-reduce synthesis — Claude Haiku distills each article in parallel (failed or junk distillations are skipped, not fatal), Claude Sonnet reduces across distillations plus any prior concept notes into one Concept Note, then Claude Haiku extracts concepts and relationships from the finished note (guaranteeing they're grounded in what the note actually says)
- Human-in-the-loop approval — LARA sends a preview to Telegram with Keep/Discard buttons before writing anything
- Atomic dual-write — on approval, writes a
concept_notesrow in SQLite and aConcept/ConceptNotesubgraph in Neo4j in one logical transaction (SQLite commits only after the Neo4j transaction commits) - MCP server — exposes topic/session/discuss context as tools so an external Claude session can read and write LARA's data during a live
/discusssession
Telegram ──► FastAPI /webhook ──► dispatcher.py ──► LangGraph graph (agent) ──► Telegram
│ │
has pending interrupt? │
yes → Command(resume) │
no → fresh invoke │
│ │
┌──────────────────┼──────────────────┐
│ │ │
Google Calendar SQLite Claude API
(read + write) (SM-2 state, (study briefs
sessions log, only)
checkpoints)
APScheduler ──► daily_planning (Mon–Fri morning + evening preview) / weekend_brief (Sat–Sun)
Telegram /prepare <topic> ──► handler.py (bypasses dispatcher) ──► LangGraph graph (knowledge)
│
synthesize_node ───┤
(Jina search, Voyage rerank, │
Claude map-reduce synthesis) │
│
prepare_preview_node
(Telegram Keep/Discard buttons)
│
prepare_confirm_node — interrupt()
│
approved ──► write_node ──► SQLite + Neo4j
rejected ──► END
The knowledge graph is a separate LangGraph StateGraph with its own SqliteSaver
checkpointer, using thread ids namespaced kg_{chat_id} (vs. the study graph's bare
{chat_id}) in the same db/state.db file, so the two graphs never collide. In
src/api/telegram/handler.py, any /prepare command or kg_-prefixed callback is
routed straight to the knowledge graph, entirely bypassing dispatcher.py and the
study-system's intent parser.
An external Claude session (e.g. during a live /discuss mock) talks to LARA over the
MCP server mounted at / in src/api/app.py, exposing get_topic_context,
log_session, get_discuss_context, and assess_discuss_readiness as tools.
| Node | Responsibility |
|---|---|
router |
Entry point — routes by trigger type (9 targets: daily, evening, weekend, study, done, pick, activate, discuss, discuss_ready_confirm) |
daily_planning |
Assembles morning/evening plan from calendar + SM-2 + gap finder; sends booking buttons |
await_daily_confirmation |
interrupt() — waits for user to confirm or skip the daily booking proposal |
weekend_brief |
Sat/Sun brief — shows due topics with overdue indicators; no weak areas displayed |
send_duration_picker |
Sends duration buttons; cleans up stale picker |
on_demand |
interrupt() — picks highest-priority due topic for requested duration |
generate_brief |
Calls Claude API — the only LLM call in this graph; sends booking buttons when a slot is available |
await_brief_confirmation |
interrupt() — waits for user to confirm or skip the on-demand booking proposal |
book_events |
Writes [Mock] GCal events after user confirmation |
done_parser |
Queries active unlogged topics from DB. 0 → message. 1 → rating buttons. 2+ → topic picker |
select_done_topic |
interrupt() — receives selected topic name, sends rating buttons, routes to log_session |
log_session |
interrupt() — logs session with quality score; sends weak areas prompt |
log_weak_areas |
interrupt() — saves first weak-areas answer or clears on Skip; routes to log_weak_areas_q2 for topic types with a second question |
log_weak_areas_q2 |
interrupt() — saves second weak-areas answer (topic-type-aware breakdown); ends with remaining unlogged list or all-done message |
study_topic |
Starts /pick flow, sends category inline buttons, cleans up stale lists |
study_topic_category |
interrupt() — handles category tap, sends matching subtopic inline buttons |
study_topic_confirm |
interrupt() — marks selected topic as in_progress, notifies user |
activate_topic |
Lists in-progress topics as inline buttons |
graduate_topic |
interrupt() — graduates selected topic to active SM-2 with first review tomorrow |
confirm_graduate |
interrupt() — confirms graduation after a /discuss readiness check reports "ready" |
discuss_parser |
Entry point for /discuss — resolves the topic and hands off to start_discuss |
start_discuss |
Sends discuss-mode context/instructions so the user can start a live session with an external Claude session |
notify_discuss_ready |
Triggered programmatically by discuss_service.assess_discuss_readiness() when a topic reaches "ready"; sends graduation confirmation buttons |
await_discuss_activation |
interrupt() — waits for confirm/skip on the discuss-triggered graduation proposal |
output |
Sends state["messages"][-1] via Telegram — shared terminal node |
| Node | Responsibility |
|---|---|
synthesize_node |
Searches blogs (Jina), reranks (Voyage), filters invalid extractions, pulls prior concept notes, synthesizes via Claude map-reduce. Routes to END if no usable articles found |
prepare_preview_node |
Formats the concept note and sends Telegram Keep/Discard buttons |
prepare_confirm_node |
interrupt() — first statement, per the HITL rule. Resumes with kg_approve/kg_reject |
write_node |
Only reached on approval — writes to SQLite (concept_notes) and Neo4j (Concept/ConceptNote + relationships) in one logical transaction |
| Layer | Technology |
|---|---|
| Agent framework | LangGraph (two independent graphs: study system + knowledge pipeline) |
| Web server | FastAPI + uvicorn |
| Scheduler | APScheduler (AsyncIO) |
| LLM | Anthropic Claude (via anthropic SDK / langchain-anthropic) — Sonnet for briefs and concept-note synthesis, Haiku for per-article map-step distillation |
| Embeddings / reranking | Voyage AI (voyageai) — voyage-4-lite embeddings, rerank-2 reranking |
| Article search | Jina AI Search (s.jina.ai) — called via raw HTTP (requests), no dedicated pip package |
| Knowledge graph | Neo4j AuraDB (neo4j driver) |
| MCP | mcp[cli] (LARA's own MCP server) + neo4j-mcp-server (Claude Code integration) |
| Messaging | python-telegram-bot |
| Calendar | Google Calendar API v3 (OAuth2) |
| Database | SQLite (via langgraph-checkpoint-sqlite for graph checkpoints; plain sqlite3 for app data) |
| Config | YAML + python-dotenv |
Known gap:
requestsis imported directly bysrc/knowledge/search.pyandsrc/knowledge/extract.pybut isn't listed inrequirements.txt— it currently installs only as a transitive dependency of another package. Pin it explicitly if you hit anImportErroron a clean install.
lara/
├── config.yaml # Schedule, focus windows, protected blocks
├── topics.yaml # Study topic catalog (tier/status/default duration)
├── requirements.txt
├── .env.example
├── pytest.ini
├── db/ # SQLite files (gitignored)
├── credentials/ # GCal OAuth credentials (gitignored)
├── migrations/
│ └── migrate_add_articles_notes.py # Creates `sources` + `concept_notes` tables
├── src/
│ ├── main.py # Entry point — starts FastAPI + scheduler
│ ├── server.py # Backwards compat re-export: from src.api.app import app
│ ├── compat/
│ │ └── langgraph_sqlite_shim.py # In-memory SqliteSaver-compatible shim — currently UNUSED (not imported anywhere; both graphs use the real langgraph-checkpoint-sqlite package)
│ ├── models/
│ │ └── telegram.py # Pydantic models for Telegram webhook payloads
│ ├── agent/ # Study system graph
│ │ ├── graph.py # LangGraph graph + SqliteSaver checkpointer
│ │ ├── nodes.py # Node orchestration + AgentState
│ │ ├── routes.py # Conditional-edge routing functions
│ │ ├── state.py # AgentState TypedDict
│ │ ├── messages.py # Pure (text, buttons) builders for every flow — no I/O
│ │ ├── plan_message.py # Daily/evening plan message section builders
│ │ ├── slot_builders.py # Study-event matching + rebooking helpers
│ │ ├── weak_areas_parser.py # Weak-areas parsing + topic-type vocabularies
│ │ ├── formatting.py # Shared time/date formatting helpers
│ │ └── tools.py # LangGraph tools
│ ├── knowledge/ # Knowledge graph pipeline (separate LangGraph graph)
│ │ ├── graph.py # Compiled StateGraph(KGState) + SqliteSaver, thread id `kg_{chat_id}`
│ │ ├── nodes.py # synthesize_node, prepare_preview_node, prepare_confirm_node, write_node
│ │ ├── state.py # KGState TypedDict
│ │ ├── clients.py # KnowledgeClients — Anthropic/Voyage/Jina/Neo4j client setup
│ │ ├── search.py # Jina search + Voyage rerank; dev runner: python -m src.knowledge.search "<topic>"
│ │ ├── extract.py # Deterministic extraction validation (no LLM); extract_clean_text/extract_top_results are deprecated (Jina search now returns full content)
│ │ ├── lookup.py # Finds prior concept notes via embedding similarity (read-only)
│ │ ├── resolve.py # Matches candidate concept names to existing Neo4j Concept nodes
│ │ ├── synthesize.py # Claude map-reduce concept note synthesis; dev runner: python -m src.knowledge.synthesize "<topic>"
│ │ └── write.py # Atomic SQLite + Neo4j write on approval
│ ├── api/
│ │ ├── app.py # FastAPI app factory + lifespan; mounts the MCP app at "/"
│ │ ├── routes/
│ │ │ ├── health.py # GET /health
│ │ │ ├── webhook.py # POST /webhook (auth + parse)
│ │ │ ├── scheduler_status.py # GET /scheduler-status
│ │ │ └── mcp.py # MCP server: get_topic_context, log_session, get_discuss_context, assess_discuss_readiness
│ │ └── telegram/
│ │ ├── handler.py # handle_update() — routes /prepare and kg_* callbacks to the knowledge graph, everything else to dispatcher
│ │ ├── intent_parser.py # Intent dataclass; parse_callback / parse_message
│ │ ├── callback_handlers.py # one function per callback type
│ │ ├── message_handlers.py # one function per command
│ │ ├── types.py
│ │ └── dispatcher.py # dedup sets, idempotency lock, invoke_safe() — study graph only
│ ├── core/
│ │ ├── sm2.py # SM-2 algorithm (pure Python)
│ │ └── gap_finder.py # Free window computation (pure Python)
│ ├── infrastructure/
│ │ ├── db.py # Schema init, seed, connection helper
│ │ ├── scheduler.py # APScheduler jobs (weekday, weekend, evening)
│ │ └── time.py # Local timezone helpers
│ ├── integrations/
│ │ ├── gcal.py # Google Calendar read + write
│ │ ├── telegram_client.py # send_message / send_buttons / remove_buttons
│ │ └── claude_api.py # generate_brief()
│ ├── repositories/
│ │ ├── session_repository.py
│ │ ├── sm2_repository.py
│ │ └── topic_repository.py
│ └── services/
│ ├── topic_service.py # graduate_topic(), get_in_progress_topics()
│ ├── discuss_service.py # get_discuss_context(), assess_discuss_readiness() — backs the MCP discuss tools
│ └── view_service.py
└── tests/
├── conftest.py
├── test_sm2.py
├── test_gap_finder.py
├── test_tools.py
├── test_study_topic.py
├── test_done_flow.py
├── test_graduate_topic.py
├── test_discuss_flow.py
├── test_discuss_service.py
├── test_kg_graph.py
├── test_nodes_daily_planning.py
├── test_nodes_weekend_brief.py
├── test_output_refactor.py
├── test_repositories.py
├── test_topic_service.py
├── test_view_service.py
├── test_dispatcher.py
├── test_telegram_client.py
└── test_webhook_handler.py
git clone <repo-url>
cd lara
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtcp .env.example .envEdit .env — required for the study system:
ANTHROPIC_API_KEY=sk-ant-...
TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHAT_ID=...
GOOGLE_CALENDAR_ID=...
GOOGLE_CREDENTIALS_PATH=credentials/gcal_credentials.json
DATABASE_PATH=db/learning.db
STATE_DATABASE_PATH=db/state.db
WEBHOOK_SECRET= # generate: python -c "import secrets; print(secrets.token_hex(32))"Additionally required for the knowledge graph pipeline — see steps 5 and 6 below for where these come from:
NEO4J_URI=
NEO4J_USERNAME=
NEO4J_PASSWORD=
NEO4J_DATABASE=
AURA_INSTANCEID=
AURA_INSTANCENAME=lara-knowledge-graph
VOYAGE_API_KEY=
JINA_API_KEY=src/knowledge/clients.py raises EnvironmentError at startup of any knowledge-graph
node if any of ANTHROPIC_API_KEY, VOYAGE_API_KEY, JINA_API_KEY, NEO4J_URI,
NEO4J_USERNAME, NEO4J_PASSWORD, NEO4J_DATABASE is missing — you only need these if
you're working on or exercising /prepare.
- Go to Google Cloud Console → APIs & Services → Credentials
- Create an OAuth 2.0 Client ID (Desktop app)
- Download the JSON and save to
credentials/gcal_credentials.json - On first run, a browser window opens for OAuth consent — token saved to
credentials/token.json
python -m src.infrastructure.dbCreates db/learning.db, seeds topics from topics.yaml, and prints them to confirm.
Also reset and reseed with:
rm db/learning.db && python -m src.infrastructure.dbThen run the knowledge-graph migration to add the sources and concept_notes tables:
python migrations/migrate_add_articles_notes.pyKnown gap: there's no seed script for the
sourcestable (the blog listsearch_blogs_for_topic()reads from). You'll need toINSERTyour blog sources by hand, e.g.:sqlite3 db/learning.db "INSERT INTO sources (name, url) VALUES ('Example Blog', 'https://example.com')"
LARA uses a Neo4j AuraDB instance to store a concept graph: articles and notes link to
Concept nodes, which are intended to eventually connect to topics for enriched
study briefs (not implemented yet — the two systems are independent today).
- Provision a free-tier instance at the AuraDB console
- Copy
NEO4J_URI,NEO4J_USERNAME,NEO4J_PASSWORD,NEO4J_DATABASE,AURA_INSTANCEID, andAURA_INSTANCENAMEinto.env
Apply the schema constraints (idempotent, safe to re-run):
python migrations/migrate_neo4j_constraints.pyOr run the Cypher directly:
CREATE CONSTRAINT concept_name_unique IF NOT EXISTS FOR (c:Concept) REQUIRE c.name IS UNIQUE;
CREATE CONSTRAINT article_id_unique IF NOT EXISTS FOR (a:Article) REQUIRE a.id IS UNIQUE;
CREATE CONSTRAINT note_id_unique IF NOT EXISTS FOR (n:Note) REQUIRE n.id IS UNIQUE;- Voyage AI — used for embeddings (
voyage-4-lite) and reranking (rerank-2). Get a key at voyageai.com and setVOYAGE_API_KEY. - Jina AI — used for article search (
s.jina.ai). Get a key at jina.ai and setJINA_API_KEY. There's no pip package for this — it's called via rawrequestsHTTP calls.
You can exercise the pipeline piece by piece without touching Telegram:
python -m src.knowledge.search "<topic>" # search + rerank only
python -m src.knowledge.synthesize "<topic>" # full search → synthesis, prints the concept note
python -m src.knowledge.dev_trigger "<topic>" # sends the real Telegram preview (needs the server running to handle the button tap)The repo includes .mcp.json which configures the neo4j-mcp-server so Claude Code can query and write the graph directly via write-cypher / read-cypher / get-schema tools.
The MCP server reads credentials from environment variables — no credentials live in .mcp.json. Credentials are kept in .claude/settings.local.json (gitignored). To set up on a new machine:
- Copy your credentials into
.claude/settings.local.jsonunder theenvkey:
{
"env": {
"NEO4J_URI": "neo4j+s://...",
"NEO4J_USERNAME": "...",
"NEO4J_PASSWORD": "...",
"NEO4J_DATABASE": "..."
}
}- Restart Claude Code — the
neo4jMCP server loads automatically (.mcp.jsonis already approved viaenableAllProjectMcpServers: truein.claude/settings.json).
To point at a different Neo4j instance, change only the four NEO4J_* values in .claude/settings.local.json and restart.
Separately, LARA's own MCP server (src/api/routes/mcp.py) is mounted at / in the
FastAPI app and exposes get_topic_context, log_session, get_discuss_context, and
assess_discuss_readiness — this is what an external Claude session calls into during
a live /discuss mock session. It runs automatically with the app; no separate setup.
curl "https://api.telegram.org/bot<TOKEN>/setWebhook?url=https://<your-domain>/webhook&secret_token=<WEBHOOK_SECRET>"python -m src.mainStarts FastAPI (port 8000) and APScheduler in a single async process.
curl http://localhost:8000/scheduler-statuscurl http://localhost:8000/health
# {"status": "ok"}python -m src.agent.graphsqlite3 db/learning.db "SELECT id, topic_id, studied_at FROM sessions WHERE topic_id = (SELECT id FROM topics WHERE name = '(TOPIC_NAME)') ORDER BY studied_at DESC LIMIT 5"
sqlite3 db/learning.db "DELETE FROM sessions WHERE id = (ID)"
sqlite3 db/learning.db "UPDATE topics SET easiness_factor = 2.5, interval_days = 1, repetitions = 0, next_review = date('now'), updated_at = CURRENT_TIMESTAMP WHERE id = (TOPIC_ID)"
sqlite3 db/state.db "DELETE FROM checkpoints; DELETE FROM writes;"rm db/learning.db
python -m src.infrastructure.db
python -m src.infrastructure.db
# Activate (move to SM-2)
sqlite3 db/learning.db "UPDATE topics SET status = 'active', next_review = date('now'), updated_at = CURRENT_TIMESTAMP WHERE name = 'TOPIC_NAME'"
# Mark in_progress
sqlite3 db/learning.db "UPDATE topics SET status = 'in_progress', updated_at = CURRENT_TIMESTAMP WHERE name = 'TOPIC_NAME'"
# Deactivate (remove from SM-2)
sqlite3 db/learning.db "UPDATE topics SET status = 'inactive', updated_at = CURRENT_TIMESTAMP WHERE name = 'TOPIC_NAME'"☀️ Good morning Diego — Tuesday April 7
📅 Your day:
09:00 Team standup (30min)
🎯 Today's mock interview(s):
10:00–11:00 [Mock] Gen AI System Design (60min)
11:00–12:00 [Mock] Data Structures and Algorithms (60min)
Confirm these mock interview blocks?
[Yes, book them] [Skip]
Send /study to generate an AI brief immediately (default 30 min).
Duration callbacks (30/45/60 min) are also supported when that keyboard is presented.
[30 min] [45 min] [60 min]
Send /done after each session. Each /done call logs one topic.
Single unlogged topic — goes straight to rating:
LARA: How did Gen AI System Design go?
[😕 Hard] [😐 OK] [😊 Easy]
[tap 😐 OK]
LARA: Any weak areas to note? Reply with text or tap Skip.
[Skip]
You: Trade-offs in vector DB selection
LARA: ✅ Gen AI System Design logged. All done for today! 💪
Multiple unlogged topics — shows picker first:
LARA: Which session are you logging?
[Gen AI System Design] [Data Structures and Algorithms]
[tap Gen AI System Design]
LARA: How did Gen AI System Design go?
[😕 Hard] [😐 OK] [😊 Easy]
[tap 😐 OK]
LARA: Any weak areas to note? Reply with text or tap Skip.
[Skip]
LARA: ✅ Gen AI System Design logged. Still unlogged: Data Structures and Algorithms. Press /done when you're ready.
| Button | Score | SM-2 effect |
|---|---|---|
| 😕 Hard | 2 | Interval resets to 1 day |
| 😐 OK | 3 | Modest growth |
| 😊 Easy | 5 | Fast growth |
Send /discuss to start a live mock/discussion session for an in-progress topic. An
external Claude session (connected via the MCP server) runs the session, then reports
back a quality score and any weak areas via assess_discuss_readiness. LARA applies a
readiness rubric: a repeated weak area across recent sessions routes the topic back to
study; no repeats plus a high quality score marks it ready and sends a graduation
confirmation; otherwise it's not ready yet and stays in the discuss loop.
LARA: Discuss mode started for Gen AI System Design. Chat with your reviewer, then wrap up when done.
[... external Claude session runs the mock ...]
LARA: Nice work — no repeated weak areas and strong quality this time.
Ready to graduate Gen AI System Design to active review?
[Yes, graduate it] [Not yet]
Send /prepare <topic> to search your configured blogs and synthesize a concept note.
You: /prepare vector database indexing
LARA: Searching your sources for "vector database indexing"...
LARA: Synthesizing concept note from 5 articles...
LARA: 📝 Vector Database Indexing
HNSW trades recall for speed via approximate graph traversal...
[full synthesized note]
Keep this note?
[✅ Keep] [❌ Discard]
[tap ✅ Keep]
LARA: Saved — 6 concepts linked, 4 new relationships written to the graph.
Edit topics.yaml and re-run python -m src.core.db to seed/update topics.
Seeding uses upsert semantics (ON CONFLICT(name) DO UPDATE) for tier, status, and conditional next_review handling.
topics:
- name: "Your Topic"
tier: 1 # 1 = high priority, 2 = medium, 3 = backgroundFocus windows and protected blocks:
focus_windows:
- start: "08:00"
end: "09:00"
- start: "10:00"
end: "22:00"
protected_blocks:
- start: "15:00"
end: "19:00"python -m pytest tests/ -vStudy system tests are pure Python — no API calls needed. Knowledge graph tests
(test_kg_graph.py) mock the LLM/Jina/Voyage/Neo4j calls and exercise the graph
orchestration (interrupt/resume, approval → write, rejection → no write, synthesis
failure → END) rather than hitting real external services.
.envandcredentials/are gitignored and never committed- Every webhook request validated against
WEBHOOK_SECRET(HTTP 403 on mismatch) - Calendar write path creates new
[Mock]events only; existing events are not modified - SQLite files are local only — never exposed via HTTP