A multi-agent system that autonomously generates CEFR-aligned language learning curricula (Vocabulary + Grammar) as strictly-typed JSON. Built with LangGraph, grounded via a RAG pipeline backed by Qdrant.
Input: target language + CEFR level (e.g. Spanish, A2)
Output: vocabulary.json + grammar.json — clean, deduplicated, Pydantic-validated
# 1. Clone and install
git clone <repo-url>
cd autonomous-curriculum-generator
pip install -r requirements.txt
# 2. Configure
cp .env.example .env
# Add your GEMINI_API_KEY (free at https://aistudio.google.com)
# 3. Ingest mock CEFR data into Qdrant (one-time setup)
python scripts/ingest_qdrant.py
# 4. Run
python main.py --language Spanish --level A2
# Output files:
# outputs/Spanish_A2_vocabulary.json
# outputs/Spanish_A2_grammar.jsonflowchart TD
INPUT(["`**Input**
language + level`"]) --> CR
CR["**1 · Context Retriever**
Qdrant RAG
─────────────────
embed query → filter by
language + level → cosine
rerank → top-4 snippets"]
CR --> CA
CA["**2 · Curriculum Architect**
gemini-1.5-pro
─────────────────
Draft vocabulary + grammar
from RAG context only"]
CA --> PR
PR["**3 · Pedagogical Reviewer**
gemini-1.5-pro
─────────────────
Check level appropriateness
Validate sequencing
Flag negative constraints"]
PR -->|"pedagogy_errors ≠ ∅
AND iter ≤ 3"| RF
PR -->|"no errors"| FG
FG["**4 · Format & Quality Guard**
gemini-1.5-flash + Pydantic
─────────────────
Parse + validate schema
Deduplicate items
Verify prerequisite DAG"]
FG -->|"format_errors ≠ ∅
AND iter ≤ 3"| RF
FG -->|"all clear"| END
RF["**5 · Refiner**
gemini-1.5-pro
─────────────────
Patch listed errors only
Preserve base curriculum
iter_count += 1"]
RF --> PR
PR -..->|"iter > 3 · fallback"| END
FG -..->|"iter > 3 · fallback"| END
END(["**Output**
vocabulary.json
grammar.json"])
style INPUT fill:#E1F5EE,stroke:#0F6E56,color:#085041
style END fill:#E1F5EE,stroke:#0F6E56,color:#085041
style CR fill:#EAF3DE,stroke:#3B6D11,color:#173404
style CA fill:#EEEDFE,stroke:#534AB7,color:#26215C
style PR fill:#EEEDFE,stroke:#534AB7,color:#26215C
style FG fill:#FAEEDA,stroke:#854F0B,color:#412402
style RF fill:#EEEDFE,stroke:#534AB7,color:#26215C
| Agent | Model | Responsibility |
|---|---|---|
| Context Retriever | — | Embeds query, filters Qdrant by language + level, cosine-reranks to top-4 snippets |
| Curriculum Architect | gemini-1.5-pro |
Drafts the full vocabulary and grammar curriculum grounded in RAG context |
| Pedagogical Reviewer | gemini-1.5-pro |
Validates level appropriateness; catches hallucinated upper-level structures (e.g. C1 subjunctive in an A2 task) |
| Format & Quality Guard | gemini-1.5-flash + Pydantic |
Enforces strict JSON schema, deduplication, and prerequisite DAG validity |
| Refiner | gemini-1.5-pro |
Surgically patches flagged errors without discarding the base curriculum |
- Happy path: Retriever → Architect → Reviewer (no errors) → Guard (no errors) → END
- Pedagogy error: → Refiner → Reviewer (re-evaluated)
- Format error: → Refiner → Reviewer → Guard (re-evaluated)
- Safety fallback: After 3 refinement iterations, the system exits with the last Pydantic-valid JSON — preferring a structurally sound output over an infinite loop.
LangGraph was chosen over alternatives such as CrewAI and Claude Agent SDK because it offers first-class support for typed, inspectable state machines — a critical property when the system must make routing decisions based on structured error lists rather than free-form agent dialogue. Unlike CrewAI's agent-to-agent communication model, LangGraph's explicit conditional edges make the fallback logic (escape after three iterations) trivially verifiable and independently testable. The framework's native integration with LangChain's google-generativeai adapter also allowed us to configure different model tiers per agent (Pro for reasoning, Flash for schema enforcement) without restructuring the graph.
Context window pressure is managed in two ways: the RAG pipeline distils authoritative CEFR references into 3–4 tightly filtered snippets — avoiding full-document injection — and each agent receives only the state fields it needs rather than the full conversation history. JSON output integrity is guaranteed through a two-layer defence: the Curriculum Architect is prompted to respond exclusively in a schema-anchored JSON block, and the Format & Quality Guard independently parses that block against Pydantic models with strict field validation, deduplication checks, and DAG cycle detection; any violation raises a structured format_errors list that routes back to the Refiner, ensuring the final output is always Pydantic-valid before it reaches END.
The demo uses a mock CSV and Google's gemini-embedding-001 to stay within free-tier limits. A production deployment would use the following pipeline:
| Language | Source |
|---|---|
| Spanish | Instituto Cervantes — Plan Curricular (PCIC) |
| English | Cambridge EVP / EGP |
| French | Alliance Française reference documents |
| German | Goethe-Institut level lists |
| All | Council of Europe — CEFR Companion Volume |
All sources are normalised to a single payload schema before ingestion:
{
"id": "e4b3d-...",
"vector": [...],
"payload": {
"language": "es",
"level": "A2",
"domain": "grammar",
"topic": "past_tense",
"content": "Pretérito Indefinido: Expresses completed past actions with regular verbs.",
"source": "instituto_cervantes",
"is_negative_constraint": false
}
}- Model:
Qwen3-Embedding-4Bserved via vLLM (replacesgemini-embedding-001) - Vector space: single Qdrant collection
cefr_global_standards— all languages co-located, partitioned strictly via metadata filters (no separate collections per language)
results = client.search(
collection_name="cefr_global_standards",
query_vector=embedding_vector,
query_filter=models.Filter(
must=[
models.FieldCondition(key="language", match=models.MatchValue(value="es")),
models.FieldCondition(key="level", match=models.MatchValue(value="A2")),
]
),
limit=20 # top-20 → Qwen3-Reranker-4B → top-4
)Semantic search alone is insufficient for a deterministic system. Metadata filtering guarantees that an A2 Spanish generation task never receives B1 Spanish grammar or English C1 vocabulary as context.
{
"language": "Spanish",
"level": "A2",
"generated_at": "2026-04-01T14:32:00Z",
"total_items": 60,
"items": [
{
"word": "hablar",
"part_of_speech": "verb",
"translation_en": "to speak / to talk",
"example_sentence": "Ella habla español muy bien.",
"topic_domain": "communication",
"cefr_level": "A2"
}
]
}{
"language": "Spanish",
"level": "A2",
"generated_at": "2026-04-01T14:32:00Z",
"total_points": 12,
"points": [
{
"id": "es_a2_grammar_001",
"title": "Presente de Indicativo — Regular Verbs",
"description": "Describes habitual actions and current states using regular -ar, -er, -ir verbs.",
"prerequisite_ids": [],
"examples": ["Yo hablo español todos los días.", "Ella come una manzana."],
"negative_constraints": ["No subjunctive", "No irregular stem-changing verbs (B1+)"],
"cefr_level": "A2"
}
]
}.
├── CLAUDE.md ← Claude Code guide
├── README.md ← this file
├── requirements.txt
├── .env.example
├── config.py ← all settings; nothing hardcoded in agents
├── main.py ← entrypoint
├── schema/models.py ← Pydantic models (VocabularyCurriculum, GrammarCurriculum)
├── agent/
│ ├── state.py ← CurriculumState TypedDict
│ ├── nodes.py ← 5 agent functions
│ └── graph.py ← LangGraph graph + routing
├── rag/retriever.py ← embed → filter → rerank
├── scripts/ingest_qdrant.py ← one-time data ingestion
├── data/mock_cefr.csv ← mock CEFR reference data (Spanish A2)
├── outputs/ ← generated JSONs (git-ignored)
└── tests/
├── test_models.py ← Pydantic validation tests
└── test_graph.py ← routing logic tests (mocked LLMs)
# All tests (no API calls — LLMs are mocked)
pytest tests/ -v
# Pydantic model validation only
pytest tests/test_models.py -v
# Graph routing logic only
pytest tests/test_graph.py -v