A sophisticated document-to-flashcard pipeline powered by multi-agent LangGraph orchestration. slide2anki transforms lecture PDFs into high-quality Anki flashcards using hierarchical graph-based agents, with every card traceable to its visual source evidence.
- Holistic Document Processing: Processes documents as coherent units with 15% overlapping chunks for context continuity
- Smart Image Handling: Extracts images, classifies them (formula, diagram, code, table), and transcribes to appropriate formats
- Vision-First Processing: Treats slides as visual documents, preserving diagrams, formulas, and layout
- Natural Deduplication: Metadata and repeated content handled once, not per-slide
- Intelligent Optimization: Automatic text-only page detection saves 30-60% on API costs
- Fault-Tolerant Execution: PostgreSQL-backed checkpointing enables job resumption after failures
- Evidence Traceability: Every flashcard links back to the exact slide region it came from
- Provider Agnostic: Supports OpenAI GPT-5.x, Google Gemini 3, xAI Grok 4, Claude 4.5, and local Ollama models
![]() |
![]() |
┌─────────────────────────────────────────────────────────────────────────────┐
│ slide2anki │
├─────────────┬─────────────┬─────────────┬─────────────┬─────────────────────┤
│ Web UI │ FastAPI │ Worker │ Core │ Infrastructure │
│ (Next.js) │ (API) │ (Jobs) │ (LangGraph) │ │
├─────────────┼─────────────┼─────────────┼─────────────┼─────────────────────┤
│ React 18 │ SQLAlchemy │ Redis Queue │ Multi-Agent │ PostgreSQL 16 │
│ TypeScript │ Pydantic 2 │ Checkpoint │ Pipelines │ Redis 7 │
│ Tailwind │ AsyncIO │ Progress │ Adapters │ MinIO S3 │
│ Zustand │ SSE Stream │ Retry │ Schemas │ Docker Compose │
└─────────────┴─────────────┴─────────────┴─────────────┴─────────────────────┘
| Component | Technology | Responsibility |
|---|---|---|
| Web UI | Next.js 14, React 18, Tailwind, Zustand | User interface for uploads, review, settings |
| API | FastAPI, SQLAlchemy, asyncpg | REST endpoints, request validation, job dispatch |
| Queue | Redis + RQ | Decouples long-running tasks from HTTP requests |
| Worker | Python worker process | Executes pipeline jobs, manages checkpoints |
| Postgres | PostgreSQL 16 | Stores projects, documents, markdown, cards, jobs, checkpoints |
| MinIO | S3-compatible storage | Stores PDFs, slide images, exports |
| Core | LangGraph, Pydantic | Framework-agnostic multi-agent extraction and generation |
The core of slide2anki is a hierarchical multi-agent system built with LangGraph. This architecture enables complex document processing through composable, fault-tolerant graph execution.
slide2anki supports two extraction pipelines:
Holistic Pipeline (Default): Processes documents as coherent units with overlapping chunks. Produces higher quality output with natural deduplication and context preservation.
Legacy Pipeline: Per-slide extraction with region segmentation. Available as fallback for specific use cases.
┌──────────────────────────────────────────────────────────────────────────────┐
│ build_holistic_graph (Orchestrator) │
│ Processes documents as coherent units with smart image handling │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────────┐ ┌──────────────┐ │
│ │ ingest │───▶│ render │───▶│ extract │───▶│ classify │ │
│ │ node │ │ node │ │ images │ │ images │ │
│ └─────────┘ └─────────┘ └─────────────┘ └──────┬───────┘ │
│ │ │
│ ┌───────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │
│ │ transcribe │───▶│ extract │───▶│ detect │───▶│ assemble │ │
│ │ images │ │ document │ │ chapters │ │ markdown │ │
│ └─────────────┘ └──────┬──────┘ └─────────────┘ └──────────────┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Chunk 1 │ │ Chunk 2 │ │ Chunk N │ (15% overlap) │
│ │ slides 1-10│ │ slides 9-18│ │slides N-M │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Image Processing Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Extract Images Classify Images │
│ ┌────────────────────────┐ ┌────────────────────────────┐ │
│ │ - Position detection │──────▶│ - Formula detection │ │
│ │ - Size filtering │ │ - Diagram identification │ │
│ │ - Repetition counting │ │ - Code/table recognition │ │
│ │ - Header/footer filter │ │ - Logo/decorative filter │ │
│ └────────────────────────┘ └─────────────┬──────────────┘ │
│ │ │
│ ▼ │
│ Transcribe Images │
│ ┌────────────────────────────┐ │
│ │ Formula → LaTeX ($$...$$) │ │
│ │ Code → Code blocks │ │
│ │ Table → Markdown table │ │
│ │ Diagram → Description │ │
│ └────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
| Graph | Purpose | Nodes | Mode |
|---|---|---|---|
build_holistic_graph |
Document-level extraction with chunking | ingest → render → images → extract → chapters → assemble | Default |
build_markdown_graph |
Legacy per-slide extraction | ingest → render → slide_worker → markdown | Fallback |
build_slide_graph |
Per-slide segmentation (legacy) | config → segment → region_worker | Legacy |
build_region_graph |
Region claim extraction (legacy) | extract → verify → repair (loop) | Legacy |
build_card_graph |
Markdown → Flashcards generation | write_cards → critique → repair → dedupe | Both modes |
Each graph maintains typed state using Pydantic models with reducer functions for parallel aggregation:
class MarkdownPipelineState(TypedDict, total=False):
pdf_data: bytes # Input PDF
document: Document # Parsed document
slides: list[Slide] # Rendered pages
claims: Annotated[list[Claim], _merge_claims] # Aggregated claims (reducer)
markdown_blocks: list[MarkdownBlock] # Output blocks
errors: list[str] # Error accumulator
def _merge_claims(existing: list[Claim], incoming: list[Claim]) -> list[Claim]:
"""Reducer: Combines claims from parallel slide workers."""
return [*existing, *incoming] if existing else list(incoming or [])The Annotated[list[Claim], _merge_claims] pattern enables fan-out/fan-in parallelism: multiple slide workers run concurrently, and their claim lists are automatically merged.
LangGraph's Send API enables dynamic parallelism based on runtime state:
def _dispatch_slides(state: MarkdownPipelineState) -> list[Send]:
"""Fan-out: Send each slide to a parallel worker."""
slides = state.get("slides", [])
return [Send("slide_worker", {"slide": slide}) for slide in slides]
# In graph construction:
graph.add_conditional_edges("render", _dispatch_slides)This pattern is used at two levels:
- Slide dispatch: After rendering, each slide is sent to its own
slide_workersubgraph - Region dispatch: After segmentation, each region is sent to its own
region_workersubgraph
PDF Upload → Validation → Page Rendering → Text-Only Detection
Key Innovation: Intelligent Text-Only Detection
Before processing, each page is analyzed to determine if it's text-only:
def _analyze_page_content(pdf_data: bytes) -> list[tuple[bool, str | None]]:
"""Analyze PDF pages to detect text-only pages and extract text."""
with pdfplumber.open(BytesIO(pdf_data)) as pdf:
for page in pdf.pages:
# Calculate meaningful image area (excluding small icons < 50px)
meaningful_image_area = sum(
w * h for img in page.images
if (w := img["x1"] - img["x0"]) > 50 and (h := img["bottom"] - img["top"]) > 50
)
# Text-only if images cover < 5% of page
is_text_only = (meaningful_image_area / page_area) < 0.05
if is_text_only:
extracted_text = page.extract_text() # Direct PDF text extractionBenefits:
- 30-60% cost reduction: Text-only pages skip the vision model entirely
- Faster processing: Text extraction is ~10x faster than vision inference
- Same quality: Extracted text feeds into the same extraction prompts
Document → Chunking (15% overlap) → Extract per Chunk → Merge → Deduplicate
The holistic pipeline processes documents as coherent units rather than per-slide:
Chunking Strategy:
class ChunkingConfig(BaseModel):
target_chunk_size: int = 10 # Slides per chunk
overlap_ratio: float = 0.15 # 15% overlap between chunks
min_chunk_size: int = 3
max_chunk_size: int = 20
def create_chunks(self, total_slides: int) -> list[DocumentChunk]:
"""Create overlapping chunks for context continuity."""Benefits over per-slide extraction:
| Aspect | Per-Slide (Legacy) | Holistic (Default) |
|---|---|---|
| Metadata handling | Repeated per slide | Mentioned once |
| Context | Isolated | Preserved across slides |
| API calls | Per region (~50+) | Per chunk (~2-4) |
| Deduplication | Post-processing | Natural |
| Quality | Variable | Consistent |
Image Processing:
Images are extracted, classified, and transcribed intelligently:
| Image Type | Detection | Processing |
|---|---|---|
| Formula | Math symbols, equation layout | Transcribed to LaTeX ($$E=mc^2$$) |
| Code | Syntax patterns, monospace | Transcribed to code blocks |
| Table | Grid structure | Converted to markdown table |
| Diagram | Shapes, arrows, flowcharts | Described in text |
| Logo | Header/footer, repetition | Filtered out |
| Decorative | Small size, edges | Filtered out |
Image Filtering Rules:
# Filter images based on position, size, and repetition
header_threshold: float = 0.15 # Top 15% = likely branding
footer_threshold: float = 0.15 # Bottom 15% = likely branding
min_image_area: float = 0.05 # < 5% of slide = likely icon
repetition_threshold: float = 0.5 # > 50% of slides = likely logoRaw Markdown → Detect Headers → Build Chapter Outline → Organize Content
The pipeline detects chapter structure from:
- Table of contents slides
- Markdown headers in extracted content
- Topic transitions identified by the model
Content is deduplicated and organized into canonical markdown blocks:
class MarkdownBlock(BaseModel):
anchor_id: str # Content-based hash for deduplication
kind: str # definition, fact, process, etc.
content: str # Markdown-formatted content
evidence: list[Evidence] # Source slide regions
position_index: int # Ordering within chapterDeduplication Strategy:
- Content is normalized (whitespace, casing)
- SHA-256 hash generates
anchor_id - Duplicate blocks merge their evidence lists (preserving all sources)
slide2anki uses langgraph-checkpoint-postgres to persist graph state after each node execution:
from langgraph.checkpoint.postgres import PostgresSaver
with get_checkpointer() as checkpointer:
graph = build_markdown_graph(adapter, checkpointer=checkpointer)
result = await graph.ainvoke(
{"pdf_data": pdf_data},
config={"configurable": {"thread_id": job_id}} # Job ID = Thread ID
)How It Works:
- Before each node: Current state is loaded from PostgreSQL
- After each node: Updated state is persisted with the node name
- On failure: Job can be retried, resuming from the last successful node
- Thread isolation: Each job has its own checkpoint stream (keyed by
job_id)
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ ingest │────▶│ render │────▶│ slide_ │──X──│ markdown │
│ ✓ │ │ ✓ │ │ worker │ │ (fail) │
└─────────────┘ └─────────────┘ └──────┬──────┘ └─────────────┘
│
Checkpoint saved ──────────┘
─── Job Retry ───
│
▼
┌─────────────┐ ┌─────────────┐
│ slide_ │────▶│ markdown │
│ worker │ │ ✓ │
│ (resume) │ └─────────────┘
└─────────────┘
API Endpoint:
POST /api/v1/jobs/{job_id}/retry
# Response
{
"id": "397ab870-389e-446e-bf95-bd6d6e519fbd",
"status": "pending",
"progress": 0,
"current_step": "queued"
}The BaseModelAdapter interface enables provider-agnostic LLM calls:
class BaseModelAdapter(ABC):
@abstractmethod
async def extract_claims(self, image_data: bytes, prompt: str) -> list[dict]:
"""Vision model: Extract claims from slide image."""
@abstractmethod
async def generate_structured(self, prompt: str, image_data: bytes | None) -> dict | list:
"""Text/Vision model: Generate structured JSON output."""
@abstractmethod
async def generate_cards(self, claims: list[Claim], prompt: str) -> list[dict]:
"""Text model: Generate flashcard drafts from claims."""
@abstractmethod
async def critique_cards(self, cards: list[CardDraft], prompt: str) -> list[dict]:
"""Text model: Quality critique of flashcard drafts."""| Provider | Adapter | Models | Use Case |
|---|---|---|---|
| OpenAI | OpenAIAdapter |
GPT-5.2, GPT-5.1, GPT-5-mini, GPT-5-nano | Production, flagship quality |
GoogleAdapter |
Gemini 3 Pro/Flash Preview, Gemini 2.5 | Cost-effective, 1M context | |
| xAI | XAIAdapter |
Grok 4.1 Fast, Grok 4 Fast | 2M context window |
| OpenRouter | OpenAIAdapter |
Any OpenRouter model | Multi-provider access |
| Ollama | OllamaAdapter |
LLaVA, Llama 3.3, Qwen 2.5 | Local, privacy-focused |
Adapters handle provider-specific quirks automatically:
# OpenAI: Handle max_tokens vs max_completion_tokens
if _uses_max_completion_tokens(model): # GPT-5.x, o-series models
create_kwargs["max_completion_tokens"] = 4096
else:
create_kwargs["max_tokens"] = 4096
# Google: Handle empty responses gracefully
if not response.candidates or not candidate.content.parts:
logger.warning(f"No content in response (finish_reason={finish_reason})")
return "" # Graceful degradation instead of crash
# xAI: Uses OpenAI-compatible API with rate limit handling
response = await client.chat.completions.create(
model="grok-4-1-fast-non-reasoning",
messages=messages,
max_tokens=4096,
)Project (workspace)
├── Document (uploaded PDF)
│ └── Slide (rendered page image)
│ ├── is_text_only: bool # Optimization flag
│ ├── extracted_text: str # Pre-extracted for text-only pages
│ └── Claim (extracted atomic fact)
│ └── Evidence (slide region bbox + snippet)
├── Chapter (document section)
│ └── MarkdownBlock (canonical content unit)
│ └── Evidence[] (source regions, merged on dedup)
├── MarkdownVersion (full snapshot for rollback)
├── Deck (flashcard collection)
│ └── CardDraft (generated card)
│ └── CardRevision (edit history)
├── GenerationConfig (card generation settings)
└── Job (async task)
├── JobEvent[] (progress log)
└── Checkpoint (LangGraph state, in postgres)
┌──────┐ Upload ┌─────┐ Enqueue ┌───────┐ Process ┌──────────┐
│ User │─────────▶│ API │───────────▶│ Redis │───────────▶│ Worker │
└──────┘ PDF └─────┘ Job └───────┘ └────┬─────┘
│ │
│ Store PDF │ Run Graph
▼ ▼
┌─────────┐ ┌──────────────┐
│ MinIO │ │ LangGraph │
└─────────┘ │ Pipeline │
└──────┬───────┘
│ │
│ Store slides │ Checkpoint
▼ ▼
┌─────────┐ Persist results ┌──────────────┐
│ MinIO │◀───────────────────────────│ PostgreSQL │
└─────────┘ └──────────────┘
│
┌────────────────────────────────────────┘
▼
┌─────────┐ Stream ┌─────┐ Display ┌──────┐
│ Redis │───────────▶│ API │──────────▶│ User │
└─────────┘ Progress └─────┘ SSE └──────┘
- Upload: User uploads PDF via Web UI
- Store: API stores PDF in MinIO, creates
Documentrecord - Enqueue: API creates
Jobrecord, enqueues to Redis - Dequeue: Worker picks up job from Redis queue
- Process: Worker runs
build_markdown_graphwith checkpointing - Checkpoint: State persisted to PostgreSQL after each node
- Progress: Worker publishes progress to Redis pub/sub
- Stream: API streams progress to UI via Server-Sent Events
- Persist: Worker saves slides (MinIO), claims, blocks (PostgreSQL)
- Complete: Job marked complete, user can review/generate cards
- Docker and Docker Compose
- Node.js 20+
- Python 3.11+
- A model provider API key
git clone https://github.com/yourusername/slide2anki.git
cd slide2anki
./infra/scripts/run_full_stack.sh --rebuild --logsOpen http://localhost:3000 and configure your model provider in Settings.
# Start infrastructure only
./infra/scripts/dev.sh
# In separate terminals:
cd apps/web && npm install && npm run dev # Frontend
cd apps/api && uv sync && uv run uvicorn app.main:app --reload # API
cd workers/runner && uv sync && uv run python -m runner.worker # WorkerConfigure the model provider in the web UI at /settings:
| Provider | Base URL | Recommended Models |
|---|---|---|
| OpenAI | (default) | gpt-5.2, gpt-5-mini (cost-effective) |
| (default) | gemini-3-flash-preview, gemini-3-pro-preview | |
| xAI | https://api.x.ai/v1 | grok-4-1-fast-non-reasoning |
| OpenRouter | https://openrouter.ai/api/v1 | Any model (includes free tiers) |
| Ollama | http://localhost:11434 | llava, llama3.3 |
slide2anki/
├── apps/
│ ├── web/ # Next.js 14 frontend
│ │ ├── app/ # App router pages
│ │ ├── components/ # React components
│ │ └── lib/ # API client, state
│ └── api/ # FastAPI backend
│ ├── app/
│ │ ├── routers/ # API endpoints
│ │ ├── db/ # SQLAlchemy models
│ │ ├── services/ # Business logic
│ │ └── schemas/ # Pydantic schemas
│ └── alembic/ # Database migrations
├── packages/
│ └── core/ # LangGraph pipeline (framework-agnostic)
│ └── slide2anki_core/
│ ├── graph/ # Graph builders and nodes
│ │ ├── build_holistic_graph.py # Default: document-level extraction
│ │ ├── build_markdown_graph.py # Legacy: per-slide extraction
│ │ ├── build_card_graph.py # Markdown → flashcards
│ │ ├── holistic/ # Holistic pipeline nodes
│ │ │ ├── extract_images.py # Image extraction & filtering
│ │ │ ├── classify_images.py # Image type classification
│ │ │ ├── transcribe_images.py # Formula/code transcription
│ │ │ ├── extract_document.py # Chunked document extraction
│ │ │ ├── detect_chapters.py # Chapter structure detection
│ │ │ └── assemble_markdown.py # Final markdown assembly
│ │ └── nodes/ # Legacy pipeline nodes
│ ├── model_adapters/ # LLM provider adapters
│ ├── schemas/ # Pydantic models
│ │ ├── images.py # Image processing schemas
│ │ ├── chapters.py # Chapter and chunking schemas
│ │ └── ... # Core schemas
│ └── utils/ # Helpers (retry, logging, PDF)
├── workers/
│ └── runner/ # Background job worker
│ └── runner/
│ ├── tasks/ # Job implementations
│ ├── config.py # Worker settings
│ └── worker.py # RQ worker entry
└── infra/
├── docker/ # Docker Compose configs
└── scripts/ # Dev scripts
- Python 3.11+ - Type hints, async/await
- FastAPI - High-performance async API framework
- SQLAlchemy 2.0 - Async ORM with type safety
- LangGraph - Graph-based agent orchestration
- Pydantic 2 - Data validation and serialization
- Redis + RQ - Job queue with pub/sub progress
- PostgreSQL 16 - Primary database + checkpoint storage
- MinIO - S3-compatible object storage
- Next.js 14 - React framework with App Router
- React 18 - UI components with hooks
- TypeScript - Type-safe frontend code
- Tailwind CSS - Utility-first styling
- Zustand - Lightweight state management
- SWR - Data fetching with caching
- Docker Compose - Local development environment
- Alembic - Database migrations
- Multi-agent LangGraph pipeline
- Vision-based slide extraction
- Text-only page optimization
- PostgreSQL checkpointing for job resume
- Multiple model provider support
- Real-time progress streaming (SSE)
- APKG export with embedded images
- Evidence-linked card review UI
- Holistic document processing pipeline
- Smart image extraction and classification
- Formula transcription to LaTeX
- Chapter detection and organization
- Chunk-based processing with overlap
- Batch document processing
- Diagram-specific card generation
- Formula rendering in cards (MathJax/KaTeX)
- User authentication and multi-tenancy
- Cloud deployment with accounts
- Spaced repetition study mode
See CONTRIBUTING.md for guidelines.
Source Available - Personal use only. See LICENSE for details.

