# 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`](docs/ARCHITECTURE.md) for depth and [`docs/PROMPTS.md`](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) ```bash 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): 1. **LLM — director call.** Implement `llm.LlamaCppLLM` (or `TransformersLLM`), then **delete the `if config.USE_MOCK:` branches** in `orchestrator.direct_turn` / `init_world` / `compact_memory` so the real `complete_json(schema=…)` path runs. The JSON schema is already derived from `schemas.DirectorOutput` via `prompts.directive_schema()`. Tune sampling per `docs/PROMPTS.md §6`. 2. **Painter.** Implement `painter.SdxlTurboPainter._render` (stub already sketches diffusers + seed + LoRA). Caching, prompt composition, and seed-pinning are done. Set `VN_IMAGE_LORA` to your fine-tuned anime LoRA when ready. 3. **STT.** `stt.WhisperSTT` is sketched (faster-whisper). The frontend already records audio and calls `/transcribe`. 4. **Polish.** Sprite moods (pre-gen set, or FLUX.2-Klein conditioning), BiRefNet transparent sprites, speculative backdrop paint, real `compact_memory` summaries. 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.** `ruff` formats/lints. - **Keep `app.py` thin.** Logic lives in `visualnovel/` so it's unit-testable without a server. - **One LLM call per turn by default** (`complete_json` with 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 in `prompts.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) 1. **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 → otherwise `uv python pin 3.12 && uv sync`. 2. **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. 3. **CTranslate2 has no Metal/ROCm** → `faster-whisper` runs on CPU on both your machines (fine for short clips). For GPU STT use `mlx-whisper` (Mac) or `whisper.cpp`. 4. **llama.cpp on hosted ZeroGPU is finicky.** Claim the llama.cpp badge **locally**; keep the `transformers` backend for the Space (`VN_LLM_BACKEND=transformers`). 5. **Image latency is the #1 UX risk.** 1–4-step SDXL-Turbo, 512–768px, text-before-image, cache hard. 6. **Context window is finite** ("Thousand Token Wood"). Feed summary + present sheets + last k turns — never the whole log. `memory.py` enforces it. 7. **Spaces filesystem is ephemeral.** `.md` memory is per-session unless you wire HF persistent storage. 8. **Qwen3 think-mode on ZeroGPU.** Qwen3-14B via `transformers` sometimes puts the entire JSON answer inside its `` block and emits nothing after ``. `TransformersLLM.complete_json` handles this by calling `apply_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.