The product
Lumen is a Spanish-learning environment for absolute beginners. A learner builds a study plan, and each lesson is generated from it. Around the lessons sit a streaming AI tutor, generated reading articles with comprehension checks, five-exercise practice sessions, and vocabulary review scheduled by spaced repetition. Learners can also upload their own documents, and the tutor uses them as reference material.
The problem
A general-purpose AI model makes a poor tutor for a beginner. Asked a simple question, it answers with vocabulary and grammar the learner hasn’t met yet. For someone at the very start, that makes the answer useless.
So the product had to be built around one constraint: everything the AI produces stays within what the learner has already covered.
What made it complex
- Keeping the AI within bounds. Every generated output, whether a tutor reply, an exercise or an article, has to stay inside the learner’s current scope. That includes answers that draw on uploaded documents written at a much higher level.
- Structured output from a model. Lessons and exercises are data the interface renders, not free text. A malformed response has to be caught before it reaches the learner.
- Streaming across service boundaries. The tutor streams its answer through two services to the browser. Neither service can buffer it.
- Cost and latency. Generation is slow and billed per call, so the same content shouldn’t be generated twice.
My responsibility
I built everything myself: the product and interface design, the architecture, the frontend, the gateway and backend services, the AI integration, the data model and the deployment.
Approach and key decisions
- Scope as part of every prompt. Each request builds its prompt from the learner’s current and earlier lessons. Retrieved document content is added as reference only, and the output rules still apply to it.
- Schemas on both sides. The backend validates generated content against Pydantic schemas. The frontend validates it again with Zod before rendering it.
- A gateway as the only public entry point. The browser only talks to an Express gateway. The gateway owns authentication (JWT with refresh-token rotation) and forwards requests to the FastAPI backend using an internal service token. The backend is never reachable from the browser.
- Generate once, then cache. Lessons, plan imports and topic suggestions are cached in Postgres, keyed by a hash of their input. The tutor and practice sessions are generated fresh every time.
- Retrieval over the learner’s own documents. Uploaded files are parsed, split into chunks and embedded, then retrieved with pgvector. This lets the tutor answer from the learner’s material and cite it.
- Learning logic stays in the client. Answer grading, spaced-repetition scheduling and weak-spot detection are plain code with no AI involved. Repeated mistakes are passed to the tutor as context.
Constraints and trade-offs
- Two languages, two services. Node handles auth and the edge. Python handles AI and data. The cost is a shared database whose tables are split between two migration tools, and that boundary has to be managed carefully.
- Start cheap, keep a path to scale. The API runs with Docker Compose behind Nginx on a single EC2 instance. A fuller AWS setup (ECS, RDS, S3) is already written as Terraform, and moving to it is a deployment change. The application code stays the same.
- Accuracy over breadth. A narrower tutor that stays within scope is more useful to a beginner than a broader one that doesn’t.
What changed
Lumen started as a single Next.js app with no database. It now runs in production at lumenedu.app as a three-tier product: a Next.js frontend on Vercel, and an API served from AWS with real accounts, a Postgres + pgvector data layer, document retrieval, and CI on GitHub Actions.