Updated Jun 21, 2026 Architecture

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, StudioTask models.
  • 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.pyAgentRegistry class.

  • Holds agent classes (blueprints), never instances.
  • Agents register in agent_service/apps.py ready() — 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)
  • scopeuser:TomasD for authenticated users, session:abc123 for anonymous
  • roleuser or agent only
  • 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

  1. Zero Django imports in genai/ — verified by pytest tests/ -v (test_system.py)
  2. manage.py check — 0 issues after every sprint
  3. Registry holds classes, never instances
  4. Tools never write to DB — they return structured data, service.py writes it
  5. All wiki writes go through Vega — no exceptions
  6. Proactive agents notify Teo via TeoNotification — never contact Tomas directly
  7. Deploy at the end of every chapter
  8. 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 via gemini-embedding-001
  • serialize_embedding(vector) → 12288-byte binary blob for sqlite-vec storage
  • deserialize_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_saved calls generate_embedding(chunk.content), serializes, inserts into vec_chunks. Failures are logged and swallowed — never crash the lock pipeline.
  • On delete: on_chunk_deleted removes the matching row from vec_chunks by chunk_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_textgenerate_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.pysynthesise_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.pygenerate_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 draftsegmented
3 Narration segmentedaudio_ready
4 Backgrounds audio_readyimages_ready
5 Effects images_readyeffects_applied
6 Soundtrack effects_appliedsoundtrack_ready
7 Merge soundtrack_readymerged
8 Review merged (no status change)
9 Approve mergedapproved
10 Store approvedstored

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 to media/studio/studio_export.json
  • manage.py import_studio — reads the JSON and creates/updates records (--force to 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 draftsegmented.

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.


Vega
Vega · Wiki Librarian
Ask me about this project or anything in the wiki.