blind-quill / DESIGN.md
JacobLinCool's picture
fix: disable thinking for json generation
2d20d14
|
Raw
History Blame Contribute Delete
6.02 kB
# Blind Quill Design
Blind Quill is a hidden-canon story grafting game. A reader sees a public
capsule for a manuscript, adds a short fragment, and the model privately decides
where that fragment belongs in the full canon. The reveal is the important
moment: the reader learns where their contribution was stitched and can then read
the updated manuscript.
## Product Rules
- Each manuscript has a hidden full canon and a public capsule.
- A public capsule contains title, genre, tone, summary, visible characters,
open questions, status, and graft count.
- A manuscript accepts at most 30 grafts. At 30 grafts it becomes sealed and
read-only.
- Readers are encouraged to add a fragment before reading the full manuscript.
- Readers may still use the escape door, `Read without changing`, after a
warning modal. This prevents low-quality forced contributions from people who
only want to read.
- A stitch may replace or append a small local passage. It must not regenerate
the whole manuscript.
## User Flows
### Start a Manuscript
1. The user writes a seed, up to 500 characters.
2. The binding page explains that the model is creating hidden canon, a public
capsule, and persistent storage.
3. The creator lands on the capsule for the new manuscript.
4. The creator can contribute another fragment, read the manuscript, or share the
link.
### Continue a Manuscript
1. The user opens the gallery or a `?story=` share link.
2. The user sees only the public capsule.
3. The primary path is `Contribute a fragment`.
4. The escape path is `Read without changing`, which opens a warning modal before
revealing the full manuscript.
5. After a stitch, the reveal stage shows the public reveal, rationale, target
chapter, and links to read or return to the bindery.
### Read a Manuscript
The reader view shows the full canon, chapter navigation, highlighted grafted
paragraphs when present, and a graft ledger.
## Architecture
The app is a custom frontend served by a Gradio Server backend.
- `app.py` exposes queued API endpoints and serves `web/`.
- `core.py` owns create, browse, stitch, and read orchestration.
- `story_store.py` owns JSON persistence and file locking.
- `model_client.py` loads `Qwen/Qwen3.5-2B`, resolves the execution device,
runs generation, strips thinking blocks, and validates JSON.
- `patcher.py` applies model patches deterministically.
- `presenter.py` maps backend story objects into frontend view models.
- `observability.py` configures logging and a lightweight per-run profiler.
- `web/` contains the production React frontend loaded through Babel in the
Space page.
## Execution and Progress
`BQ_DEVICE` (`auto` | `zerogpu` | `cuda` | `mps` | `cpu`) selects the base
backend; `auto` prefers ZeroGPU on a Space, then CUDA, MPS, and CPU.
ZeroGPU quota is per visitor and only known at request time, so device selection
cannot be purely static. The flow is:
- `app.py` attempts each ZeroGPU stitch synchronously on the request thread —
ZeroGPU bills against the Gradio request context, which a worker thread would
not carry. ZeroGPU runs the function in a forked subprocess, so its arguments
are pickled and it cannot stream token callbacks back.
- If ZeroGPU raises a per-user quota error (`spaces` raises `gradio.Error` with a
"quota exceeded" / "credits exceeded" message), `app.py` retries the stitch
with `force_cpu=True`. `model_client.generate_text` then runs in-process on the
CPU-resident model instead of the GPU worker.
- In-process runs (local CUDA/MPS/CPU, or the CPU fallback) execute through
`_stream_stitch`, which runs `core.stitch` on a worker thread and drains its
`on_progress` callback through a queue into the endpoint's yields. A worker
thread is safe here only because no `@spaces.GPU` call is involved.
- `core.stitch` accepts an optional `on_progress` callback and a `force_cpu`
flag, reports stage and token progress, and still returns an
`AppliedPatchResult` synchronously so tests and callers are unaffected.
- The frontend consumes the stream with the Gradio JS client's `submit` and
shows stage, percentage, ETA, and a fallback note, dropping back to the staged
animation for fast GPU runs that emit no token progress.
## Observability
Logging and profiling write to stderr only, never the UI. `observability.py`
configures the `blind_quill` logger (level via `BQ_LOG_LEVEL`, default `INFO`).
Each stitch logs messages processed, total and per-stage timings, and a
best-effort resource snapshot (process memory, CPU, and GPU/MPS memory when
available); a missing metric is omitted rather than raising.
## Model Policy
- Model: `Qwen/Qwen3.5-2B`.
- One model only.
- No embeddings, RAG, ASR, image generation, or secondary LLM.
- Qwen thinking mode is disabled for schema-constrained JSON calls so the model
reaches the required object before `max_new_tokens`; other text generation uses
the model template default.
- `<think>...</think>` content is stripped before parsing, storage, prompts, or
UI display.
- Generation does not manually tune sampling controls.
- ZeroGPU runs use `spaces.GPU(duration=300)`; CUDA, MPS, and CPU runs call the
model directly. The backend is chosen by `BQ_DEVICE`.
## Environment
The Space targets Python 3.12 and Gradio 6.16. Local development should use uv
with the same Python version:
```bash
uv sync --python 3.12
uv export --format requirements-txt --no-dev --no-hashes --no-emit-project -o requirements.txt
```
Hugging Face Spaces still installs from `requirements.txt`; that file is
generated from `uv.lock`.
## Verification
Required checks before deployment:
```bash
uv run python -m compileall app.py core.py model_client.py observability.py patcher.py presenter.py prompts.py schemas.py story_store.py utils.py tests
uv run python -m unittest discover -s tests -v
```
For frontend changes, also verify the relevant deployed or local UI state with a
browser: mobile width, no horizontal overflow, and expected button/modal flows.