Architecture
The Two-App Split
The entire codebase is split into two distinct layers. This is a non-negotiable architectural decision.
genai/ — Pure Python agent framework
- Zero Django imports. Ever.
- Testable without Django running.
- Contains:
base.py,llm_client.py,memory.py,registry.py,tools/,agents/ - Verified after every sprint with:
pytest tests/ -v(test_system.py checks zero Django imports)
agent_service/ — Django bridge app
- The only place allowed to import from both Django and
genai/. - All ORM calls, views, URL routing live here.
- Contains:
models.py,views.py,service.py,urls.py,admin.py,apps.py
bookstore/ — Bookstore Django app
- Series, Book, Chapter, ChapterChunk models.
- Four public views: index, series, book, chapter reader.
- Arthur's workspace — commission, write, rewrite, lock.
- Management commands:
export_bookstore,import_bookstore.
studio/ — Video production pipeline app
- Standalone Django app. No dependency on
genai/agent framework. StudioProject,StudioEpisode,StudioScene,StudioTaskmodels.- Step-by-step production workflow UI for converting locked chapters into videos.
- Local generation tools (Flux, MusicGen, FFmpeg) run on local machine — not on shared hosting.
- See Studio Production Pipeline section below.
The Registry
genai/registry.py — AgentRegistry class.
- Holds agent classes (blueprints), never instances.
- Agents register in
agent_service/apps.pyready()— fires once per Passenger process startup. get_by_type()returns the class; caller instantiates with dependencies.- Module-level singleton:
registry = AgentRegistry()
Singleton types — only one allowed: assistant, librarian
Non-singleton types — many allowed, stored as list: project_agent, publisher
Agent Lifecycle — Per Request
Every agent is instantiated fresh per Django request, destroyed after response. Continuity comes from the database, not a persistent object.
Request → service.py → query DB → instantiate agent → load_memory() → agent.run() → save to DB → Response
Background Task Pattern (Arthur)
Long-running Arthur operations (write chapter, rewrite, lock) run in background threads. The view creates an ArthurTask DB record and returns a task_id immediately. The frontend polls GET /api/arthur/task/<id>/ every 4 seconds until status=done.
This means Tomas can navigate away — the task survives in the DB.
Craft Memory System (Arthur)
Four-layer file-based craft memory. Grows with every locked chapter.
| Layer | File | Scope |
|---|---|---|
| General | media/arthur/craft/general/{language}.md |
All books, all genres |
| Genre | media/arthur/craft/genre/{genre}/{language}.md |
Same genre books |
| Series | media/arthur/series/{slug}/craft.md |
All books in series |
| Book | media/arthur/series/{slug}/books/{slug}/craft.md |
This book only |
Language rules (Czech quotation marks, register, diacritics, sentence rhythm) live in the general craft files — not hardcoded in the system prompt.
ConversationMessage — Unified Model
One model handles all agent conversations.
agent_name— which agent owns this conversation (teo,vega,dash,arthur)scope—user:TomasDfor authenticated users,session:abc123for anonymousrole—useroragentonly- Rolling window: 20 messages per
(agent_name, scope)pair
ChapterFeedback — Separate from Conversation
Tomas's rewrite feedback is stored in ChapterFeedback, not ConversationMessage. Arthur's acknowledgements are UI-only and never saved. At lock time, ChapterFeedback records are read to synthesise craft notes.
Agent Offices — URL Structure
| URL | Agent | Access |
|---|---|---|
/teo/ |
Teo | Authenticated only |
/wiki/ |
Vega | Public + authenticated |
/dashboard/ |
Dash | Authenticated only |
/bookstore/ |
Arthur | Public (read) + authenticated (write) |
/studio/ |
Studio production app | Authenticated only |
Structured Response Format
Every agent returns exactly this dict:
{
"message": "string — the response text",
"emotion": "neutral|happy|thinking|excited|confused|concerned|playful|sarcastic|angry|satisfied",
"agent": "string — agent name",
"actions": []
}
Proactive Agent Pattern
Cron jobs trigger Django management commands. Each proactive agent has an observe() method.
*/10 * * * * manage.py run_dash # market monitoring
0 * * * * manage.py run_arthur # chapter activity check
0 8 * * * manage.py run_vega # visitor pattern review
Proactive agents never contact Tomas directly. They write TeoNotification records. Teo surfaces them as the morning briefing.
Non-Negotiable Rules
- Zero Django imports in
genai/— verified bypytest tests/ -v(test_system.py) manage.py check— 0 issues after every sprint- Registry holds classes, never instances
- Tools never write to DB — they return structured data,
service.pywrites it - All wiki writes go through Vega — no exceptions
- Proactive agents notify Teo via TeoNotification — never contact Tomas directly
- Deploy at the end of every chapter
- Every chapter ends with: polish sprint + handover sprint
Vector Infrastructure (Chapter VI)
SQLite is extended with sqlite-vec for semantic vector search. No separate database — vectors live in the same db.sqlite3 file alongside all other tables.
Extension Loading
sqlite-vec is loaded on every DB connection via a connection_created Django signal handler defined at module level in agent_service/apps.py. Module-level placement prevents garbage collection. Cross-platform path resolution handles .so (Linux), .dll (Windows), and .dylib (macOS).
vec_chunks Virtual Table
CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks
USING vec0(
chunk_id INTEGER PRIMARY KEY,
embedding FLOAT[3072]
)
Maps 1:1 with ChapterChunk.id for story chunks. Also stores image descriptions for Studio scene image reuse (chunk_type='scene_image'). Created automatically via post_migrate signal. IF NOT EXISTS makes it idempotent.
Embeddings
genai/embeddings.py — pure Python, zero Django imports.
generate_embedding(text)→ 3072 floats viagemini-embedding-001serialize_embedding(vector)→ 12288-byte binary blob for sqlite-vec storagedeserialize_embedding(blob)→ list of 3072 floats
Embedding model: gemini-embedding-001 (3072 dimensions). Raw sqlite3 connection used for all vec_chunks operations — Django's cursor wrapper mangles binary blobs.
chunk_type Values
| chunk_type | Source | Used by |
|---|---|---|
fact |
Arthur's lock pipeline | Arthur — contradiction checking |
style_example |
Arthur's lock pipeline | Arthur — prose calibration |
scene_image |
Studio's image pipeline | Studio — image reuse lookup |
Embedding Pipeline (Chapter VII)
ChapterChunk records are automatically embedded into vec_chunks via Django signals defined at module level in agent_service/apps.py.
- On create:
on_chunk_savedcallsgenerate_embedding(chunk.content), serializes, inserts intovec_chunks. Failures are logged and swallowed — never crash the lock pipeline. - On delete:
on_chunk_deletedremoves the matching row fromvec_chunksbychunk_id. - Historical backfill:
manage.py embed_chunks— delta mode (skips existing),--force(re-embed all),--dry-run(preview only).
ChapterChunk.id = chunk_id in vec_chunks. They are always in sync.
Semantic Retrieval Engine (Chapter VIII)
search_story_memory() in agent_service/service.py — the top-level retrieval function Arthur calls on every chapter write.
search_story_memory(
query_text, # any language — cross-lingual search works
limit=3, # number of results to return
chunk_type=None, # 'fact', 'style_example', or None (both)
book_slug=None, # scope filter — series + this book, or None (all)
max_distance=None, # similarity threshold — drop results above this distance
)
Pipeline: query_text → generate_embedding() → search_vectors() (KNN via sqlite-vec) → ORM fetch of ChapterChunk objects → filtered and sorted result list.
Returns a list of (ChapterChunk, distance) tuples, closest first.
KNN Query
search_vectors(raw_connection, query_vector, limit) in genai/embeddings.py — pure Python, zero Django imports.
SELECT chunk_id, distance FROM vec_chunks WHERE embedding MATCH ? AND k = ?
Cosine distance: 0.0 = identical, 2.0 = opposite. When filtering by chunk_type or book_slug, KNN over-fetches by 4x to ensure enough results survive the filter.
Arthur Prompt Injection (Chapter IX)
Retrieved context is injected into Arthur's write prompt automatically on every chapter write.
assemble_arthur_context(book_slug, scene_outline, limit=3) calls search_story_memory() twice — once for facts, once for style examples. Returns an XML-tagged context block injected as === RETRIEVED CONTEXT === in the write prompt. Retrieval failures are silently swallowed — the write pipeline is never blocked.
Series Architecture (Chapter IX)
media/arthur/craft/series_rules.md — loaded into every write and commission prompt as === SERIES RULES ===. Contains three-level ending structure, protagonist continuity rules, arc architecture, unknown danger rules, and commercial viability (original IP only).
Craft Hierarchy
| Level | File | Scope |
|---|---|---|
| Global rules | craft/series_rules.md |
Every series, every language |
| General prose | craft/general/{lang}.md |
All books in this language |
| Genre | craft/genre/{genre}/{lang}.md |
All books in this genre |
| Series voice | series/{slug}/craft.md |
All books in this series |
| Book lessons | series/{slug}/books/{slug}/craft.md |
This book only |
EPUB Compilation (Chapter X)
bookstore/exporter.py — compiles all locked chapters of a book into a valid .epub file. Standard EPUB structure: mimetype, META-INF/container.xml, OEBPS/content.opf, OEBPS/toc.ncx, per-chapter XHTML files. Triggered via "Export to EPUB" button on Book detail panel. Output streamed as download.
Series Architecture (Chapter X)
Arthur maintains two levels of private concept beyond the book level.
Series Concept (series_concept.md.enc)
Created once per series at series commission time via POST /api/arthur/commission-series/. Arthur designs the universe-level long-game: the true nature of the unknown danger, the arc vision, universe rules, the long thread, and the franchise ending. Encrypted with AES-256-CBC. Key stored in ArthurSecret with secret_type='series' and book_slug='series:{slug}'.
Loaded into context when commissioning each new book so Arthur writes with the long-game in mind.
world.md — Public Canon Bridge
Plain-text file at media/arthur/series/{slug}/world.md. Populated automatically when the final chapter of a book is locked. Arthur reads all chapter summaries and writes confirmed facts, characters, closed threads, open threads, and a "where we are" paragraph.
Readable by Tomas. Loaded into Book 2+ commission prompts. Updated after every completed book.
manage.py update_world_md <series_slug> <book_slug> — manual trigger for backfill.
ArthurSecret secret_type
ArthurSecret.secret_type field: book (default), series, arc. Series secrets use book_slug='series:{slug}' as their unique key. Book secrets unchanged.
Soundtrack Pipeline (Chapter XVI)
studio/mood.py — synthesise_episode_mood(scenes) — pure Python. Single Gemini call combining all scene mood_brief values into one music prompt. Result stored as episode.ambient_music_mood.
studio/music_gen.py — generate_ambient(mood, output_path, bpm, key_scale, duration, api_url, temperature) — pure Python. HTTP client to local ACE-Step API server. Polls by task_id. Applies _smooth_bass() post-processing (18Hz high-pass + soft limiter). Locked params: lm_temperature=0.8, lm_top_p=0.95.
SoundtrackTake Model
Each generation creates a SoundtrackTake record and a numbered take file. Settings (bpm, key_scale, loop_length, temperature, prompt) are stored per-take. On approval, the approved take is renamed to ambient_{episode_number:03d}_v{version:03d}.wav, all non-approved takes are deleted (files + DB), and the episode advances to soundtrack_ready.
SoundtrackTake
episode (FK → StudioEpisode)
take_number, file_path, prompt
bpm, key_scale, loop_length, temperature
is_approved, created_at
File Naming
| Type | Pattern | Example |
|---|---|---|
| Take (temporary) | ambient_{episode_id:03d}_take_{n:03d}.wav |
ambient_001_take_003.wav |
| Approved | ambient_{episode_number:03d}_v{version:03d}.wav |
ambient_001_v001.wav |
All files stored at media/studio/audio/.
Studio is a standalone Django app (studio/) providing a step-by-step video production workflow. It is not an agent — it is a production tool. Each episode (one chapter → one video) passes through ten steps.
Terminology
| Studio term | Maps to |
|---|---|
| Project | Book (bookstore.Book) |
| Episode | Chapter (bookstore.Chapter, must be locked) |
| Scene | Segment of a chapter (created during Step 2) |
Models
StudioProject
book (OneToOne FK → bookstore.Book)
tts_voice — Gemini TTS voice profile, consistent across all episodes
StudioEpisode
project (FK → StudioProject)
chapter (OneToOne FK → bookstore.Chapter)
workflow_status — see Episode Workflow below
ambient_music_path, ambient_music_mood — set in Step 6
final_video_path — set in Step 7
approved_at, youtube_url — set in Steps 9–10
StudioScene
episode (FK → StudioEpisode)
scene_number, content, word_count, estimated_duration_seconds
mood_brief — music/visual mood description (e.g. "low drone, claustrophobic, tension")
effect_type — one of 6 effects (see Effects below)
narration_path, narration_duration_seconds — set in Step 3
image_path, image_description, image_vec_chunk_id — set in Step 4
status — pending | narration_done | image_done | effects_done | complete
StudioTask
episode (FK → StudioEpisode)
task_type — segmentation | narration | image_generation | soundtrack | merge
status — pending | running | done | error
Episode Workflow — 10 Steps
| Step | Name | Status transition |
|---|---|---|
| 1 | Pick Chapter | → draft (set at episode creation) |
| 2 | Segment | draft → segmented |
| 3 | Narration | segmented → audio_ready |
| 4 | Backgrounds | audio_ready → images_ready |
| 5 | Effects | images_ready → effects_applied |
| 6 | Soundtrack | effects_applied → soundtrack_ready |
| 7 | Merge | soundtrack_ready → merged |
| 8 | Review | merged (no status change) |
| 9 | Approve | merged → approved |
| 10 | Store | approved → stored |
Segmentation (Step 2 — Chapter XII)
Gemini splits chapter prose into scenes at narrative beats — location change, POV shift, tone shift, beat resolution. Tomas sets a min/max scene count (defaults 8/12) before triggering. Segmentation saves scenes but keeps episode status as draft — Tomas reviews scene cards (mood brief, effect type, expandable prose) and clicks "Approve Segmentation" to advance to segmented and unlock Step 3. Re-segmenting a previously approved episode resets it back to draft. studio/segmentation.py is pure Python with zero Django imports. studio/service.py is the Django bridge — run_segmentation() spawns a background thread and returns a task ID for polling. approve_segmentation() advances the status.
LLM: Gemini 2.5-flash (STUDIO_MODEL_NAME). The thinking-model output is handled by trying all {...} blocks largest-first so reasoning preamble never breaks JSON parsing.
TTS Narration (Step 3 — Chapter XIII)
Gemini TTS generates .mp3 per scene. Voice profile set at project level — consistent across all episodes. Long scenes split at sentence boundaries before the API call.
Background Images (Step 4 — Chapter XIV)
Flux.1-dev (quantized fp8, runs on RTX 5070 12GB) generates 1280×720 background images. Before generating, Studio checks vec_chunks for an existing image with a similar description (threshold: distance ≤ 0.6). Image descriptions are embedded with gemini-embedding-001 and stored as chunk_type='scene_image' — no separate embedding model needed. Tomas can override any scene with a custom uploaded image.
Effects (Step 5 — Chapter XVI)
Six effects available. Studio suggests one per scene during segmentation. Tomas can override per scene.
| Effect | Description |
|---|---|
slow_zoom_in |
Ken Burns — gradual zoom toward centre |
slow_zoom_out |
Ken Burns — gradual zoom away from centre |
drift_left |
Pan slowly left |
drift_right |
Pan slowly right |
fade_through_black |
Fade to black and back — scene transitions |
static |
No movement |
Soundtrack (Step 6 — Chapter XV)
Meta MusicGen medium model generates one ambient .wav track per episode. Mood synthesised by Gemini from all scene mood briefs. Target duration: total narration length + 20%. The ambient track is also exportable standalone for a second YouTube ambient music channel.
Merge (Step 7 — Chapter XVI)
MoviePy + FFmpeg assembles the final video: per scene (image + effect + narration audio) → concatenated scene clips → ambient music layered underneath (ducked -18dB under narration, full volume in gaps) → episode_N.mp4.
Local Generation
Flux.1-dev and MusicGen run locally on Tomas's machine (RTX 5070, 12GB GDDR7). They are not deployed to shared hosting. The Django app on dolejsek.cz orchestrates and stores results — generation runs locally and files are committed to media/studio/.
LOCAL_STUDIO Flag
LOCAL_STUDIO=True in .env on the local machine. Absent on the server (defaults to False). The episode workflow view passes local_studio to the template — when False, a prominent read-only banner is shown and generation buttons are hidden.
DB Sync (Chapter XI)
Generated media files stay local. Only DB state syncs between local and server via two management commands:
manage.py export_studio— dumps all Studio DB records tomedia/studio/studio_export.jsonmanage.py import_studio— reads the JSON and creates/updates records (--forceto overwrite)
The JSON is committed to git. Workflow: export → commit → push → server pulls → import.
URL Structure
/studio/ — project list
/studio/new/ — create project
/studio/project/<slug>/ — project detail + episode list
/studio/project/<slug>/add-episode/ — add episode
/studio/project/<slug>/episode/<n>/ — episode workflow (10 steps)
Studio TTS Narration Pipeline (Chapter XIII)
studio/tts.py — pure Python, zero Django imports. generate_narration(scene_content, narrator_brief, voice_profile, output_path, api_key, model_name) calls gemini-3.1-flash-tts-preview via genai.Client directly (api_key injected). model_name is a required parameter — read from TTS_MODEL_NAME env var in service.py and injected at call time. Returns WAV written via stdlib wave (24kHz 16-bit mono PCM). 3 retries with 2s delay. NarrationError raised after exhausted retries.
narrator_brief from segmentation feeds directly into the TTS prompt as Director's Notes. Editable inline in Step 3 — on local machine only (LOCAL_STUDIO=True). Read-only on server.
scene_voice field on StudioScene stores the voice used per scene. On regeneration, the existing WAV is archived as scene_NN_narration_VOICE_TIMESTAMP.wav before the new file is written.
TTS_MODEL_NAME env var controls the model. STUDIO_MODEL_NAME env var controls the segmentation model. Both read in service.py and injected — never hardcoded.
test_system.py::test_zero_django_imports_in_studio_services covers studio/tts.py via STUDIO_PURE_PYTHON_MODULES.
Path Storage Convention
All media file paths in StudioScene and StudioEpisode are stored relative to BASE_DIR:
media/studio/{series_slug}/{book_slug}/chapters/{n}/scenes/scene_NN_narration.wav
At file access time (serving, archiving, FFmpeg), the absolute path is reconstructed:
abs_path = os.path.join(settings.BASE_DIR, scene.narration_path)
This makes paths portable across machines. Export/import pass relative paths as-is. The fix_narration_paths management command migrates any legacy absolute paths.
studio/segmentation.py — pure Python, zero Django imports. segment_chapter(chapter_text, llm_client, min_scenes, max_scenes) makes a single Gemini call and returns a list of scene dicts with content, mood_brief, and effect_type. Raises SegmentationError on malformed response. The LLM client is injected as a parameter — fully testable with MockLLMClient.
studio/service.py — Django bridge. run_segmentation(episode_id, min_scenes, max_scenes) reads chapter prose from disk, calls segment_chapter(), clears existing scenes, saves new StudioScene records, and spawns a background thread returning a StudioTask PK for polling. Episode workflow_status is not advanced automatically — approve_segmentation(episode_id) does that explicitly, moving draft → segmented.
STUDIO_MODEL_NAME env var controls the model (default gemini-2.5-flash). The _parse_response() function handles Gemini 2.5-flash thinking output by trying all {...} blocks largest-first — the real JSON answer is always the largest valid block.
test_system.py::test_zero_django_imports_in_studio_services centrally verifies all studio pure-Python modules. Future modules (tts.py, image_gen.py, etc.) are pre-listed as commented entries.