A newer version of the Gradio SDK is available: 6.20.0
CLAUDE.md
Writing conventions
- Always use regular hyphens (
-) instead of em dashes (—) in all generated text and documentation.
Working guidance for AI pair-programming (Claude Code) on hackathon-ia-visualnovel — an
AI-improvised anime visual novel for the Build Small Hackathon. Read this first, then
docs/ARCHITECTURE.md for depth and docs/PROMPTS.md
for the exact prompts + grammar.
What this project is (1 paragraph)
A voice-or-text VN where story, NPC dialogue, and art are generated live by small local models. Three model roles: The Weaver (director — emits structured JSON directives), The Voices (NPC actor — in-character dialogue), and The Painter (SDXL-Turbo — backdrops + sprites). The Weaver and Voices share one LLM (two system prompts). Input is transcribed by Whisper (The Ear). Hard constraint: ≤ 32B total params, must stay a Gradio app on a HF Space.
The golden rule
The model proposes, code disposes. The LLM returns a typed DirectorOutput; state.apply_directives
is the only function that mutates GameState. The .md files are a derived view for humans and for
re-injecting context — never the source of truth. Don't let the LLM hand-edit prose state.
Commands (uv)
uv sync # light: gradio + pydantic + pillow (mock mode)
uv run python -m visualnovel.smoke # full loop, no UI, no models
uv run python app.py # custom VN UI → http://localhost:7860
GRADIO_MVP_UI=1 uv run python app.py # gr.Blocks fallback
uv run ruff check . && uv run ruff format .
uv run pytest -q # state round-trip · directives · memory budget
# real backends (install the relevant extras first — see README for ROCm/Metal flags)
VN_MOCK=0 VN_LLM_BACKEND=transformers uv run python app.py
VN_TRACE=runs/$(date +%s).jsonl uv run python app.py # Open-Trace bonus
⭐ The mock → real migration path (the whole point of this scaffold)
Everything runs mock-first (VN_MOCK=1). Replace one mock at a time; keep pytest + smoke green
after each. Work in this order (lowest risk → highest):
- LLM — director call. Implement
llm.LlamaCppLLM(orTransformersLLM), then delete theif config.USE_MOCK:branches inorchestrator.direct_turn/init_world/compact_memoryso the realcomplete_json(schema=…)path runs. The JSON schema is already derived fromschemas.DirectorOutputviaprompts.directive_schema(). Tune sampling perdocs/PROMPTS.md §6. - Painter. Implement
painter.SdxlTurboPainter._render(stub already sketches diffusers + seed + LoRA). Caching, prompt composition, and seed-pinning are done. SetVN_IMAGE_LORAto your fine-tuned anime LoRA when ready. - STT.
stt.WhisperSTTis sketched (faster-whisper). The frontend already records audio and calls/transcribe. - Polish. Sprite moods (pre-gen set, or FLUX.2-Klein conditioning), BiRefNet transparent sprites,
speculative backdrop paint, real
compact_memorysummaries.
Acceptance after each step: uv run python -m visualnovel.smoke still completes and uv run pytest -q
is green. The mock branches are clearly marked so they're easy to find and remove.
Architecture at a glance
player (text/voice)
│ voice → stt.py (Whisper)
▼
engine.play_turn ─▶ orchestrator.direct_turn ─ one grammar-constrained LLM call ─▶ DirectorOutput
│ (memory.assemble_context builds the bounded prompt)
▼
state.apply_directives (deterministic; the ONLY mutator) ─▶ state.save_memory (.md views)
│
├─ painter.backdrop / painter.sprite (cached by entity·mood·seed)
▼
engine._view ─▶ ViewState ─▶ frontend/index.html (backdrop + sprite + dialogue)
File responsibilities
| File | Owns | Don't put here |
|---|---|---|
visualnovel/schemas.py |
all Pydantic contracts (GameState, DirectorOutput, ViewState) |
logic |
visualnovel/state.py |
apply_directives (sole mutator) + .md render |
model calls, UI |
visualnovel/llm.py |
one LLM wrapper: complete / complete_json(schema) |
game logic |
visualnovel/orchestrator.py |
the Weaver: init / direct_turn / compact (+ mock branches) | raw prompt strings (import from prompts.py) |
visualnovel/characters.py |
the Voices: present-character context | image prompts |
visualnovel/painter.py |
prompt compose, cache, seeds, render | story decisions |
visualnovel/stt.py |
Whisper transcribe | anything else |
visualnovel/memory.py |
bounded context + compaction trigger | persistence format (that's state.py) |
visualnovel/engine.py |
façade tying it together → ViewState |
model internals |
app.py |
gradio.Server routes, @app.api, @spaces.GPU |
business logic (keep thin) |
frontend/index.html |
the entire VN UI; talks via the Gradio JS client | secrets, model logic |
Conventions
- Python 3.11+ (3.12 pinned), type hints everywhere, Pydantic for all IO contracts.
ruffformats/lints. - Keep
app.pythin. Logic lives invisualnovel/so it's unit-testable without a server. - One LLM call per turn by default (
complete_jsonwith the directive schema). Extra calls (init, compaction) are allowed but rare — latency matters. - Every Painter call is cached by
(kind, prompt, seed). Generate a sprite once; never re-paint to "refresh" (kills consistency). Seeds are explicit and stored in state. - Prompts are versioned in
docs/PROMPTS.md(mirrored inprompts.py). Don't scatter literals. - Heavy imports stay lazy (inside the real backend classes) so a mock checkout needs nothing.
- Stream text first, image second. Dialogue appears before the (slower) image.
Platform gotchas (read before you fight these)
- Python 3.12 is pinned (
.python-version) because torch / llama-cpp-python / ctranslate2 ship wheels for it. Don't bump to a bleeding-edge interpreter unless every dependency has wheels → otherwiseuv python pin 3.12 && uv sync. - No CUDA locally — ROCm + Metal. llama.cpp build flags differ:
-DGGML_METAL=on(Mac, default),-DGGML_HIPBLAS=on/-DGGML_HIP=on(AMD). torch reports ROCm as "cuda" →detect_device()returns"cuda"on the AMD box;"mps"on Mac. - CTranslate2 has no Metal/ROCm →
faster-whisperruns on CPU on both your machines (fine for short clips). For GPU STT usemlx-whisper(Mac) orwhisper.cpp. - llama.cpp on hosted ZeroGPU is finicky. Claim the llama.cpp badge locally; keep the
transformersbackend for the Space (VN_LLM_BACKEND=transformers). - Image latency is the #1 UX risk. 1–4-step SDXL-Turbo, 512–768px, text-before-image, cache hard.
- Context window is finite ("Thousand Token Wood"). Feed summary + present sheets + last k turns —
never the whole log.
memory.pyenforces it. - Spaces filesystem is ephemeral.
.mdmemory is per-session unless you wire HF persistent storage. - Qwen3 think-mode on ZeroGPU. Qwen3-14B via
transformerssometimes puts the entire JSON answer inside its<think>block and emits nothing after</think>.TransformersLLM.complete_jsonhandles this by callingapply_chat_template(..., enable_thinking=False)to suppress thinking for structured output, and falls back to searching inside the think block if no JSON appears after it. Do NOT use_quiet_stderr()around transformers loads — it swallows loading errors that are essential for ZeroGPU debugging.
Definition of done (MVP)
A player types (then speaks) a line → an NPC replies in character → the Weaver decides whether the scene/character changed → the screen shows a coherent backdrop + sprite + dialogue, looping indefinitely without crashing, on the laptop-safe config, inside the custom VN UI.
Out of scope (resist these)
Multiplayer; save/load beyond one session; combat/stats UIs; more than ~3 NPCs on screen; photorealism (anime is cheaper, faster, prettier here); a second LLM just to look "multi-agent". Ship the loop.