TutorLoom
The learning platform for companies, colleges and training institutes. Free and open source, with an Enterprise edition.
翻譯準備中
尚未收集儲存庫資訊。
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
製作者的介紹僅有上面的一句話。 更多詳情請閱讀儲存庫 README。 儲存庫 README ↗
正在載入完整 README。
檢視完整 README ↗Stop memorizing rules. Build German sentences until word order feels natural.
An open-source, self-hosted language trainer: an LLM writes fresh B1-level exercises for any grammar topic you describe, spaced repetition decides what you review, and podcast mode lets you keep drilling on a walk.
Try the live demo → · Run your own · What's new
Textbooks give you ten example sentences per grammar point. You need hundreds — with weil, obwohl, damit, verb + preposition, Konjunktiv II — until you stop thinking about where the verb goes. This trainer generates them on demand, in situations you choose (a Jobcenter appointment, the telc speaking exam, small talk at work), and keeps bringing back the ones you get wrong.
| AI explains your mistakes | On your phone |
|---|---|
gct CLI lets you — or a coding agent — manage topics from the terminal.German B1 is where it started, but topics are just prompts: point it at any language or level.
docker run -p 8080:8080 \
-e OPENAI_API_KEY=your_api_key \
-e MODEL_NAME=gpt-4o-mini \
ghcr.io/korjavin/german-conjuctions-trainer:2
Open http://localhost:8080 and start practicing. For persistent storage, Claude, Google login, audio and podcasts see Running with Docker and Environment Variables.
Archive root. Archived topics disappear from the practice topic picker on the main screen but keep their exercises, audio cache and podcasts; inside the archive they behave like any other branch (drag, edit, delete). Restore puts a topic back under the parent it was archived from (or at the root level if that parent is gone). API: POST /api/topics/<id>/archive and /unarchive.This application provides an optional login feature using Google OAuth 2.0. When a user logs in, the application will store their statistics and settings, allowing them to track their progress across sessions. This feature is entirely optional and the application is fully functional without logging in.
For more information on the data we store, please see our Privacy Policy.
The 🎧 button in the header builds a Glossika-style audio lesson from the selected topic and its subtopics — the same selection as a practice session. The episode plays in the browser (it keeps playing when the dialog is closed) or downloads as an MP3 for any player.
German in episodes is spoken slower than in the trainer: ElevenLabs speed PODCAST_DE_SPEED (default 0.75, clamped to 0.7–1.2); English uses ELEVENLABS_VOICE_SPEED. Slow German clips are cached under their own key, so trainer audio is unaffected.
Audio uses the configured ElevenLabs voice and model (ELEVENLABS_MODEL_ID, default eleven_v4). English clips are requested with language_code: en; eleven_multilingual_v2 ignores it (a German voice then reads English with an accent), so if a eleven_multilingual_* model is configured English is synthesized with eleven_flash_v2_5. ELEVENLABS_ORIGINAL_LANGUAGE_VOICE_ID sets a separate voice (by ID) for English. English clips are cached in audio_cache next to the German ones, keyed by model and voice, so changing either re-synthesizes them. The server stitches the MP3 frames itself (no ffmpeg) and serves episodes from GET /api/podcast/<id>.mp3 (built by POST /api/podcast with {"topic_id": "..."}).
Builds are the most expensive request, so they are bounded:
429 PODCAST_BUSY immediately.429 PODCAST_RATE_LIMITED with Retry-After.504), each TTS clip after 45 s; TTS calls are cancelled when the deadline passes or the client disconnects.audio_cache/podcasts/ for exactly seven days (expires_at in the response). The TTS cache cleanup never touches them. The episode store is capped at 1 GiB; when it is full, new builds get 503 PODCAST_STORAGE_FULL instead of evicting episodes whose links were already handed out.Logged-in users can listen in a podcast app instead of the browser. Settings → Podcast RSS feed shows a private feed URL (/podcast/feed/<token>.xml) to add to any podcast app; it lists the episodes that user built in the web UI during the last seven days (the app downloads them before they expire). Guests get no feed.
404 and the podcast app must be re-subscribed./api/podcast/<id>.mp3 built from PUBLIC_BASE_URL (or the origin of GOOGLE_REDIRECT_URL), never from the request's Host header. Without either, the feed is disabled.GET /api/podcast/feed returns {"feed_url": "..."} (creating the token on first call); POST /api/podcast/feed/regenerate replaces it. Both need a session.This application uses a Simple Intent approach combined with optional Prompt Refinement to enhance the quality of generated exercises.
Simple Intent Prompts: When creating topics, you only need to describe what you want to practice—grammar topic, vocabulary themes, and situations. The backend automatically adds all technical details (system role instructions, variation profiles, JSON output format) before sending to the language model.
Example simple intent prompt:
Topic: um...zu and damit conjunctions
Level: B1
Vocabulary: daily life, travel, work
Situations: explaining reasons, expressing goals, giving instructions
Optional Refinement: If enabled, the application can send your simple intent to a language model with a meta-prompt that expands vocabulary range, suggests diverse real-life situations, and clarifies difficulty level while keeping the result concise.
This ensures that the exercises you receive are not repetitive and are of higher pedagogical quality.
To provide insight into the prompt refinement process, you can view the most recently used refined prompt. This is useful for debugging and understanding how the AI is interpreting and improving your prompts.
You can access this feature via the "View Last Refined Prompt" button in the settings menu.
gct)A Go-based CLI under cmd/cli lets admins and coding agents manage topics and
trigger exercise generation without using the web UI. It authenticates via
Google OAuth (device flow) and stores a long-lived bearer token in
~/.config/gct/config.json. Build it with make build-cli. See
CLI.md for installation, GCP setup, command reference, and
troubleshooting.
docker run -p 8080:8080 \
-e OPENAI_API_KEY=your_openai_api_key_here \
-e OPENAI_URL=https://api.openai.com/v1 \
-e MODEL_NAME=gpt-3.5-turbo-1106 \
ghcr.io/korjavin/german-conjuctions-trainer:2
Every release is listed on the Releases page with human-readable notes (same text as CHANGELOG.md). To get an email when a new version ships, click Watch → Custom → Releases on GitHub, or subscribe to the releases Atom feed.
Image tags:
| Tag | Moves when |
|---|---|
:2.0.0 | never — exact version |
:2.0 | patch releases (fixes only) |
:2 | any release without breaking changes — recommended |
:latest | every release, including breaking ones |
Before moving to a new major version, read the "Before you upgrade" part of its release notes and back up your SQLite database.
# Build the image
docker build -t german-conjunctions-trainer .
# Run the container
docker run -p 8080:8080 \
-e OPENAI_API_KEY=your_openai_api_key_here \
-e OPENAI_URL=https://api.openai.com/v1 \
-e MODEL_NAME=gpt-4 \
german-conjunctions-trainer
| Variable | Required | Default | Description |
|---|---|---|---|
OPENAI_API_KEY | Yes | - | Your OpenAI API key or compatible API key |
OPENAI_URL | No | https://api.openai.com/v1 | API endpoint URL |
OPENAI_TIMEOUT_SECONDS | No | 180 | Timeout for each LLM request (refinement and generation) |
ENABLE_PROMPT_REFINEMENT | No | false | When true, runs refinement before generation; otherwise uses variation-profile generation directly |
MODEL_NAME | No | gpt-3.5-turbo-1106 | Model name to use |
LLM_PROVIDER | No | - | anthropic = call the native Claude Messages API at OPENAI_URL/messages (set OPENAI_URL=https://api.anthropic.com/v1) with prompt caching; unset = OpenAI-compatible /chat/completions |
LLM_FALLBACK_URL | No | - | OpenAI-compatible endpoint retried once when the primary answers with a 4xx/5xx status (e.g. out of credits). Timeouts are not retried. Unset = no fallback |
LLM_FALLBACK_API_KEY | No | - | API key for the fallback endpoint |
LLM_FALLBACK_MODEL | No | MODEL_NAME | Model name for the fallback endpoint |
LLM_BATCH_MIN_POOL | No | - | With LLM_PROVIDER=anthropic: every 15 minutes, topics with fewer cached exercises than this get topped up in the background through the Message Batches API (50% cheaper, async). The in-flight batch id is kept in llm_batch.json next to the database. Unset/0 = off |
SQLITE_PATH | No | german.db | Path to the SQLite database file |
PORT | No | 8080 | Port for the web server |
The application automatically runs database migrations on startup to update the schema. If you're upgrading from an older version:
parent_id and sort_order columns to the topics tableImportant: If you have duplicate topic names at the same parent level in your existing database, the migration will fail. You must manually resolve duplicates by renaming or deleting duplicate topics before the migration can complete.
Migrations are designed to be idempotent - running them multiple times has no effect.
The project includes comprehensive test suites for both backend and frontend functionality.
# Run all tests
go test ./...
# Run tests for specific package
go test ./internal/app/...
go test ./pkg/storage/...
# Run tests with verbose output
go test -v ./...
# Run tests with coverage
go test -cover ./...
Vitest unit suites live in js/__tests__/ (happy-dom) and run in CI on every pull request (pnpm test).
The comprehensive test suite covers:
Note: Airtable storage is deprecated and will be removed in future versions. Use SQLite (default) instead.
To use Airtable (legacy), set STORAGE_TYPE=airtable and configure the following:
| Variable | Required | Default | Description |
|---|---|---|---|
AIRTABLE_TOKEN | Yes (if Airtable) | - | Your Airtable Personal Access Token |
AIRTABLE_BASE_ID | Yes (if Airtable) | - | Your Airtable Base ID |
On first startup, the application will create two default topics:
Topics can be organized hierarchically in a tree structure with advanced features:
Topic Hierarchy Features:
Topic Name Uniqueness: Topic names must be unique at the same parent level (case-insensitive). You cannot create two topics with the same name that share the same parent, but you can reuse names at different levels (e.g., "Grammar" -> "Verbs" and "Adjectives" -> "Verbs" are both allowed).
Sort Order Field: Topics have a sort_order field that determines their display position within their parent. Lower values appear first. When using the "Tree" sort option, topics are displayed based on their sort_order value.
Tree Depth Limit: Topic trees are limited to a maximum depth of 100 levels to prevent performance issues.
Topic Creation Rules:
When creating or editing topics, the following validation rules apply:
You can assign a parent topic to any new or existing topic to keep your exercises neatly categorized (e.g., "Grammar" -> "Verbs" -> "Verb + Preposition"). Deleting a topic that has children is prevented with a HTTP 409 Conflict to ensure data integrity.
Keyboard Shortcuts for Topic Tree:
The application supports any OpenAI-compatible API through environment variables:
# Example: Claude via the native Messages API, with OpenAI as fallback
# when the Anthropic credits run out
docker run -p 8080:8080 \
-e OPENAI_API_KEY=your_anthropic_console_key \
-e LLM_PROVIDER=anthropic \
-e OPENAI_URL=https://api.anthropic.com/v1 \
-e MODEL_NAME=claude-sonnet-5-5 \
-e LLM_FALLBACK_URL=https://api.openai.com/v1 \
-e LLM_FALLBACK_API_KEY=your_openai_api_key \
-e LLM_FALLBACK_MODEL=gpt-4o-mini \
german-conjunctions-trainer
# Example: Using Azure OpenAI
docker run -p 8080:8080 \
-e OPENAI_API_KEY=your_azure_key \
-e OPENAI_URL=https://your-resource.openai.azure.com/v1 \
-e MODEL_NAME=gpt-4 \
german-conjunctions-trainer
Anthropic's compatibility layer ignores response_format, so replies may arrive wrapped in a ```json fence or in prose; the app extracts the outermost JSON object before parsing. The app sends no temperature, so the compat layer's cap of 1 does not apply.
With LLM_PROVIDER=anthropic the topic part of the generation prompt is sent as a cached system prompt: each call logs cache_read_input_tokens, which is above 0 on the second generation of a topic within 5 minutes. Topic prompts shorter than the model's minimum cacheable length (512 tokens on the 5.x models) are not cached. Structured outputs (output_config json_schema) are deliberately not used: under the schema grammar Claude mostly returned one exercise instead of the requested set, so the reply is parsed like the compat one.
# Set required environment variables
export OPENAI_API_KEY=your_openai_api_key
# Run the Go backend
go run main.go
# The server will serve static files from ./static/ (for Docker) or current directory (local)
# Access the app at http://localhost:8080
The backend includes rate limiting to prevent abuse. By default, it allows one request every three seconds per IP address.
.
├── main.go # Go backend server with API and Airtable integration
├── index.html # Main application UI
├── js/, css/ # Frontend ES modules (entry js/main.js) and stylesheets
├── agent.md # Context file for AI development
├── Dockerfile # Container definition for production
├── docker-compose.yml # Docker Compose for local development
├── go.mod # Go module dependencies
├── example.prompt.md # Example prompt for exercise generation
└── .github/workflows/ # CI/CD pipelines for Docker builds
MIT License - see LICENSE file for details.
The application includes a fast, automated unit test suite for the vanilla JS frontend using Vitest and happy-dom.
To run the automated tests:
# Run all tests once
pnpm test
# Run tests in watch mode
pnpm run test:watch
# Run tests and generate coverage report
pnpm run test:coverage
CORS_ALLOWED_ORIGINS| No |
* |
Comma-separated list of allowed CORS origins. Defaults to wildcard (*) for development. It is strongly recommended to set this to your specific domain(s) in production. |
AUDIO_CACHE_MAX_SIZE_MB | No | 2048 | Maximum size of the local TTS audio cache in MB (e.g., 2048 for 2GB). Older files are removed when the limit is reached. |
GOOGLE_CLIENT_ID | No | - | Your Google OAuth 2.0 Client ID |
GOOGLE_CLIENT_SECRET | No | - | Your Google OAuth 2.0 Client Secret |
GOOGLE_REDIRECT_URL | No | - | Your Google OAuth 2.0 Redirect URL |
PUBLIC_BASE_URL | No | origin of GOOGLE_REDIRECT_URL | Public origin of the server (e.g. https://gct.example.com), used for absolute URLs in the podcast RSS feed. If neither is set, the feed is disabled. |
COOKIE_HASH_KEY | When Google login is enabled | Randomly generated | A key of at least 32 bytes for HMAC authentication of cookies (openssl rand -hex 32). Required when Google login is enabled; the server refuses to start without it. Without login, a temporary key is generated at startup. |
COOKIE_BLOCK_KEY | When Google login is enabled | Randomly generated | A 32-byte key for AES-256 encryption of cookie data (openssl rand -hex 16). Required when Google login is enabled; the server refuses to start without it. Without login, a temporary key is generated at startup. |