Spaces:
Running
Running
Commit ·
414dc55
0
Parent(s):
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +24 -0
- .gitattributes +37 -0
- .gitignore +39 -0
- COMPLIANCE.md +95 -0
- Dockerfile +47 -0
- README.md +91 -0
- app.py +70 -0
- assets/fonts/body.woff2 +0 -0
- assets/fonts/heading.woff2 +0 -0
- assets/ui/music/ambient_theme.mp3 +3 -0
- assets/ui/music/ambient_theme.wav +3 -0
- assets/ui/sfx/accuse.wav +0 -0
- assets/ui/sfx/click.wav +0 -0
- assets/ui/sfx/fail.wav +0 -0
- assets/ui/sfx/page.wav +0 -0
- assets/ui/sfx/present.wav +0 -0
- assets/ui/sfx/select.wav +0 -0
- assets/ui/sfx/success.wav +0 -0
- cases/prebaked/CASE-0001.json +543 -0
- cases/prebaked/CASE-0002.json +543 -0
- cases/prebaked/CASE-0003.json +543 -0
- cases/prebaked/CASE-0004.json +543 -0
- cases/prebaked/CASE-0005.json +543 -0
- cases/seeds/GRAYMOOR-3107.json +236 -0
- cases/seeds/tutorial.json +588 -0
- docs/FIELD_NOTES.md +97 -0
- docs/sample_agent_trace.json +104 -0
- pyproject.toml +33 -0
- requirements.txt +23 -0
- scripts/build_tutorial_case.py +286 -0
- scripts/curate_prebaked.py +65 -0
- scripts/fetch_models.py +74 -0
- scripts/net_audit.py +81 -0
- scripts/prebake_cases.py +125 -0
- src/case_zero/__init__.py +7 -0
- src/case_zero/api/__init__.py +13 -0
- src/case_zero/api/case_adapter.py +221 -0
- src/case_zero/api/dto.py +31 -0
- src/case_zero/api/hints.py +37 -0
- src/case_zero/api/public_view.py +179 -0
- src/case_zero/api/questions.py +20 -0
- src/case_zero/api/routes_case.py +55 -0
- src/case_zero/api/routes_run.py +202 -0
- src/case_zero/api/runtime.py +243 -0
- src/case_zero/api/scripted.py +48 -0
- src/case_zero/api/server.py +96 -0
- src/case_zero/api/tts_service.py +73 -0
- src/case_zero/audio/__init__.py +16 -0
- src/case_zero/audio/manifest.py +26 -0
- src/case_zero/audio/synth.py +168 -0
.dockerignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Build context excludes — keep the image lean and force a clean web build in-image.
|
| 2 |
+
.git
|
| 3 |
+
.venv
|
| 4 |
+
venv
|
| 5 |
+
env
|
| 6 |
+
**/__pycache__
|
| 7 |
+
*.pyc
|
| 8 |
+
.pytest_cache
|
| 9 |
+
.ruff_cache
|
| 10 |
+
.mypy_cache
|
| 11 |
+
.coverage
|
| 12 |
+
htmlcov
|
| 13 |
+
|
| 14 |
+
# Frontend: node_modules and any local dist are rebuilt by the Node stage.
|
| 15 |
+
web/node_modules
|
| 16 |
+
web/dist
|
| 17 |
+
web/.vite
|
| 18 |
+
|
| 19 |
+
# Large/generated artifacts (weights are fetched in-build; runtime cases are ephemeral).
|
| 20 |
+
models/llm/*.gguf
|
| 21 |
+
models/**/*.onnx
|
| 22 |
+
cases/runtime
|
| 23 |
+
*.log
|
| 24 |
+
.env
|
.gitattributes
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
assets/ui/music/ambient_theme.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
assets/ui/music/ambient_theme.wav filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.egg-info/
|
| 5 |
+
.pytest_cache/
|
| 6 |
+
.mypy_cache/
|
| 7 |
+
.ruff_cache/
|
| 8 |
+
.coverage
|
| 9 |
+
htmlcov/
|
| 10 |
+
.venv/
|
| 11 |
+
|
| 12 |
+
# Models and large artifacts (baked at build time, never committed)
|
| 13 |
+
models/llm/*.gguf
|
| 14 |
+
models/**/*.onnx
|
| 15 |
+
assets/voices/*.onnx
|
| 16 |
+
assets/voices/*.onnx.json
|
| 17 |
+
|
| 18 |
+
# Generated runtime data
|
| 19 |
+
cases/runtime/
|
| 20 |
+
.cache/
|
| 21 |
+
*.log
|
| 22 |
+
|
| 23 |
+
# OS
|
| 24 |
+
.DS_Store
|
| 25 |
+
Thumbs.db
|
| 26 |
+
|
| 27 |
+
# Dev screenshots / scratch artifacts
|
| 28 |
+
/*.png
|
| 29 |
+
.playwright-mcp/
|
| 30 |
+
assets/sprites/cache/
|
| 31 |
+
|
| 32 |
+
# Frontend (Vite + Preact) - node_modules and the build output are not committed;
|
| 33 |
+
# the bundle is built in the Docker image (M5).
|
| 34 |
+
web/node_modules/
|
| 35 |
+
web/dist/
|
| 36 |
+
web/.vite/
|
| 37 |
+
|
| 38 |
+
# Local environment overrides (optional; the game runs fully on defaults) - never commit.
|
| 39 |
+
.env
|
COMPLIANCE.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Case Zero - Hackathon Compliance
|
| 2 |
+
|
| 3 |
+
Built for the **Build Small Hackathon** ("Small models, big adventure").
|
| 4 |
+
|
| 5 |
+
Case Zero is a **Gradio application**: the whole app is one `gradio.Server` (Gradio 6
|
| 6 |
+
"Server mode" - a FastAPI subclass launched through Gradio, with Gradio API endpoints
|
| 7 |
+
registered via `@server.api`). It is deployed as a **Hugging Face Space** on **CPU** (no
|
| 8 |
+
GPU). It ships via the Docker SDK purely so llama.cpp compiles on a stable base image - the
|
| 9 |
+
app itself is Gradio, served end to end by `gradio.Server`.
|
| 10 |
+
|
| 11 |
+
## Core requirements
|
| 12 |
+
|
| 13 |
+
| Requirement | Status |
|
| 14 |
+
|---|---|
|
| 15 |
+
| Total model params <= 32B | ✓ ~1.6B (see budget below) |
|
| 16 |
+
| Built in Gradio | ✓ one `gradio.Server`, with `@server.api` endpoints (`new_case`, `interrogate`) |
|
| 17 |
+
| Hosted as a Hugging Face Space | ✓ `build-small-hackathon/case0` (Docker SDK, `app_port: 7860`) |
|
| 18 |
+
| Demo video | ☐ to record (warmup -> interrogate -> present evidence -> alibi cracks -> accuse -> verdict) |
|
| 19 |
+
| Social-media post | ☐ to post |
|
| 20 |
+
|
| 21 |
+
## Parameter budget (<= 32B total)
|
| 22 |
+
|
| 23 |
+
Every model is open-weights and self-run. **No third-party AI service is ever called.**
|
| 24 |
+
|
| 25 |
+
| Component | Model | Open? | Params | Runs |
|
| 26 |
+
|---|---|---|---|---|
|
| 27 |
+
| Reasoning + dialogue (the whole game) | Qwen2.5-1.5B-Instruct (Q4_K_M GGUF) | Apache-2.0 | **1.5B** | in-process llama.cpp on CPU |
|
| 28 |
+
| Suspect voices | Supertonic (ONNX) | open | ~0.1B | local ONNX Runtime (CPU) |
|
| 29 |
+
| Portraits / scenes / props | Procedural canvas - no model | n/a | 0B | client-side |
|
| 30 |
+
| Music + SFX | Pre-made / procedural audio - no model | n/a | 0B | playback only |
|
| 31 |
+
| Embeddings / vector RAG | none | n/a | 0B | - |
|
| 32 |
+
|
| 33 |
+
**Total runtime parameters: ~1.6B** - far under 32B (and under 4B, eligible for the
|
| 34 |
+
**Tiny Titan** special award).
|
| 35 |
+
|
| 36 |
+
## Merit badges
|
| 37 |
+
|
| 38 |
+
### Earned by the build (verifiable on the Space)
|
| 39 |
+
|
| 40 |
+
- **Off the Grid** - *"No cloud APIs. The whole thing runs on the model in front of you."*
|
| 41 |
+
The LLM is in-process llama.cpp; the voices are a local ONNX model; the pixel art is
|
| 42 |
+
rendered client-side on canvas; the music is a bundled CC-BY track. The open weights are
|
| 43 |
+
baked into the Docker image at build time, so the running container makes **no AI network
|
| 44 |
+
calls at all**. Proof: `python scripts/net_audit.py` runs a full playthrough under a
|
| 45 |
+
socket guard and asserts **zero non-loopback connections**. ✓
|
| 46 |
+
- **Llama Champion** - *"Your model runs through the llama.cpp runtime."* The LLM runs
|
| 47 |
+
through `llama-cpp-python` (in-process, on the CPU) - no server, no GPU, no remote
|
| 48 |
+
endpoint. ✓
|
| 49 |
+
- **Off-Brand** - *"A custom frontend that pushes past the default Gradio look."* The front
|
| 50 |
+
end is **not** stock Gradio. It is a hand-built **pixel-art noir SPA (Preact + Vite,
|
| 51 |
+
TypeScript)** - 12 screens, a custom pixel design system (self-hosted Silkscreen /
|
| 52 |
+
Pixelify Sans fonts, beveled 9-slice panels, inventory-slot evidence cards, a ruled-paper
|
| 53 |
+
dossier with page-flips), a draggable corkboard, a live interrogation stage with a
|
| 54 |
+
voiced suspect, procedural canvas art and rain FX, and a full client audio layer. The
|
| 55 |
+
built bundle is served as static files by the same `gradio.Server` that exposes the
|
| 56 |
+
`/api` routes - one process, no separate frontend host. ✓
|
| 57 |
+
|
| 58 |
+
### Targeted / in progress
|
| 59 |
+
|
| 60 |
+
- **Field Notes** - *"Write a blog post or report about your project."* Draft in
|
| 61 |
+
[`docs/FIELD_NOTES.md`](docs/FIELD_NOTES.md) - to be published on the Hub.
|
| 62 |
+
- **Sharing is Caring** - *"You shared your agent trace on the Hub for everyone to learn
|
| 63 |
+
from."* A captured interrogation/generation trace to be uploaded to the Hub.
|
| 64 |
+
- **Well-Tuned** - *"Your app uses a fine-tuned model you've published on Hugging Face."*
|
| 65 |
+
Not yet - the game runs on stock Qwen2.5-1.5B. Would require fine-tuning and publishing a
|
| 66 |
+
model; out of scope for this submission unless pursued separately.
|
| 67 |
+
|
| 68 |
+
## Zero cloud AI APIs
|
| 69 |
+
|
| 70 |
+
- **No OpenAI, Anthropic, Google, ElevenLabs, Higgsfield, Midjourney, or any other hosted
|
| 71 |
+
AI API is ever called** - not for text, not for voice, not for images.
|
| 72 |
+
- The LLM is the in-process llama.cpp runtime. The voices are a local ONNX model. The pixel
|
| 73 |
+
art is procedural canvas. The music is a bundled CC-BY track.
|
| 74 |
+
- The open Qwen GGUF and Supertonic ONNX are **baked into the Docker image at build time**,
|
| 75 |
+
so the running container makes no AI network calls. `scripts/net_audit.py` proves zero
|
| 76 |
+
non-loopback connections during a full playthrough.
|
| 77 |
+
|
| 78 |
+
## Anti-cheat / fairness (why the game is solvable and the win is earned)
|
| 79 |
+
|
| 80 |
+
- The sealed solution (killer, true motive, key evidence) is **never sent to the client**
|
| 81 |
+
pre-verdict; it is read only inside `/api/run/{runId}/accuse`. Verified by anti-leak tests.
|
| 82 |
+
- Suspicion, evidence reactions, and the verdict are **server-authoritative** - the client
|
| 83 |
+
only displays them.
|
| 84 |
+
- Suspects **never confess**: the win is registered only when the player accuses correctly,
|
| 85 |
+
so the outcome is immune to prose (a jailbroken "just tell me who did it" earns nothing).
|
| 86 |
+
|
| 87 |
+
## Submission checklist
|
| 88 |
+
|
| 89 |
+
- [x] Gradio app on a Hugging Face Space (CPU)
|
| 90 |
+
- [x] <= 32B total params (~1.6B)
|
| 91 |
+
- [x] Open-weights, self-run models only - zero cloud AI APIs
|
| 92 |
+
- [x] Custom (non-default) UI - pixel-art Preact SPA via `gradio.Server`
|
| 93 |
+
- [x] Off the Grid proof (`scripts/net_audit.py`)
|
| 94 |
+
- [ ] Short demo video
|
| 95 |
+
- [ ] Social-media post
|
Dockerfile
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Case Zero - Hugging Face Space (Docker SDK, CPU).
|
| 2 |
+
# Two stages: (1) Node builds the Preact pixel-art SPA into web/dist; (2) Python compiles
|
| 3 |
+
# llama.cpp from source on a stable glibc base (bookworm / gcc 12 - the Gradio-SDK builder's
|
| 4 |
+
# trixie/gcc 14 could not build it) and bakes everything. The app is served 100% by one
|
| 5 |
+
# gradio.Server: the built SPA as static files + the /api routes. No GPU, no remote endpoint.
|
| 6 |
+
|
| 7 |
+
# ---- stage 1: build the frontend bundle ----
|
| 8 |
+
FROM node:22-slim AS web
|
| 9 |
+
WORKDIR /web
|
| 10 |
+
COPY web/package.json web/package-lock.json ./
|
| 11 |
+
RUN npm ci
|
| 12 |
+
COPY web/ ./
|
| 13 |
+
RUN npm run build
|
| 14 |
+
|
| 15 |
+
# ---- stage 2: python runtime ----
|
| 16 |
+
FROM python:3.12-slim
|
| 17 |
+
|
| 18 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 19 |
+
build-essential cmake git libsndfile1 \
|
| 20 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 21 |
+
|
| 22 |
+
RUN useradd -m -u 1000 user
|
| 23 |
+
USER user
|
| 24 |
+
WORKDIR /home/user/app
|
| 25 |
+
|
| 26 |
+
ENV HOME=/home/user
|
| 27 |
+
ENV PATH=/home/user/.local/bin:$PATH
|
| 28 |
+
ENV GRADIO_ANALYTICS_ENABLED=False
|
| 29 |
+
ENV HF_HUB_OFFLINE=0
|
| 30 |
+
ENV CASE0_PORT=7860
|
| 31 |
+
# Portable build (no -march=native) so the compiled wheel runs on any HF CPU.
|
| 32 |
+
ENV CMAKE_ARGS=-DGGML_NATIVE=OFF
|
| 33 |
+
ENV CMAKE_BUILD_PARALLEL_LEVEL=4
|
| 34 |
+
|
| 35 |
+
COPY --chown=user requirements.txt .
|
| 36 |
+
RUN pip install --no-cache-dir --user -r requirements.txt
|
| 37 |
+
|
| 38 |
+
COPY --chown=user . .
|
| 39 |
+
# Bring in the production SPA bundle built in stage 1 (web/dist is .dockerignored).
|
| 40 |
+
COPY --from=web --chown=user /web/dist ./web/dist
|
| 41 |
+
|
| 42 |
+
# Bake the open weights (LLM GGUF + Supertonic voices) so cold starts are fast and the
|
| 43 |
+
# running container needs no network. Falls back to a runtime fetch if this is skipped.
|
| 44 |
+
RUN python scripts/fetch_models.py || echo "weight prefetch skipped (will fetch at runtime)"
|
| 45 |
+
|
| 46 |
+
EXPOSE 7860
|
| 47 |
+
CMD ["python", "app.py"]
|
README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Case Zero
|
| 3 |
+
emoji: 🕵️
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: true
|
| 9 |
+
license: apache-2.0
|
| 10 |
+
models:
|
| 11 |
+
- Qwen/Qwen2.5-1.5B-Instruct
|
| 12 |
+
tags:
|
| 13 |
+
- build-small-hackathon
|
| 14 |
+
- llama-cpp
|
| 15 |
+
- tiny-titan
|
| 16 |
+
- detective-game
|
| 17 |
+
- text-generation
|
| 18 |
+
- tts
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
# 🕵️ Case Zero — the AI *is* the detective game
|
| 22 |
+
|
| 23 |
+
**A brand-new murder mystery, written and acted by a 1.5B model, every single time.**
|
| 24 |
+
|
| 25 |
+
No scripted cases. No content library. A single small local model invents the whole
|
| 26 |
+
thing — the victim, the suspects, their secrets and motives, the timeline, the murder
|
| 27 |
+
weapon, the evidence, and the one who did it — then **role-plays every suspect live**.
|
| 28 |
+
They remember what you asked. They lie to your face. And when you slap down the right
|
| 29 |
+
piece of evidence, you watch the lie **crack in real time**.
|
| 30 |
+
|
| 31 |
+
> Interrogate. Investigate. Accuse. One of them is guilty. Prove it.
|
| 32 |
+
|
| 33 |
+
## ✨ The moment that sells it
|
| 34 |
+
|
| 35 |
+
Search the rooms, find a clue that contradicts a suspect's alibi, **present it**, and
|
| 36 |
+
their story falls apart on screen — stress spikes, the alibi breaks, the truth leaks.
|
| 37 |
+
Then name the killer, cite your proof, and get a scored verdict with a "Director's Cut"
|
| 38 |
+
walkthrough of how the crime really went down.
|
| 39 |
+
|
| 40 |
+
## 🧠 How it works
|
| 41 |
+
|
| 42 |
+
| Layer | What it does |
|
| 43 |
+
|---|---|
|
| 44 |
+
| **Model** — Qwen2.5-1.5B-Instruct (GGUF) | The whole game. Runs in-process on the CPU through **llama.cpp** (`llama-cpp-python`) — no server, no GPU, no remote endpoint. |
|
| 45 |
+
| **Generation** | The model authors every case as JSON; deterministic Python only wires the *structure* (who's guilty, who was where) so the mystery is always solvable. |
|
| 46 |
+
| **Solver** | A fairness referee: single culprit, a breakable alibi, every innocent cleared, and a discoverability gate so the key clue is always findable in play. |
|
| 47 |
+
| **Director** | Whether a lie gets caught is decided by **ground truth, not the model** — so the win condition is immune to prose (a jailbroken "just tell me who did it" earns nothing). |
|
| 48 |
+
| **Voice** — Supertonic | Each suspect gets a distinct, gender-matched on-device voice, synthesized **sentence-by-sentence as the reply streams**. |
|
| 49 |
+
| **Art** | Procedural pixel-art portraits, rooms, and evidence — rendered **client-side on canvas** at one integer-scaled density (so the server spends ~0 CPU on visuals). |
|
| 50 |
+
| **UI** | A custom **pixel-art noir SPA (Preact)**, 12 screens, served **100% through `gradio.Server`** (Gradio 6 "Server mode") — the built bundle as static files plus the JSON/SSE `/api` routes, all in one process. No separate frontend host. |
|
| 51 |
+
|
| 52 |
+
The model does all the creative work. Deterministic code is only guardrails and a
|
| 53 |
+
reliability layer — it never writes story, character, or dialogue.
|
| 54 |
+
|
| 55 |
+
## 🏆 Built for the Build Small Hackathon
|
| 56 |
+
|
| 57 |
+
- **Tiny Titan (≤4B):** the entire game runs on **Qwen2.5-1.5B** — ~1.6B total runtime
|
| 58 |
+
params (LLM + Supertonic), far under the 32B cap.
|
| 59 |
+
- **Llama Champion:** the model runs through the **llama.cpp** runtime, in-process — no
|
| 60 |
+
server, no remote endpoint.
|
| 61 |
+
- **Off-Brand:** a fully custom pixel-art frontend, served through `gradio.Server`.
|
| 62 |
+
- All models are **open-weights and self-run**. No third-party AI APIs are ever called.
|
| 63 |
+
|
| 64 |
+
See [COMPLIANCE.md](COMPLIANCE.md) for the full parameter budget and badge details.
|
| 65 |
+
|
| 66 |
+
## ▶️ Run it locally
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
# 1. backend deps + open weights
|
| 70 |
+
python -m venv .venv && .venv/Scripts/pip install -r requirements.txt # (Windows)
|
| 71 |
+
python scripts/fetch_models.py # one-time: fetch the open GGUF + Supertonic
|
| 72 |
+
|
| 73 |
+
# 2. build the pixel-art frontend bundle (served by gradio.Server from web/dist)
|
| 74 |
+
cd web && npm install && npm run build && cd ..
|
| 75 |
+
|
| 76 |
+
# 3. run — open http://127.0.0.1:7860
|
| 77 |
+
python app.py
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
The game runs entirely on the CPU — laptop or Space, same code, no GPU required.
|
| 81 |
+
(In the Docker/Space build both steps happen automatically: a Node stage builds the
|
| 82 |
+
bundle and the Python stage compiles llama.cpp and bakes the weights.)
|
| 83 |
+
|
| 84 |
+
## 🙏 Credits
|
| 85 |
+
|
| 86 |
+
- **LLM:** [Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct) (Apache-2.0), via llama.cpp.
|
| 87 |
+
- **Voices:** Supertonic on-device TTS.
|
| 88 |
+
- **Music:** *"Backbay Lounge"* by Kevin MacLeod (incompetech.com), licensed under
|
| 89 |
+
[Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/).
|
| 90 |
+
- **Fonts:** Silkscreen & Pixelify Sans (SIL Open Font License), self-hosted.
|
| 91 |
+
- Pixel art and UI sound effects: procedurally generated.
|
app.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Case Zero entrypoint - one ``gradio.Server`` for a Hugging Face (CPU) Space.
|
| 2 |
+
|
| 3 |
+
The pixel-art frontend (built Preact bundle in ``web/dist``) and the game's JSON/SSE API
|
| 4 |
+
are both served by a single ``gradio.Server`` (a FastAPI subclass). The LLM and TTS run
|
| 5 |
+
in-process on the CPU via llama.cpp / Supertonic - no GPU, no inference API, fully local.
|
| 6 |
+
|
| 7 |
+
Gradio's own frontend and Node SSR proxy are disabled (``_frontend=False``,
|
| 8 |
+
``ssr_mode=False``) so our SPA owns ``/`` and we don't pay the node-proxy CPU cost on the
|
| 9 |
+
2-vCPU Space.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
# Make src importable whether run from the repo root or as a Space.
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
|
| 20 |
+
|
| 21 |
+
os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _ensure_weights() -> None:
|
| 25 |
+
"""Download the LLM GGUF once if it is not already on disk (Spaces have no baked
|
| 26 |
+
weights). A no-op locally where the file already exists. Invoked lazily on first
|
| 27 |
+
real generation (M1+), not at boot, so the server starts instantly."""
|
| 28 |
+
from case_zero.config import get_settings
|
| 29 |
+
|
| 30 |
+
settings = get_settings()
|
| 31 |
+
if settings.llm_model_path.exists():
|
| 32 |
+
return
|
| 33 |
+
try:
|
| 34 |
+
import shutil
|
| 35 |
+
|
| 36 |
+
from huggingface_hub import hf_hub_download
|
| 37 |
+
|
| 38 |
+
dest = settings.llm_model_path
|
| 39 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 40 |
+
cached = hf_hub_download(
|
| 41 |
+
repo_id="Qwen/Qwen2.5-1.5B-Instruct-GGUF",
|
| 42 |
+
filename="qwen2.5-1.5b-instruct-q4_k_m.gguf",
|
| 43 |
+
)
|
| 44 |
+
shutil.copy(cached, dest)
|
| 45 |
+
print(f"[startup] fetched LLM weights -> {dest}")
|
| 46 |
+
except Exception as exc: # pragma: no cover
|
| 47 |
+
print(f"[startup] weight fetch failed: {exc}", file=sys.stderr)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def main() -> None:
|
| 51 |
+
from case_zero.api import build_server
|
| 52 |
+
from case_zero.api.runtime import RUNTIME
|
| 53 |
+
|
| 54 |
+
# Fetch weights if needed, then prebuild one case in the background so the first
|
| 55 |
+
# "New Case" is ready (or nearly) by the time a detective connects.
|
| 56 |
+
_ensure_weights()
|
| 57 |
+
RUNTIME.start_buffer()
|
| 58 |
+
|
| 59 |
+
server = build_server()
|
| 60 |
+
server.launch(
|
| 61 |
+
server_name="0.0.0.0",
|
| 62 |
+
server_port=int(os.environ.get("CASE0_PORT", "7860")),
|
| 63 |
+
share=False,
|
| 64 |
+
ssr_mode=False,
|
| 65 |
+
_frontend=False,
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
if __name__ == "__main__":
|
| 70 |
+
main()
|
assets/fonts/body.woff2
ADDED
|
Binary file (7.69 kB). View file
|
|
|
assets/fonts/heading.woff2
ADDED
|
Binary file (8.4 kB). View file
|
|
|
assets/ui/music/ambient_theme.mp3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1adaff8da50ff31e2b813b7d7886a0f674d777d0e4ee95c526c326a925e68c3d
|
| 3 |
+
size 985453
|
assets/ui/music/ambient_theme.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7a7f34402a5e90b90602d1517650dbf60cef2155fb6cd3dff7ecd843607104e4
|
| 3 |
+
size 705644
|
assets/ui/sfx/accuse.wav
ADDED
|
Binary file (22.1 kB). View file
|
|
|
assets/ui/sfx/click.wav
ADDED
|
Binary file (2.69 kB). View file
|
|
|
assets/ui/sfx/fail.wav
ADDED
|
Binary file (21.2 kB). View file
|
|
|
assets/ui/sfx/page.wav
ADDED
|
Binary file (7.98 kB). View file
|
|
|
assets/ui/sfx/present.wav
ADDED
|
Binary file (9.75 kB). View file
|
|
|
assets/ui/sfx/select.wav
ADDED
|
Binary file (6.22 kB). View file
|
|
|
assets/ui/sfx/success.wav
ADDED
|
Binary file (21.2 kB). View file
|
|
|
cases/prebaked/CASE-0001.json
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "CASE-0001",
|
| 3 |
+
"seed": 43004,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Mysterious Vanishing",
|
| 6 |
+
"briefing": "A wealthy socialite vanishes mysteriously from her luxurious estate, leaving behind a cryptic note and no trace of the weapon used in the murder.",
|
| 7 |
+
"knobs": {
|
| 8 |
+
"setting_hint": "",
|
| 9 |
+
"era_hint": "",
|
| 10 |
+
"tone_hint": "",
|
| 11 |
+
"n_suspects": 4,
|
| 12 |
+
"n_red_herrings": 2,
|
| 13 |
+
"alibi_tightness": 0.6,
|
| 14 |
+
"difficulty": "standard"
|
| 15 |
+
},
|
| 16 |
+
"setting": {
|
| 17 |
+
"name": "Oakwood Manor",
|
| 18 |
+
"description": "",
|
| 19 |
+
"locations": [
|
| 20 |
+
{
|
| 21 |
+
"loc_id": "L1",
|
| 22 |
+
"name": "Entrance Hall",
|
| 23 |
+
"description": "",
|
| 24 |
+
"adjacent_to": [
|
| 25 |
+
"L2",
|
| 26 |
+
"L3",
|
| 27 |
+
"L4"
|
| 28 |
+
]
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"loc_id": "L2",
|
| 32 |
+
"name": "Garden Room",
|
| 33 |
+
"description": "",
|
| 34 |
+
"adjacent_to": [
|
| 35 |
+
"L1"
|
| 36 |
+
]
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"loc_id": "L3",
|
| 40 |
+
"name": "Dining Room",
|
| 41 |
+
"description": "",
|
| 42 |
+
"adjacent_to": [
|
| 43 |
+
"L1"
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"loc_id": "L4",
|
| 48 |
+
"name": "Study",
|
| 49 |
+
"description": "",
|
| 50 |
+
"adjacent_to": [
|
| 51 |
+
"L1"
|
| 52 |
+
]
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
"murder_window": {
|
| 56 |
+
"start_min": 1260,
|
| 57 |
+
"end_min": 1320
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
"victim": {
|
| 61 |
+
"vic_id": "V1",
|
| 62 |
+
"name": "Isabella Whitcomb",
|
| 63 |
+
"role": "Society Lady",
|
| 64 |
+
"found_at_loc_id": "L3",
|
| 65 |
+
"found_at_min": 1325,
|
| 66 |
+
"cause_of_death": "Struck with a hidden dagger by an unseen assailant in her Study at 21:45.",
|
| 67 |
+
"time_of_death": {
|
| 68 |
+
"start_min": 1280,
|
| 69 |
+
"end_min": 1310
|
| 70 |
+
}
|
| 71 |
+
},
|
| 72 |
+
"weapon": {
|
| 73 |
+
"weapon_id": "W1",
|
| 74 |
+
"name": "Silent Dagger",
|
| 75 |
+
"kind": "Blade, concealed as a candlestick on the mantelpiece",
|
| 76 |
+
"origin_loc_id": "L3",
|
| 77 |
+
"requires_strength": false,
|
| 78 |
+
"leaves_trace": ""
|
| 79 |
+
},
|
| 80 |
+
"suspects": [
|
| 81 |
+
{
|
| 82 |
+
"sus_id": "S1",
|
| 83 |
+
"name": "Charles Rivington",
|
| 84 |
+
"role": "Billionaire Business Partner",
|
| 85 |
+
"persona_summary": "A charming yet ruthless man who keeps secrets close to his chest.",
|
| 86 |
+
"demeanour": "hostile and defensive, bristling at any hint of suspicion",
|
| 87 |
+
"is_culprit": true,
|
| 88 |
+
"physical_capability": {
|
| 89 |
+
"strength": true,
|
| 90 |
+
"mobility": true
|
| 91 |
+
},
|
| 92 |
+
"personality": {
|
| 93 |
+
"composure": 0.55,
|
| 94 |
+
"aggression": 0.88,
|
| 95 |
+
"evasiveness": 0.4
|
| 96 |
+
},
|
| 97 |
+
"tells": [
|
| 98 |
+
"I don't have secrets, just a perfect facade for the world outside this house."
|
| 99 |
+
],
|
| 100 |
+
"knows_facts": [
|
| 101 |
+
"F_scene",
|
| 102 |
+
"F_sec1"
|
| 103 |
+
],
|
| 104 |
+
"secrets": [
|
| 105 |
+
"He is secretly involved in illegal activities that he wants no one to know about, fearing the law will reveal too much about his reputation and business."
|
| 106 |
+
],
|
| 107 |
+
"true_whereabouts": [
|
| 108 |
+
{
|
| 109 |
+
"window": {
|
| 110 |
+
"start_min": 1260,
|
| 111 |
+
"end_min": 1280
|
| 112 |
+
},
|
| 113 |
+
"loc_id": "L4",
|
| 114 |
+
"activity": "mingling in plain sight",
|
| 115 |
+
"co_present_sus_ids": []
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
"window": {
|
| 119 |
+
"start_min": 1280,
|
| 120 |
+
"end_min": 1310
|
| 121 |
+
},
|
| 122 |
+
"loc_id": "L3",
|
| 123 |
+
"activity": "alone with the victim",
|
| 124 |
+
"co_present_sus_ids": []
|
| 125 |
+
}
|
| 126 |
+
],
|
| 127 |
+
"stated_alibi": {
|
| 128 |
+
"claim_text": "I am currently in the Study writing a report on our business strategy.",
|
| 129 |
+
"claimed_segments": [
|
| 130 |
+
{
|
| 131 |
+
"window": {
|
| 132 |
+
"start_min": 1260,
|
| 133 |
+
"end_min": 1320
|
| 134 |
+
},
|
| 135 |
+
"loc_id": "L4",
|
| 136 |
+
"witness_sus_ids": []
|
| 137 |
+
}
|
| 138 |
+
]
|
| 139 |
+
},
|
| 140 |
+
"must_lie_about": [
|
| 141 |
+
"F_scene"
|
| 142 |
+
],
|
| 143 |
+
"anchored_lies": [
|
| 144 |
+
{
|
| 145 |
+
"lie_id": "LIE_alibi",
|
| 146 |
+
"topic": "where you were during the murder",
|
| 147 |
+
"claimed": "I am currently in the Study writing a report on our business strategy.",
|
| 148 |
+
"truth_ref": "F_scene",
|
| 149 |
+
"breaks_on": [
|
| 150 |
+
"C_b1",
|
| 151 |
+
"C_b2"
|
| 152 |
+
],
|
| 153 |
+
"fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
|
| 154 |
+
}
|
| 155 |
+
],
|
| 156 |
+
"voice": null,
|
| 157 |
+
"visual": {
|
| 158 |
+
"subject_type": "suspect",
|
| 159 |
+
"palette": "noir",
|
| 160 |
+
"gender": "male",
|
| 161 |
+
"age_band": "50s",
|
| 162 |
+
"build": null,
|
| 163 |
+
"hair": null,
|
| 164 |
+
"attire": "Tuxedo and polished shoes",
|
| 165 |
+
"mood": "tense",
|
| 166 |
+
"accent_color": "#b8860b",
|
| 167 |
+
"location_tags": [],
|
| 168 |
+
"prop_tags": [],
|
| 169 |
+
"prompt_hint": "Fitted suit with a sharp, intimidating demeanor, Tuxedo and polished shoes"
|
| 170 |
+
}
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
"sus_id": "S2",
|
| 174 |
+
"name": "Sophia Holmes",
|
| 175 |
+
"role": "Deceased Lover of Isabella Whitcomb",
|
| 176 |
+
"persona_summary": "A quiet and gentle woman who kept her love life private.",
|
| 177 |
+
"demeanour": "composed and cooperative on the surface, carefully measured",
|
| 178 |
+
"is_culprit": false,
|
| 179 |
+
"physical_capability": {
|
| 180 |
+
"strength": true,
|
| 181 |
+
"mobility": true
|
| 182 |
+
},
|
| 183 |
+
"personality": {
|
| 184 |
+
"composure": 0.72,
|
| 185 |
+
"aggression": 0.4,
|
| 186 |
+
"evasiveness": 0.3
|
| 187 |
+
},
|
| 188 |
+
"tells": [
|
| 189 |
+
"I am not a villain, my heart is in the right place."
|
| 190 |
+
],
|
| 191 |
+
"knows_facts": [
|
| 192 |
+
"F_sec2"
|
| 193 |
+
],
|
| 194 |
+
"secrets": [
|
| 195 |
+
"She secretly loved Isabella deeply, but chose to be faithful to Charles for his wealth. This secret is hidden in the attic of her childhood home."
|
| 196 |
+
],
|
| 197 |
+
"true_whereabouts": [
|
| 198 |
+
{
|
| 199 |
+
"window": {
|
| 200 |
+
"start_min": 1260,
|
| 201 |
+
"end_min": 1320
|
| 202 |
+
},
|
| 203 |
+
"loc_id": "L2",
|
| 204 |
+
"activity": "going about the evening",
|
| 205 |
+
"co_present_sus_ids": []
|
| 206 |
+
}
|
| 207 |
+
],
|
| 208 |
+
"stated_alibi": {
|
| 209 |
+
"claim_text": "I was in Garden Room the whole time.",
|
| 210 |
+
"claimed_segments": [
|
| 211 |
+
{
|
| 212 |
+
"window": {
|
| 213 |
+
"start_min": 1260,
|
| 214 |
+
"end_min": 1320
|
| 215 |
+
},
|
| 216 |
+
"loc_id": "L2",
|
| 217 |
+
"witness_sus_ids": []
|
| 218 |
+
}
|
| 219 |
+
]
|
| 220 |
+
},
|
| 221 |
+
"must_lie_about": [
|
| 222 |
+
"F_sec2"
|
| 223 |
+
],
|
| 224 |
+
"anchored_lies": [
|
| 225 |
+
{
|
| 226 |
+
"lie_id": "LIE_sec2",
|
| 227 |
+
"topic": "She secretly loved Isabella deeply, but chose to",
|
| 228 |
+
"claimed": "Her death was a heart attack related to stress over their marriage. The dagger she held belonged to an old family heirloom.",
|
| 229 |
+
"truth_ref": "F_sec2",
|
| 230 |
+
"breaks_on": [
|
| 231 |
+
"C_h2"
|
| 232 |
+
],
|
| 233 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 234 |
+
}
|
| 235 |
+
],
|
| 236 |
+
"voice": null,
|
| 237 |
+
"visual": {
|
| 238 |
+
"subject_type": "suspect",
|
| 239 |
+
"palette": "noir",
|
| 240 |
+
"gender": "female",
|
| 241 |
+
"age_band": "20s",
|
| 242 |
+
"build": null,
|
| 243 |
+
"hair": null,
|
| 244 |
+
"attire": "Formal attire with delicate jewelry",
|
| 245 |
+
"mood": "tense",
|
| 246 |
+
"accent_color": "#3a6ea5",
|
| 247 |
+
"location_tags": [],
|
| 248 |
+
"prop_tags": [],
|
| 249 |
+
"prompt_hint": "Gentle demeanor, wearing white lace gloves for evening, Formal attire with delicate jewelry"
|
| 250 |
+
}
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"sus_id": "S3",
|
| 254 |
+
"name": "Arthur Reed",
|
| 255 |
+
"role": "Former Business Rival of Charles Rivington",
|
| 256 |
+
"persona_summary": "An unscrupulous businessman who often played dirty tricks on his competitors.",
|
| 257 |
+
"demeanour": "rattled and evasive, voice tightening under pressure",
|
| 258 |
+
"is_culprit": false,
|
| 259 |
+
"physical_capability": {
|
| 260 |
+
"strength": true,
|
| 261 |
+
"mobility": true
|
| 262 |
+
},
|
| 263 |
+
"personality": {
|
| 264 |
+
"composure": 0.32,
|
| 265 |
+
"aggression": 0.55,
|
| 266 |
+
"evasiveness": 0.68
|
| 267 |
+
},
|
| 268 |
+
"tells": [
|
| 269 |
+
"I have no secrets, just a lack of time to plan my next scheme."
|
| 270 |
+
],
|
| 271 |
+
"knows_facts": [
|
| 272 |
+
"F_sec3"
|
| 273 |
+
],
|
| 274 |
+
"secrets": [
|
| 275 |
+
"He secretly owned a large corporation that was funding the illegal activities Charles financed, hoping to eliminate competition through deceitful means."
|
| 276 |
+
],
|
| 277 |
+
"true_whereabouts": [
|
| 278 |
+
{
|
| 279 |
+
"window": {
|
| 280 |
+
"start_min": 1260,
|
| 281 |
+
"end_min": 1320
|
| 282 |
+
},
|
| 283 |
+
"loc_id": "L4",
|
| 284 |
+
"activity": "going about the evening",
|
| 285 |
+
"co_present_sus_ids": []
|
| 286 |
+
}
|
| 287 |
+
],
|
| 288 |
+
"stated_alibi": {
|
| 289 |
+
"claim_text": "I was in Study the whole time.",
|
| 290 |
+
"claimed_segments": [
|
| 291 |
+
{
|
| 292 |
+
"window": {
|
| 293 |
+
"start_min": 1260,
|
| 294 |
+
"end_min": 1320
|
| 295 |
+
},
|
| 296 |
+
"loc_id": "L4",
|
| 297 |
+
"witness_sus_ids": []
|
| 298 |
+
}
|
| 299 |
+
]
|
| 300 |
+
},
|
| 301 |
+
"must_lie_about": [
|
| 302 |
+
"F_sec3"
|
| 303 |
+
],
|
| 304 |
+
"anchored_lies": [
|
| 305 |
+
{
|
| 306 |
+
"lie_id": "LIE_sec3",
|
| 307 |
+
"topic": "He secretly owned a large corporation that was f",
|
| 308 |
+
"claimed": "His business rivalship with Charles led to an argument at their annual meeting. He used an old map as evidence of Charles's guilt.",
|
| 309 |
+
"truth_ref": "F_sec3",
|
| 310 |
+
"breaks_on": [
|
| 311 |
+
"C_h3"
|
| 312 |
+
],
|
| 313 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 314 |
+
}
|
| 315 |
+
],
|
| 316 |
+
"voice": null,
|
| 317 |
+
"visual": {
|
| 318 |
+
"subject_type": "suspect",
|
| 319 |
+
"palette": "noir",
|
| 320 |
+
"gender": "male",
|
| 321 |
+
"age_band": "50s",
|
| 322 |
+
"build": null,
|
| 323 |
+
"hair": null,
|
| 324 |
+
"attire": "Corporate suit and formal tie",
|
| 325 |
+
"mood": "tense",
|
| 326 |
+
"accent_color": "#9a9aa0",
|
| 327 |
+
"location_tags": [],
|
| 328 |
+
"prop_tags": [],
|
| 329 |
+
"prompt_hint": "Business attire with a cold stare, Corporate suit and formal tie"
|
| 330 |
+
}
|
| 331 |
+
},
|
| 332 |
+
{
|
| 333 |
+
"sus_id": "S4",
|
| 334 |
+
"name": "Elizabeth Taylor",
|
| 335 |
+
"role": "Servant in Isabella's Household",
|
| 336 |
+
"persona_summary": "A reliable but unremarkable housemaid with a strict job description.",
|
| 337 |
+
"demeanour": "guarded and weary, giving away as little as possible",
|
| 338 |
+
"is_culprit": false,
|
| 339 |
+
"physical_capability": {
|
| 340 |
+
"strength": true,
|
| 341 |
+
"mobility": true
|
| 342 |
+
},
|
| 343 |
+
"personality": {
|
| 344 |
+
"composure": 0.6,
|
| 345 |
+
"aggression": 0.25,
|
| 346 |
+
"evasiveness": 0.55
|
| 347 |
+
},
|
| 348 |
+
"tells": [
|
| 349 |
+
"I am just a servant, doing my job."
|
| 350 |
+
],
|
| 351 |
+
"knows_facts": [
|
| 352 |
+
"F_sec4"
|
| 353 |
+
],
|
| 354 |
+
"secrets": [
|
| 355 |
+
"She was secretly part of an organization that had been spying on her employer for years, leading to the death of several wealthy women they were investigating. This knowledge kept hidden from her due to fear and lack of evidence against herself."
|
| 356 |
+
],
|
| 357 |
+
"true_whereabouts": [
|
| 358 |
+
{
|
| 359 |
+
"window": {
|
| 360 |
+
"start_min": 1260,
|
| 361 |
+
"end_min": 1320
|
| 362 |
+
},
|
| 363 |
+
"loc_id": "L1",
|
| 364 |
+
"activity": "going about the evening",
|
| 365 |
+
"co_present_sus_ids": []
|
| 366 |
+
}
|
| 367 |
+
],
|
| 368 |
+
"stated_alibi": {
|
| 369 |
+
"claim_text": "I was in Entrance Hall the whole time.",
|
| 370 |
+
"claimed_segments": [
|
| 371 |
+
{
|
| 372 |
+
"window": {
|
| 373 |
+
"start_min": 1260,
|
| 374 |
+
"end_min": 1320
|
| 375 |
+
},
|
| 376 |
+
"loc_id": "L1",
|
| 377 |
+
"witness_sus_ids": []
|
| 378 |
+
}
|
| 379 |
+
]
|
| 380 |
+
},
|
| 381 |
+
"must_lie_about": [
|
| 382 |
+
"F_sec4"
|
| 383 |
+
],
|
| 384 |
+
"anchored_lies": [
|
| 385 |
+
{
|
| 386 |
+
"lie_id": "LIE_sec4",
|
| 387 |
+
"topic": "She was secretly part of an organization that ha",
|
| 388 |
+
"claimed": "Her mistress believed she was just a devoted housemaid and found her death under mysterious circumstances in the garden.",
|
| 389 |
+
"truth_ref": "F_sec4",
|
| 390 |
+
"breaks_on": [
|
| 391 |
+
"C_h4"
|
| 392 |
+
],
|
| 393 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 394 |
+
}
|
| 395 |
+
],
|
| 396 |
+
"voice": null,
|
| 397 |
+
"visual": {
|
| 398 |
+
"subject_type": "suspect",
|
| 399 |
+
"palette": "noir",
|
| 400 |
+
"gender": "female",
|
| 401 |
+
"age_band": "20s",
|
| 402 |
+
"build": null,
|
| 403 |
+
"hair": null,
|
| 404 |
+
"attire": "Simple evening gown",
|
| 405 |
+
"mood": "tense",
|
| 406 |
+
"accent_color": "#6b8f71",
|
| 407 |
+
"location_tags": [],
|
| 408 |
+
"prop_tags": [],
|
| 409 |
+
"prompt_hint": "Well-groomed but not overly enthusiastic with work, Simple evening gown"
|
| 410 |
+
}
|
| 411 |
+
}
|
| 412 |
+
],
|
| 413 |
+
"culprit": {
|
| 414 |
+
"sus_id": "S1",
|
| 415 |
+
"true_motive": {
|
| 416 |
+
"motive_id": "M1",
|
| 417 |
+
"category": "revenge",
|
| 418 |
+
"summary": "Charles Rivington murdered Isabella Whitcomb in the Dining Room as a means to exact revenge against her for publicly revealing his affair with her."
|
| 419 |
+
},
|
| 420 |
+
"method_narrative": "Rivington used the Silent Dagger, which could be seen through the dining room window, to commit the murder when no one was around. He then fabricated the alibi of being in the Study to cover up his actions.",
|
| 421 |
+
"alibi_lie": {
|
| 422 |
+
"claimed_loc_id": "L4",
|
| 423 |
+
"actual_loc_id": "L3",
|
| 424 |
+
"contradicted_by_clue_ids": [
|
| 425 |
+
"C_b1",
|
| 426 |
+
"C_b2"
|
| 427 |
+
]
|
| 428 |
+
}
|
| 429 |
+
},
|
| 430 |
+
"relationships": [],
|
| 431 |
+
"facts": [
|
| 432 |
+
{
|
| 433 |
+
"fact_id": "F_scene",
|
| 434 |
+
"statement": "Charles Rivington was in Dining Room during the murder.",
|
| 435 |
+
"true_value": true,
|
| 436 |
+
"loc_id": "L3",
|
| 437 |
+
"at_min": 1280
|
| 438 |
+
},
|
| 439 |
+
{
|
| 440 |
+
"fact_id": "F_sec1",
|
| 441 |
+
"statement": "He is secretly involved in illegal activities that he wants no one to know about, fearing the law will reveal too much about his reputation and business.",
|
| 442 |
+
"true_value": true,
|
| 443 |
+
"loc_id": null,
|
| 444 |
+
"at_min": null
|
| 445 |
+
},
|
| 446 |
+
{
|
| 447 |
+
"fact_id": "F_sec2",
|
| 448 |
+
"statement": "She secretly loved Isabella deeply, but chose to be faithful to Charles for his wealth. This secret is hidden in the attic of her childhood home.",
|
| 449 |
+
"true_value": true,
|
| 450 |
+
"loc_id": null,
|
| 451 |
+
"at_min": null
|
| 452 |
+
},
|
| 453 |
+
{
|
| 454 |
+
"fact_id": "F_sec3",
|
| 455 |
+
"statement": "He secretly owned a large corporation that was funding the illegal activities Charles financed, hoping to eliminate competition through deceitful means.",
|
| 456 |
+
"true_value": true,
|
| 457 |
+
"loc_id": null,
|
| 458 |
+
"at_min": null
|
| 459 |
+
},
|
| 460 |
+
{
|
| 461 |
+
"fact_id": "F_sec4",
|
| 462 |
+
"statement": "She was secretly part of an organization that had been spying on her employer for years, leading to the death of several wealthy women they were investigating. This knowledge kept hidden from her due to fear and lack of evidence against herself.",
|
| 463 |
+
"true_value": true,
|
| 464 |
+
"loc_id": null,
|
| 465 |
+
"at_min": null
|
| 466 |
+
}
|
| 467 |
+
],
|
| 468 |
+
"clues": [
|
| 469 |
+
{
|
| 470 |
+
"clue_id": "C_b1",
|
| 471 |
+
"name": "Dropped cufflink",
|
| 472 |
+
"reveal_text": "A monogrammed cufflink wedged under the rug, dropped in haste.",
|
| 473 |
+
"discoverable_at_loc_id": "L3",
|
| 474 |
+
"discovery_method": "forensic",
|
| 475 |
+
"supports_fact_id": "F_scene",
|
| 476 |
+
"points_to_sus_id": "S1",
|
| 477 |
+
"contradicts_alibi_of": "S1",
|
| 478 |
+
"is_red_herring": false,
|
| 479 |
+
"weight": 1.0
|
| 480 |
+
},
|
| 481 |
+
{
|
| 482 |
+
"clue_id": "C_b2",
|
| 483 |
+
"name": "Partial fingerprint",
|
| 484 |
+
"reveal_text": "A fresh partial fingerprint on the silent dagger, unaccounted for among the guests.",
|
| 485 |
+
"discoverable_at_loc_id": "L1",
|
| 486 |
+
"discovery_method": "forensic",
|
| 487 |
+
"supports_fact_id": "F_scene",
|
| 488 |
+
"points_to_sus_id": "S1",
|
| 489 |
+
"contradicts_alibi_of": "S1",
|
| 490 |
+
"is_red_herring": false,
|
| 491 |
+
"weight": 0.7
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
"clue_id": "C_h2",
|
| 495 |
+
"name": "A small photograph of Isabella, aged 16",
|
| 496 |
+
"reveal_text": "The photo has been replaced with a fake, and the original lies in her late mother's attic, containing love letters from both Sophia and Charles.",
|
| 497 |
+
"discoverable_at_loc_id": "L2",
|
| 498 |
+
"discovery_method": "search",
|
| 499 |
+
"supports_fact_id": "F_sec2",
|
| 500 |
+
"points_to_sus_id": "S2",
|
| 501 |
+
"contradicts_alibi_of": null,
|
| 502 |
+
"is_red_herring": true,
|
| 503 |
+
"weight": 0.3
|
| 504 |
+
},
|
| 505 |
+
{
|
| 506 |
+
"clue_id": "C_h3",
|
| 507 |
+
"name": "Old photograph",
|
| 508 |
+
"reveal_text": "A worn photograph they would rather no one had seen.",
|
| 509 |
+
"discoverable_at_loc_id": "L4",
|
| 510 |
+
"discovery_method": "search",
|
| 511 |
+
"supports_fact_id": "F_sec3",
|
| 512 |
+
"points_to_sus_id": "S3",
|
| 513 |
+
"contradicts_alibi_of": null,
|
| 514 |
+
"is_red_herring": true,
|
| 515 |
+
"weight": 0.3
|
| 516 |
+
},
|
| 517 |
+
{
|
| 518 |
+
"clue_id": "C_h4",
|
| 519 |
+
"name": "A small keychain with no initials but containing information about an organization's codes",
|
| 520 |
+
"reveal_text": "The keychain had been left behind when another servant tried to take the code for a secret meeting of the group, leading Elizabeth to use her position to access the information.",
|
| 521 |
+
"discoverable_at_loc_id": "L1",
|
| 522 |
+
"discovery_method": "search",
|
| 523 |
+
"supports_fact_id": "F_sec4",
|
| 524 |
+
"points_to_sus_id": "S4",
|
| 525 |
+
"contradicts_alibi_of": null,
|
| 526 |
+
"is_red_herring": true,
|
| 527 |
+
"weight": 0.3
|
| 528 |
+
}
|
| 529 |
+
],
|
| 530 |
+
"solution": {
|
| 531 |
+
"culprit_sus_id": "S1",
|
| 532 |
+
"weapon_id": "W1",
|
| 533 |
+
"motive_id": "M1",
|
| 534 |
+
"minimal_clue_set": [
|
| 535 |
+
"C_b1"
|
| 536 |
+
],
|
| 537 |
+
"deduction_chain": [
|
| 538 |
+
"Rivington planted the Silent Dagger through the dining room window on the night of the murder to cover his tracks.",
|
| 539 |
+
"The glass dropped by Rivington during the crime can be traced back to the dining room, indicating he was still there when Isabella was murdered.",
|
| 540 |
+
"The torn fiber from the carpet near the dining room window is consistent with him being in the Study at that time, supporting the false claim of his alibi."
|
| 541 |
+
]
|
| 542 |
+
}
|
| 543 |
+
}
|
cases/prebaked/CASE-0002.json
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "CASE-0002",
|
| 3 |
+
"seed": 43015,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Biblioteca Romantica Verdict",
|
| 6 |
+
"briefing": "A mysterious murder occurred in the dimly lit halls of the Whispering Library, where shadows dance on the walls and secrets linger.",
|
| 7 |
+
"knobs": {
|
| 8 |
+
"setting_hint": "",
|
| 9 |
+
"era_hint": "",
|
| 10 |
+
"tone_hint": "",
|
| 11 |
+
"n_suspects": 4,
|
| 12 |
+
"n_red_herrings": 2,
|
| 13 |
+
"alibi_tightness": 0.6,
|
| 14 |
+
"difficulty": "standard"
|
| 15 |
+
},
|
| 16 |
+
"setting": {
|
| 17 |
+
"name": "Whispering Library",
|
| 18 |
+
"description": "",
|
| 19 |
+
"locations": [
|
| 20 |
+
{
|
| 21 |
+
"loc_id": "L1",
|
| 22 |
+
"name": "Entrance Hall",
|
| 23 |
+
"description": "",
|
| 24 |
+
"adjacent_to": [
|
| 25 |
+
"L2",
|
| 26 |
+
"L3",
|
| 27 |
+
"L4"
|
| 28 |
+
]
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"loc_id": "L2",
|
| 32 |
+
"name": "Mystic Room",
|
| 33 |
+
"description": "",
|
| 34 |
+
"adjacent_to": [
|
| 35 |
+
"L1"
|
| 36 |
+
]
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"loc_id": "L3",
|
| 40 |
+
"name": "Biblioteca Romantica",
|
| 41 |
+
"description": "",
|
| 42 |
+
"adjacent_to": [
|
| 43 |
+
"L1"
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"loc_id": "L4",
|
| 48 |
+
"name": "Coffeeshop",
|
| 49 |
+
"description": "",
|
| 50 |
+
"adjacent_to": [
|
| 51 |
+
"L1"
|
| 52 |
+
]
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
"murder_window": {
|
| 56 |
+
"start_min": 1260,
|
| 57 |
+
"end_min": 1320
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
"victim": {
|
| 61 |
+
"vic_id": "V1",
|
| 62 |
+
"name": "Isabella Whitmore",
|
| 63 |
+
"role": "Author and Writer",
|
| 64 |
+
"found_at_loc_id": "L3",
|
| 65 |
+
"found_at_min": 1325,
|
| 66 |
+
"cause_of_death": "Poisoned with an ancient elixir from the library's oldest collection",
|
| 67 |
+
"time_of_death": {
|
| 68 |
+
"start_min": 1280,
|
| 69 |
+
"end_min": 1310
|
| 70 |
+
}
|
| 71 |
+
},
|
| 72 |
+
"weapon": {
|
| 73 |
+
"weapon_id": "W1",
|
| 74 |
+
"name": "Engraved Dagger",
|
| 75 |
+
"kind": "Silver",
|
| 76 |
+
"origin_loc_id": "L3",
|
| 77 |
+
"requires_strength": false,
|
| 78 |
+
"leaves_trace": ""
|
| 79 |
+
},
|
| 80 |
+
"suspects": [
|
| 81 |
+
{
|
| 82 |
+
"sus_id": "S1",
|
| 83 |
+
"name": "Thomas Reed",
|
| 84 |
+
"role": "Rival Writer",
|
| 85 |
+
"persona_summary": "A calculating and ambitious rival, Thomas always seems to find ways to undermine Isabella's work.",
|
| 86 |
+
"demeanour": "visibly frightened and on edge, dreading every question",
|
| 87 |
+
"is_culprit": false,
|
| 88 |
+
"physical_capability": {
|
| 89 |
+
"strength": true,
|
| 90 |
+
"mobility": true
|
| 91 |
+
},
|
| 92 |
+
"personality": {
|
| 93 |
+
"composure": 0.2,
|
| 94 |
+
"aggression": 0.3,
|
| 95 |
+
"evasiveness": 0.7
|
| 96 |
+
},
|
| 97 |
+
"tells": [
|
| 98 |
+
"The dagger was given to me on my mother’s request, but now I regret not giving it to her."
|
| 99 |
+
],
|
| 100 |
+
"knows_facts": [
|
| 101 |
+
"F_sec1"
|
| 102 |
+
],
|
| 103 |
+
"secrets": [
|
| 104 |
+
"He was secretly developing a similar poison for personal vendetta against Isabella."
|
| 105 |
+
],
|
| 106 |
+
"true_whereabouts": [
|
| 107 |
+
{
|
| 108 |
+
"window": {
|
| 109 |
+
"start_min": 1260,
|
| 110 |
+
"end_min": 1320
|
| 111 |
+
},
|
| 112 |
+
"loc_id": "L1",
|
| 113 |
+
"activity": "going about the evening",
|
| 114 |
+
"co_present_sus_ids": []
|
| 115 |
+
}
|
| 116 |
+
],
|
| 117 |
+
"stated_alibi": {
|
| 118 |
+
"claim_text": "I was in Entrance Hall the whole time.",
|
| 119 |
+
"claimed_segments": [
|
| 120 |
+
{
|
| 121 |
+
"window": {
|
| 122 |
+
"start_min": 1260,
|
| 123 |
+
"end_min": 1320
|
| 124 |
+
},
|
| 125 |
+
"loc_id": "L1",
|
| 126 |
+
"witness_sus_ids": []
|
| 127 |
+
}
|
| 128 |
+
]
|
| 129 |
+
},
|
| 130 |
+
"must_lie_about": [
|
| 131 |
+
"F_sec1"
|
| 132 |
+
],
|
| 133 |
+
"anchored_lies": [
|
| 134 |
+
{
|
| 135 |
+
"lie_id": "LIE_sec1",
|
| 136 |
+
"topic": "He was secretly developing a similar poison for ",
|
| 137 |
+
"claimed": "This dagger is a gift from his mother, honoring her late husband, who inspired the poem he wrote about. He regrets not giving it to his wife but sees this as compensation.",
|
| 138 |
+
"truth_ref": "F_sec1",
|
| 139 |
+
"breaks_on": [
|
| 140 |
+
"C_h1"
|
| 141 |
+
],
|
| 142 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 143 |
+
}
|
| 144 |
+
],
|
| 145 |
+
"voice": null,
|
| 146 |
+
"visual": {
|
| 147 |
+
"subject_type": "suspect",
|
| 148 |
+
"palette": "noir",
|
| 149 |
+
"gender": "male",
|
| 150 |
+
"age_band": "50s",
|
| 151 |
+
"build": null,
|
| 152 |
+
"hair": null,
|
| 153 |
+
"attire": "Silk robes with a silver brooch depicting his wife and her husband's urn. He has a stoic look that hides a deep sorrow.",
|
| 154 |
+
"mood": "tense",
|
| 155 |
+
"accent_color": "#b8860b",
|
| 156 |
+
"location_tags": [],
|
| 157 |
+
"prop_tags": [],
|
| 158 |
+
"prompt_hint": "Steady, bookish, 40s, dressed in dark dress robes., Silk robes with a silver brooch depicting his wife and her husband's urn. He has a stoic look that hides a deep sorrow."
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
{
|
| 162 |
+
"sus_id": "S2",
|
| 163 |
+
"name": "Kendall Black",
|
| 164 |
+
"role": "Rival's Rival",
|
| 165 |
+
"persona_summary": "A quiet and level-headed sibling who sees Thomas as a threat.",
|
| 166 |
+
"demeanour": "hostile and defensive, bristling at any hint of suspicion",
|
| 167 |
+
"is_culprit": false,
|
| 168 |
+
"physical_capability": {
|
| 169 |
+
"strength": true,
|
| 170 |
+
"mobility": true
|
| 171 |
+
},
|
| 172 |
+
"personality": {
|
| 173 |
+
"composure": 0.55,
|
| 174 |
+
"aggression": 0.88,
|
| 175 |
+
"evasiveness": 0.4
|
| 176 |
+
},
|
| 177 |
+
"tells": [
|
| 178 |
+
"I have no secrets, just a love for my late father’s poetry."
|
| 179 |
+
],
|
| 180 |
+
"knows_facts": [
|
| 181 |
+
"F_sec2"
|
| 182 |
+
],
|
| 183 |
+
"secrets": [
|
| 184 |
+
"She was secretly working on the elixir together with Thomas for personal interest in poetry."
|
| 185 |
+
],
|
| 186 |
+
"true_whereabouts": [
|
| 187 |
+
{
|
| 188 |
+
"window": {
|
| 189 |
+
"start_min": 1260,
|
| 190 |
+
"end_min": 1320
|
| 191 |
+
},
|
| 192 |
+
"loc_id": "L2",
|
| 193 |
+
"activity": "going about the evening",
|
| 194 |
+
"co_present_sus_ids": []
|
| 195 |
+
}
|
| 196 |
+
],
|
| 197 |
+
"stated_alibi": {
|
| 198 |
+
"claim_text": "I was in Mystic Room the whole time.",
|
| 199 |
+
"claimed_segments": [
|
| 200 |
+
{
|
| 201 |
+
"window": {
|
| 202 |
+
"start_min": 1260,
|
| 203 |
+
"end_min": 1320
|
| 204 |
+
},
|
| 205 |
+
"loc_id": "L2",
|
| 206 |
+
"witness_sus_ids": []
|
| 207 |
+
}
|
| 208 |
+
]
|
| 209 |
+
},
|
| 210 |
+
"must_lie_about": [
|
| 211 |
+
"F_sec2"
|
| 212 |
+
],
|
| 213 |
+
"anchored_lies": [
|
| 214 |
+
{
|
| 215 |
+
"lie_id": "LIE_sec2",
|
| 216 |
+
"topic": "She was secretly working on the elixir together ",
|
| 217 |
+
"claimed": "Her cover story is to show respect, but her passion for poetry is evident from her bookish appearance and love of rare books.",
|
| 218 |
+
"truth_ref": "F_sec2",
|
| 219 |
+
"breaks_on": [
|
| 220 |
+
"C_h2"
|
| 221 |
+
],
|
| 222 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 223 |
+
}
|
| 224 |
+
],
|
| 225 |
+
"voice": null,
|
| 226 |
+
"visual": {
|
| 227 |
+
"subject_type": "suspect",
|
| 228 |
+
"palette": "noir",
|
| 229 |
+
"gender": "female",
|
| 230 |
+
"age_band": "20s",
|
| 231 |
+
"build": null,
|
| 232 |
+
"hair": null,
|
| 233 |
+
"attire": "A vintage dress and a soft leather coat that fits her slender figure.",
|
| 234 |
+
"mood": "tense",
|
| 235 |
+
"accent_color": "#3a6ea5",
|
| 236 |
+
"location_tags": [],
|
| 237 |
+
"prop_tags": [],
|
| 238 |
+
"prompt_hint": "Flaxen-haired, wearing simple attire as she carries an old book with her., A vintage dress and a soft leather coat that fits her slender figure."
|
| 239 |
+
}
|
| 240 |
+
},
|
| 241 |
+
{
|
| 242 |
+
"sus_id": "S3",
|
| 243 |
+
"name": "Henry Mason",
|
| 244 |
+
"role": "Business Partner",
|
| 245 |
+
"persona_summary": "A pragmatic businessman who sees the library as a place of knowledge and learning.",
|
| 246 |
+
"demeanour": "composed and cooperative on the surface, carefully measured",
|
| 247 |
+
"is_culprit": false,
|
| 248 |
+
"physical_capability": {
|
| 249 |
+
"strength": true,
|
| 250 |
+
"mobility": true
|
| 251 |
+
},
|
| 252 |
+
"personality": {
|
| 253 |
+
"composure": 0.72,
|
| 254 |
+
"aggression": 0.4,
|
| 255 |
+
"evasiveness": 0.3
|
| 256 |
+
},
|
| 257 |
+
"tells": [
|
| 258 |
+
"I don’t see any reason to worry, my name is listed as the borrower in this contract."
|
| 259 |
+
],
|
| 260 |
+
"knows_facts": [
|
| 261 |
+
"F_sec3"
|
| 262 |
+
],
|
| 263 |
+
"secrets": [
|
| 264 |
+
"He was involved in Isabella's manuscript with an ulterior motive, attempting to find loopholes to cheat her financially."
|
| 265 |
+
],
|
| 266 |
+
"true_whereabouts": [
|
| 267 |
+
{
|
| 268 |
+
"window": {
|
| 269 |
+
"start_min": 1260,
|
| 270 |
+
"end_min": 1320
|
| 271 |
+
},
|
| 272 |
+
"loc_id": "L4",
|
| 273 |
+
"activity": "going about the evening",
|
| 274 |
+
"co_present_sus_ids": []
|
| 275 |
+
}
|
| 276 |
+
],
|
| 277 |
+
"stated_alibi": {
|
| 278 |
+
"claim_text": "I was in Coffeeshop the whole time.",
|
| 279 |
+
"claimed_segments": [
|
| 280 |
+
{
|
| 281 |
+
"window": {
|
| 282 |
+
"start_min": 1260,
|
| 283 |
+
"end_min": 1320
|
| 284 |
+
},
|
| 285 |
+
"loc_id": "L4",
|
| 286 |
+
"witness_sus_ids": []
|
| 287 |
+
}
|
| 288 |
+
]
|
| 289 |
+
},
|
| 290 |
+
"must_lie_about": [
|
| 291 |
+
"F_sec3"
|
| 292 |
+
],
|
| 293 |
+
"anchored_lies": [
|
| 294 |
+
{
|
| 295 |
+
"lie_id": "LIE_sec3",
|
| 296 |
+
"topic": "He was involved in Isabella's manuscript with an",
|
| 297 |
+
"claimed": "His cover story is about his interest in literature and how he respects her work, but there’s always a chance he may have made mistakes.",
|
| 298 |
+
"truth_ref": "F_sec3",
|
| 299 |
+
"breaks_on": [
|
| 300 |
+
"C_h3"
|
| 301 |
+
],
|
| 302 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 303 |
+
}
|
| 304 |
+
],
|
| 305 |
+
"voice": null,
|
| 306 |
+
"visual": {
|
| 307 |
+
"subject_type": "suspect",
|
| 308 |
+
"palette": "noir",
|
| 309 |
+
"gender": "male",
|
| 310 |
+
"age_band": "50s",
|
| 311 |
+
"build": null,
|
| 312 |
+
"hair": null,
|
| 313 |
+
"attire": "Business attire with a pocket watch on his belt.",
|
| 314 |
+
"mood": "tense",
|
| 315 |
+
"accent_color": "#9a9aa0",
|
| 316 |
+
"location_tags": [],
|
| 317 |
+
"prop_tags": [],
|
| 318 |
+
"prompt_hint": "Business suit and tie, looking stern but slightly worried., Business attire with a pocket watch on his belt."
|
| 319 |
+
}
|
| 320 |
+
},
|
| 321 |
+
{
|
| 322 |
+
"sus_id": "S4",
|
| 323 |
+
"name": "Emily Thompson",
|
| 324 |
+
"role": "Friend",
|
| 325 |
+
"persona_summary": "A bright and passionate friend who often volunteers her time at the library.",
|
| 326 |
+
"demeanour": "rattled and evasive, voice tightening under pressure",
|
| 327 |
+
"is_culprit": true,
|
| 328 |
+
"physical_capability": {
|
| 329 |
+
"strength": true,
|
| 330 |
+
"mobility": true
|
| 331 |
+
},
|
| 332 |
+
"personality": {
|
| 333 |
+
"composure": 0.32,
|
| 334 |
+
"aggression": 0.55,
|
| 335 |
+
"evasiveness": 0.68
|
| 336 |
+
},
|
| 337 |
+
"tells": [
|
| 338 |
+
"I just thought the book was old, Emily."
|
| 339 |
+
],
|
| 340 |
+
"knows_facts": [
|
| 341 |
+
"F_scene",
|
| 342 |
+
"F_sec4"
|
| 343 |
+
],
|
| 344 |
+
"secrets": [
|
| 345 |
+
"She was secretly recording Isabella's every move to blackmail her later. She left a note on a bookshelf."
|
| 346 |
+
],
|
| 347 |
+
"true_whereabouts": [
|
| 348 |
+
{
|
| 349 |
+
"window": {
|
| 350 |
+
"start_min": 1260,
|
| 351 |
+
"end_min": 1280
|
| 352 |
+
},
|
| 353 |
+
"loc_id": "L4",
|
| 354 |
+
"activity": "mingling in plain sight",
|
| 355 |
+
"co_present_sus_ids": []
|
| 356 |
+
},
|
| 357 |
+
{
|
| 358 |
+
"window": {
|
| 359 |
+
"start_min": 1280,
|
| 360 |
+
"end_min": 1310
|
| 361 |
+
},
|
| 362 |
+
"loc_id": "L3",
|
| 363 |
+
"activity": "alone with the victim",
|
| 364 |
+
"co_present_sus_ids": []
|
| 365 |
+
}
|
| 366 |
+
],
|
| 367 |
+
"stated_alibi": {
|
| 368 |
+
"claim_text": "I was in the Coffeeshop during that time, as I am sure you can verify.",
|
| 369 |
+
"claimed_segments": [
|
| 370 |
+
{
|
| 371 |
+
"window": {
|
| 372 |
+
"start_min": 1260,
|
| 373 |
+
"end_min": 1320
|
| 374 |
+
},
|
| 375 |
+
"loc_id": "L4",
|
| 376 |
+
"witness_sus_ids": []
|
| 377 |
+
}
|
| 378 |
+
]
|
| 379 |
+
},
|
| 380 |
+
"must_lie_about": [
|
| 381 |
+
"F_scene"
|
| 382 |
+
],
|
| 383 |
+
"anchored_lies": [
|
| 384 |
+
{
|
| 385 |
+
"lie_id": "LIE_alibi",
|
| 386 |
+
"topic": "where you were during the murder",
|
| 387 |
+
"claimed": "I was in the Coffeeshop during that time, as I am sure you can verify.",
|
| 388 |
+
"truth_ref": "F_scene",
|
| 389 |
+
"breaks_on": [
|
| 390 |
+
"C_b1",
|
| 391 |
+
"C_b2"
|
| 392 |
+
],
|
| 393 |
+
"fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
|
| 394 |
+
}
|
| 395 |
+
],
|
| 396 |
+
"voice": null,
|
| 397 |
+
"visual": {
|
| 398 |
+
"subject_type": "suspect",
|
| 399 |
+
"palette": "noir",
|
| 400 |
+
"gender": "female",
|
| 401 |
+
"age_band": "25s",
|
| 402 |
+
"build": null,
|
| 403 |
+
"hair": null,
|
| 404 |
+
"attire": "A simple dress and a handbag that’s always full of notes.",
|
| 405 |
+
"mood": "tense",
|
| 406 |
+
"accent_color": "#6b8f71",
|
| 407 |
+
"location_tags": [],
|
| 408 |
+
"prop_tags": [],
|
| 409 |
+
"prompt_hint": "Bookish, with long hair tied back. She’s in her 30s and wears stylish yet slightly disheveled attire., A simple dress and a handbag that’s always full of notes."
|
| 410 |
+
}
|
| 411 |
+
}
|
| 412 |
+
],
|
| 413 |
+
"culprit": {
|
| 414 |
+
"sus_id": "S4",
|
| 415 |
+
"true_motive": {
|
| 416 |
+
"motive_id": "M1",
|
| 417 |
+
"category": "revenge",
|
| 418 |
+
"summary": "Emily Thompson killed Isabella Whitmore to exact revenge over a past betrayal."
|
| 419 |
+
},
|
| 420 |
+
"method_narrative": "The killer, Emily Thompson, murdered Isabella Whitmore at the Biblioteca Romantica between 21:00 and 22:00 using an engraved dagger. They then claimed they never left the Coffeeshop to cover their tracks.",
|
| 421 |
+
"alibi_lie": {
|
| 422 |
+
"claimed_loc_id": "L4",
|
| 423 |
+
"actual_loc_id": "L3",
|
| 424 |
+
"contradicted_by_clue_ids": [
|
| 425 |
+
"C_b1",
|
| 426 |
+
"C_b2"
|
| 427 |
+
]
|
| 428 |
+
}
|
| 429 |
+
},
|
| 430 |
+
"relationships": [],
|
| 431 |
+
"facts": [
|
| 432 |
+
{
|
| 433 |
+
"fact_id": "F_scene",
|
| 434 |
+
"statement": "Emily Thompson was in Biblioteca Romantica during the murder.",
|
| 435 |
+
"true_value": true,
|
| 436 |
+
"loc_id": "L3",
|
| 437 |
+
"at_min": 1280
|
| 438 |
+
},
|
| 439 |
+
{
|
| 440 |
+
"fact_id": "F_sec1",
|
| 441 |
+
"statement": "He was secretly developing a similar poison for personal vendetta against Isabella.",
|
| 442 |
+
"true_value": true,
|
| 443 |
+
"loc_id": null,
|
| 444 |
+
"at_min": null
|
| 445 |
+
},
|
| 446 |
+
{
|
| 447 |
+
"fact_id": "F_sec2",
|
| 448 |
+
"statement": "She was secretly working on the elixir together with Thomas for personal interest in poetry.",
|
| 449 |
+
"true_value": true,
|
| 450 |
+
"loc_id": null,
|
| 451 |
+
"at_min": null
|
| 452 |
+
},
|
| 453 |
+
{
|
| 454 |
+
"fact_id": "F_sec3",
|
| 455 |
+
"statement": "He was involved in Isabella's manuscript with an ulterior motive, attempting to find loopholes to cheat her financially.",
|
| 456 |
+
"true_value": true,
|
| 457 |
+
"loc_id": null,
|
| 458 |
+
"at_min": null
|
| 459 |
+
},
|
| 460 |
+
{
|
| 461 |
+
"fact_id": "F_sec4",
|
| 462 |
+
"statement": "She was secretly recording Isabella's every move to blackmail her later. She left a note on a bookshelf.",
|
| 463 |
+
"true_value": true,
|
| 464 |
+
"loc_id": null,
|
| 465 |
+
"at_min": null
|
| 466 |
+
}
|
| 467 |
+
],
|
| 468 |
+
"clues": [
|
| 469 |
+
{
|
| 470 |
+
"clue_id": "C_b1",
|
| 471 |
+
"name": "Stopped clock",
|
| 472 |
+
"reveal_text": "A mantel clock knocked still at the very minute of death.",
|
| 473 |
+
"discoverable_at_loc_id": "L3",
|
| 474 |
+
"discovery_method": "forensic",
|
| 475 |
+
"supports_fact_id": "F_scene",
|
| 476 |
+
"points_to_sus_id": "S4",
|
| 477 |
+
"contradicts_alibi_of": "S4",
|
| 478 |
+
"is_red_herring": false,
|
| 479 |
+
"weight": 1.0
|
| 480 |
+
},
|
| 481 |
+
{
|
| 482 |
+
"clue_id": "C_b2",
|
| 483 |
+
"name": "Smudged tumbler",
|
| 484 |
+
"reveal_text": "A tumbler left where the body fell, its rim marked with a recent lip-print.",
|
| 485 |
+
"discoverable_at_loc_id": "L1",
|
| 486 |
+
"discovery_method": "forensic",
|
| 487 |
+
"supports_fact_id": "F_scene",
|
| 488 |
+
"points_to_sus_id": "S4",
|
| 489 |
+
"contradicts_alibi_of": "S4",
|
| 490 |
+
"is_red_herring": false,
|
| 491 |
+
"weight": 0.7
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
"clue_id": "C_h1",
|
| 495 |
+
"name": "Poison Bottle",
|
| 496 |
+
"reveal_text": "A letter detailing plans for both poison and Isabella's manuscript underlined with 'Favor.'",
|
| 497 |
+
"discoverable_at_loc_id": "L2",
|
| 498 |
+
"discovery_method": "search",
|
| 499 |
+
"supports_fact_id": "F_sec1",
|
| 500 |
+
"points_to_sus_id": "S1",
|
| 501 |
+
"contradicts_alibi_of": null,
|
| 502 |
+
"is_red_herring": true,
|
| 503 |
+
"weight": 0.3
|
| 504 |
+
},
|
| 505 |
+
{
|
| 506 |
+
"clue_id": "C_h2",
|
| 507 |
+
"name": "Aunt's Book",
|
| 508 |
+
"reveal_text": "The receipt from a rare manuscript by the poet who inspired Thomas’s poem. It was found in Isabella’s room.",
|
| 509 |
+
"discoverable_at_loc_id": "L4",
|
| 510 |
+
"discovery_method": "search",
|
| 511 |
+
"supports_fact_id": "F_sec2",
|
| 512 |
+
"points_to_sus_id": "S2",
|
| 513 |
+
"contradicts_alibi_of": null,
|
| 514 |
+
"is_red_herring": true,
|
| 515 |
+
"weight": 0.3
|
| 516 |
+
},
|
| 517 |
+
{
|
| 518 |
+
"clue_id": "C_h3",
|
| 519 |
+
"name": "Loan Agreement",
|
| 520 |
+
"reveal_text": "A copy of the loan agreement that shows Henry’s involvement with Isabella's manuscript under his name.",
|
| 521 |
+
"discoverable_at_loc_id": "L1",
|
| 522 |
+
"discovery_method": "search",
|
| 523 |
+
"supports_fact_id": "F_sec3",
|
| 524 |
+
"points_to_sus_id": "S3",
|
| 525 |
+
"contradicts_alibi_of": null,
|
| 526 |
+
"is_red_herring": true,
|
| 527 |
+
"weight": 0.3
|
| 528 |
+
}
|
| 529 |
+
],
|
| 530 |
+
"solution": {
|
| 531 |
+
"culprit_sus_id": "S4",
|
| 532 |
+
"weapon_id": "W1",
|
| 533 |
+
"motive_id": "M1",
|
| 534 |
+
"minimal_clue_set": [
|
| 535 |
+
"C_b1"
|
| 536 |
+
],
|
| 537 |
+
"deduction_chain": [
|
| 538 |
+
"The alibi claim suggests Emily Thompson's motive could be related to a past betrayal or misunderstanding that led her to seek revenge. The fact she was in her own home and not at the Biblioteca Romantica is intriguing, as it implies the crime might have been premeditated.",
|
| 539 |
+
"The broken droplet of ink on one of the bookshelves suggests someone had their pen placed down and walked away before the murder took place. This indicates a timeframe consistent with Emily Thompson being in the library.",
|
| 540 |
+
"The torn fiber from the carpet near the door could be linked to the fact that there were no footprints found outside, which is another piece of evidence pointing to Emily Thompson's alibi. The presence of this fiber suggests she was present when Isabella Whitmore entered and left the Biblioteca."
|
| 541 |
+
]
|
| 542 |
+
}
|
| 543 |
+
}
|
cases/prebaked/CASE-0003.json
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "CASE-0003",
|
| 3 |
+
"seed": 43016,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Mistletoe Kiss",
|
| 6 |
+
"briefing": "A cold morning in the English countryside, a small town where secrets are as common as the mist.",
|
| 7 |
+
"knobs": {
|
| 8 |
+
"setting_hint": "",
|
| 9 |
+
"era_hint": "",
|
| 10 |
+
"tone_hint": "",
|
| 11 |
+
"n_suspects": 4,
|
| 12 |
+
"n_red_herrings": 2,
|
| 13 |
+
"alibi_tightness": 0.6,
|
| 14 |
+
"difficulty": "standard"
|
| 15 |
+
},
|
| 16 |
+
"setting": {
|
| 17 |
+
"name": "Roches Bleues Manor",
|
| 18 |
+
"description": "",
|
| 19 |
+
"locations": [
|
| 20 |
+
{
|
| 21 |
+
"loc_id": "L1",
|
| 22 |
+
"name": "Entrance Hall",
|
| 23 |
+
"description": "",
|
| 24 |
+
"adjacent_to": [
|
| 25 |
+
"L2",
|
| 26 |
+
"L3",
|
| 27 |
+
"L4"
|
| 28 |
+
]
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"loc_id": "L2",
|
| 32 |
+
"name": "Kitchen",
|
| 33 |
+
"description": "",
|
| 34 |
+
"adjacent_to": [
|
| 35 |
+
"L1"
|
| 36 |
+
]
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"loc_id": "L3",
|
| 40 |
+
"name": "Library",
|
| 41 |
+
"description": "",
|
| 42 |
+
"adjacent_to": [
|
| 43 |
+
"L1"
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"loc_id": "L4",
|
| 48 |
+
"name": "Bedroom",
|
| 49 |
+
"description": "",
|
| 50 |
+
"adjacent_to": [
|
| 51 |
+
"L1"
|
| 52 |
+
]
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
"murder_window": {
|
| 56 |
+
"start_min": 1260,
|
| 57 |
+
"end_min": 1320
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
"victim": {
|
| 61 |
+
"vic_id": "V1",
|
| 62 |
+
"name": "Eleanor Blakeley",
|
| 63 |
+
"role": "Dormant Housekeeper, 50s",
|
| 64 |
+
"found_at_loc_id": "L3",
|
| 65 |
+
"found_at_min": 1325,
|
| 66 |
+
"cause_of_death": "Strangled with a ribbon around the neck in the library while asleep.",
|
| 67 |
+
"time_of_death": {
|
| 68 |
+
"start_min": 1280,
|
| 69 |
+
"end_min": 1310
|
| 70 |
+
}
|
| 71 |
+
},
|
| 72 |
+
"weapon": {
|
| 73 |
+
"weapon_id": "W1",
|
| 74 |
+
"name": "Ribbon",
|
| 75 |
+
"kind": "Natural",
|
| 76 |
+
"origin_loc_id": "L3",
|
| 77 |
+
"requires_strength": false,
|
| 78 |
+
"leaves_trace": ""
|
| 79 |
+
},
|
| 80 |
+
"suspects": [
|
| 81 |
+
{
|
| 82 |
+
"sus_id": "S1",
|
| 83 |
+
"name": "Arthur Thornwell",
|
| 84 |
+
"role": "Lover, 30s",
|
| 85 |
+
"persona_summary": "The charming and romantic Arthur is often mistaken for the man of the house due to his impeccable taste.",
|
| 86 |
+
"demeanour": "hostile and defensive, bristling at any hint of suspicion",
|
| 87 |
+
"is_culprit": true,
|
| 88 |
+
"physical_capability": {
|
| 89 |
+
"strength": true,
|
| 90 |
+
"mobility": true
|
| 91 |
+
},
|
| 92 |
+
"personality": {
|
| 93 |
+
"composure": 0.55,
|
| 94 |
+
"aggression": 0.88,
|
| 95 |
+
"evasiveness": 0.4
|
| 96 |
+
},
|
| 97 |
+
"tells": [
|
| 98 |
+
"I did everything I could to protect her from the dangers of this job."
|
| 99 |
+
],
|
| 100 |
+
"knows_facts": [
|
| 101 |
+
"F_scene",
|
| 102 |
+
"F_sec1"
|
| 103 |
+
],
|
| 104 |
+
"secrets": [
|
| 105 |
+
"A long-standing secret between him and Eleanor regarding her past as a prisoner before joining the staff."
|
| 106 |
+
],
|
| 107 |
+
"true_whereabouts": [
|
| 108 |
+
{
|
| 109 |
+
"window": {
|
| 110 |
+
"start_min": 1260,
|
| 111 |
+
"end_min": 1280
|
| 112 |
+
},
|
| 113 |
+
"loc_id": "L4",
|
| 114 |
+
"activity": "mingling in plain sight",
|
| 115 |
+
"co_present_sus_ids": []
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
"window": {
|
| 119 |
+
"start_min": 1280,
|
| 120 |
+
"end_min": 1310
|
| 121 |
+
},
|
| 122 |
+
"loc_id": "L3",
|
| 123 |
+
"activity": "alone with the victim",
|
| 124 |
+
"co_present_sus_ids": []
|
| 125 |
+
}
|
| 126 |
+
],
|
| 127 |
+
"stated_alibi": {
|
| 128 |
+
"claim_text": "I was in the Bedroom all night long; I never left it.",
|
| 129 |
+
"claimed_segments": [
|
| 130 |
+
{
|
| 131 |
+
"window": {
|
| 132 |
+
"start_min": 1260,
|
| 133 |
+
"end_min": 1320
|
| 134 |
+
},
|
| 135 |
+
"loc_id": "L4",
|
| 136 |
+
"witness_sus_ids": []
|
| 137 |
+
}
|
| 138 |
+
]
|
| 139 |
+
},
|
| 140 |
+
"must_lie_about": [
|
| 141 |
+
"F_scene"
|
| 142 |
+
],
|
| 143 |
+
"anchored_lies": [
|
| 144 |
+
{
|
| 145 |
+
"lie_id": "LIE_alibi",
|
| 146 |
+
"topic": "where you were during the murder",
|
| 147 |
+
"claimed": "I was in the Bedroom all night long; I never left it.",
|
| 148 |
+
"truth_ref": "F_scene",
|
| 149 |
+
"breaks_on": [
|
| 150 |
+
"C_b1",
|
| 151 |
+
"C_b2"
|
| 152 |
+
],
|
| 153 |
+
"fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
|
| 154 |
+
}
|
| 155 |
+
],
|
| 156 |
+
"voice": null,
|
| 157 |
+
"visual": {
|
| 158 |
+
"subject_type": "suspect",
|
| 159 |
+
"palette": "noir",
|
| 160 |
+
"gender": "male",
|
| 161 |
+
"age_band": "30s",
|
| 162 |
+
"build": null,
|
| 163 |
+
"hair": null,
|
| 164 |
+
"attire": "Dapper suit, white shirt, silver jewelry",
|
| 165 |
+
"mood": "guarded",
|
| 166 |
+
"accent_color": "#b8860b",
|
| 167 |
+
"location_tags": [],
|
| 168 |
+
"prop_tags": [],
|
| 169 |
+
"prompt_hint": "Elegant but slightly intimidating due to his aristocratic demeanor and sharp wit., Dapper suit, white shirt, silver jewelry"
|
| 170 |
+
}
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
"sus_id": "S2",
|
| 174 |
+
"name": "Elizabeth 'Lizzy' Drake",
|
| 175 |
+
"role": "Rival, 40s",
|
| 176 |
+
"persona_summary": "Elizabeth is a relentless competitor who's always one step ahead. She saw Eleanor as an obstacle in their careers.",
|
| 177 |
+
"demeanour": "composed and cooperative on the surface, carefully measured",
|
| 178 |
+
"is_culprit": false,
|
| 179 |
+
"physical_capability": {
|
| 180 |
+
"strength": true,
|
| 181 |
+
"mobility": true
|
| 182 |
+
},
|
| 183 |
+
"personality": {
|
| 184 |
+
"composure": 0.72,
|
| 185 |
+
"aggression": 0.4,
|
| 186 |
+
"evasiveness": 0.3
|
| 187 |
+
},
|
| 188 |
+
"tells": [
|
| 189 |
+
"I can’t believe it was Lizzy who did this."
|
| 190 |
+
],
|
| 191 |
+
"knows_facts": [
|
| 192 |
+
"F_sec2"
|
| 193 |
+
],
|
| 194 |
+
"secrets": [
|
| 195 |
+
"Their rivalry has been simmering for years and now finally boils over with the murder."
|
| 196 |
+
],
|
| 197 |
+
"true_whereabouts": [
|
| 198 |
+
{
|
| 199 |
+
"window": {
|
| 200 |
+
"start_min": 1260,
|
| 201 |
+
"end_min": 1320
|
| 202 |
+
},
|
| 203 |
+
"loc_id": "L2",
|
| 204 |
+
"activity": "going about the evening",
|
| 205 |
+
"co_present_sus_ids": []
|
| 206 |
+
}
|
| 207 |
+
],
|
| 208 |
+
"stated_alibi": {
|
| 209 |
+
"claim_text": "I was in Kitchen the whole time.",
|
| 210 |
+
"claimed_segments": [
|
| 211 |
+
{
|
| 212 |
+
"window": {
|
| 213 |
+
"start_min": 1260,
|
| 214 |
+
"end_min": 1320
|
| 215 |
+
},
|
| 216 |
+
"loc_id": "L2",
|
| 217 |
+
"witness_sus_ids": []
|
| 218 |
+
}
|
| 219 |
+
]
|
| 220 |
+
},
|
| 221 |
+
"must_lie_about": [
|
| 222 |
+
"F_sec2"
|
| 223 |
+
],
|
| 224 |
+
"anchored_lies": [
|
| 225 |
+
{
|
| 226 |
+
"lie_id": "LIE_sec2",
|
| 227 |
+
"topic": "Their rivalry has been simmering for years and n",
|
| 228 |
+
"claimed": "Eleanor was jealous of Lizzy’s success, which led to her decision to strangle her on a dare.",
|
| 229 |
+
"truth_ref": "F_sec2",
|
| 230 |
+
"breaks_on": [
|
| 231 |
+
"C_h2"
|
| 232 |
+
],
|
| 233 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 234 |
+
}
|
| 235 |
+
],
|
| 236 |
+
"voice": null,
|
| 237 |
+
"visual": {
|
| 238 |
+
"subject_type": "suspect",
|
| 239 |
+
"palette": "noir",
|
| 240 |
+
"gender": "female",
|
| 241 |
+
"age_band": "40s",
|
| 242 |
+
"build": null,
|
| 243 |
+
"hair": null,
|
| 244 |
+
"attire": "Professional blazer, tailored skirt, heels",
|
| 245 |
+
"mood": "tense",
|
| 246 |
+
"accent_color": "#3a6ea5",
|
| 247 |
+
"location_tags": [],
|
| 248 |
+
"prop_tags": [],
|
| 249 |
+
"prompt_hint": "Businesswoman, smartly dressed in professional attire with a sharp edge in her eye., Professional blazer, tailored skirt, heels"
|
| 250 |
+
}
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"sus_id": "S3",
|
| 254 |
+
"name": "George 'Gee' Smith",
|
| 255 |
+
"role": "Business Partner, 50s",
|
| 256 |
+
"persona_summary": "Georgie is known for his strict rules and demanding business practices. He’s been trying to push Eleanor out of her position.",
|
| 257 |
+
"demeanour": "rattled and evasive, voice tightening under pressure",
|
| 258 |
+
"is_culprit": false,
|
| 259 |
+
"physical_capability": {
|
| 260 |
+
"strength": true,
|
| 261 |
+
"mobility": true
|
| 262 |
+
},
|
| 263 |
+
"personality": {
|
| 264 |
+
"composure": 0.32,
|
| 265 |
+
"aggression": 0.55,
|
| 266 |
+
"evasiveness": 0.68
|
| 267 |
+
},
|
| 268 |
+
"tells": [
|
| 269 |
+
"I’m too focused on work to think about this."
|
| 270 |
+
],
|
| 271 |
+
"knows_facts": [
|
| 272 |
+
"F_sec3"
|
| 273 |
+
],
|
| 274 |
+
"secrets": [
|
| 275 |
+
"Their partnership was more about power than mutual respect due to a falling out over an investment deal that went badly."
|
| 276 |
+
],
|
| 277 |
+
"true_whereabouts": [
|
| 278 |
+
{
|
| 279 |
+
"window": {
|
| 280 |
+
"start_min": 1260,
|
| 281 |
+
"end_min": 1320
|
| 282 |
+
},
|
| 283 |
+
"loc_id": "L4",
|
| 284 |
+
"activity": "going about the evening",
|
| 285 |
+
"co_present_sus_ids": []
|
| 286 |
+
}
|
| 287 |
+
],
|
| 288 |
+
"stated_alibi": {
|
| 289 |
+
"claim_text": "I was in Bedroom the whole time.",
|
| 290 |
+
"claimed_segments": [
|
| 291 |
+
{
|
| 292 |
+
"window": {
|
| 293 |
+
"start_min": 1260,
|
| 294 |
+
"end_min": 1320
|
| 295 |
+
},
|
| 296 |
+
"loc_id": "L4",
|
| 297 |
+
"witness_sus_ids": []
|
| 298 |
+
}
|
| 299 |
+
]
|
| 300 |
+
},
|
| 301 |
+
"must_lie_about": [
|
| 302 |
+
"F_sec3"
|
| 303 |
+
],
|
| 304 |
+
"anchored_lies": [
|
| 305 |
+
{
|
| 306 |
+
"lie_id": "LIE_sec3",
|
| 307 |
+
"topic": "Their partnership was more about power than mutu",
|
| 308 |
+
"claimed": "Eleanor wanted him to approve the project, but he never did, leading to their breakup and subsequent murder.",
|
| 309 |
+
"truth_ref": "F_sec3",
|
| 310 |
+
"breaks_on": [
|
| 311 |
+
"C_h3"
|
| 312 |
+
],
|
| 313 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 314 |
+
}
|
| 315 |
+
],
|
| 316 |
+
"voice": null,
|
| 317 |
+
"visual": {
|
| 318 |
+
"subject_type": "suspect",
|
| 319 |
+
"palette": "noir",
|
| 320 |
+
"gender": "male",
|
| 321 |
+
"age_band": "50s",
|
| 322 |
+
"build": null,
|
| 323 |
+
"hair": null,
|
| 324 |
+
"attire": "Business suit, polished shoes, pocket watch",
|
| 325 |
+
"mood": "guarded",
|
| 326 |
+
"accent_color": "#9a9aa0",
|
| 327 |
+
"location_tags": [],
|
| 328 |
+
"prop_tags": [],
|
| 329 |
+
"prompt_hint": "Professional, well-dressed in a suit jacket and tie with his typical professional demeanor., Business suit, polished shoes, pocket watch"
|
| 330 |
+
}
|
| 331 |
+
},
|
| 332 |
+
{
|
| 333 |
+
"sus_id": "S4",
|
| 334 |
+
"name": "William 'Billy' Jackson",
|
| 335 |
+
"role": "Rival's Lover, 45s",
|
| 336 |
+
"persona_summary": "Billy is known for his cunning ways and was always close to Eleanor due to their shared love of adventure. Now they’re both suspects.",
|
| 337 |
+
"demeanour": "guarded and weary, giving away as little as possible",
|
| 338 |
+
"is_culprit": false,
|
| 339 |
+
"physical_capability": {
|
| 340 |
+
"strength": true,
|
| 341 |
+
"mobility": true
|
| 342 |
+
},
|
| 343 |
+
"personality": {
|
| 344 |
+
"composure": 0.6,
|
| 345 |
+
"aggression": 0.25,
|
| 346 |
+
"evasiveness": 0.55
|
| 347 |
+
},
|
| 348 |
+
"tells": [
|
| 349 |
+
"I don’t remember being here."
|
| 350 |
+
],
|
| 351 |
+
"knows_facts": [
|
| 352 |
+
"F_sec4"
|
| 353 |
+
],
|
| 354 |
+
"secrets": [
|
| 355 |
+
"They have a past relationship that wasn’t officially documented but grew into something deeper than expected."
|
| 356 |
+
],
|
| 357 |
+
"true_whereabouts": [
|
| 358 |
+
{
|
| 359 |
+
"window": {
|
| 360 |
+
"start_min": 1260,
|
| 361 |
+
"end_min": 1320
|
| 362 |
+
},
|
| 363 |
+
"loc_id": "L1",
|
| 364 |
+
"activity": "going about the evening",
|
| 365 |
+
"co_present_sus_ids": []
|
| 366 |
+
}
|
| 367 |
+
],
|
| 368 |
+
"stated_alibi": {
|
| 369 |
+
"claim_text": "I was in Entrance Hall the whole time.",
|
| 370 |
+
"claimed_segments": [
|
| 371 |
+
{
|
| 372 |
+
"window": {
|
| 373 |
+
"start_min": 1260,
|
| 374 |
+
"end_min": 1320
|
| 375 |
+
},
|
| 376 |
+
"loc_id": "L1",
|
| 377 |
+
"witness_sus_ids": []
|
| 378 |
+
}
|
| 379 |
+
]
|
| 380 |
+
},
|
| 381 |
+
"must_lie_about": [
|
| 382 |
+
"F_sec4"
|
| 383 |
+
],
|
| 384 |
+
"anchored_lies": [
|
| 385 |
+
{
|
| 386 |
+
"lie_id": "LIE_sec4",
|
| 387 |
+
"topic": "They have a past relationship that wasn’t offici",
|
| 388 |
+
"claimed": "He saw her as the perfect partner in crime, leading him to commit this act to protect her from falling out of favor with his boss.",
|
| 389 |
+
"truth_ref": "F_sec4",
|
| 390 |
+
"breaks_on": [
|
| 391 |
+
"C_h4"
|
| 392 |
+
],
|
| 393 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 394 |
+
}
|
| 395 |
+
],
|
| 396 |
+
"voice": null,
|
| 397 |
+
"visual": {
|
| 398 |
+
"subject_type": "suspect",
|
| 399 |
+
"palette": "noir",
|
| 400 |
+
"gender": "male",
|
| 401 |
+
"age_band": "45s",
|
| 402 |
+
"build": null,
|
| 403 |
+
"hair": null,
|
| 404 |
+
"attire": "Casual, bright colors, a handkerchief tied around his neck for added flair.",
|
| 405 |
+
"mood": "guarded",
|
| 406 |
+
"accent_color": "#6b8f71",
|
| 407 |
+
"location_tags": [],
|
| 408 |
+
"prop_tags": [],
|
| 409 |
+
"prompt_hint": "Cheerful and charming but slightly off-kilter in his mannerisms due to his history with Eleanor., Casual, bright colors, a handkerchief tied around his neck for added flair."
|
| 410 |
+
}
|
| 411 |
+
}
|
| 412 |
+
],
|
| 413 |
+
"culprit": {
|
| 414 |
+
"sus_id": "S1",
|
| 415 |
+
"true_motive": {
|
| 416 |
+
"motive_id": "M1",
|
| 417 |
+
"category": "revenge",
|
| 418 |
+
"summary": "Arthur Thornwell murdered Eleanor Blakeley to exact revenge for her betrayal of his love."
|
| 419 |
+
},
|
| 420 |
+
"method_narrative": "The killer, Arthur Thornwell, chose the Library as a secluded space where he could carry out the murder without witnesses. He used a ribbon to cover the crime scene and then claimed that he was in the Bedroom when the murder occurred.",
|
| 421 |
+
"alibi_lie": {
|
| 422 |
+
"claimed_loc_id": "L4",
|
| 423 |
+
"actual_loc_id": "L3",
|
| 424 |
+
"contradicted_by_clue_ids": [
|
| 425 |
+
"C_b1",
|
| 426 |
+
"C_b2"
|
| 427 |
+
]
|
| 428 |
+
}
|
| 429 |
+
},
|
| 430 |
+
"relationships": [],
|
| 431 |
+
"facts": [
|
| 432 |
+
{
|
| 433 |
+
"fact_id": "F_scene",
|
| 434 |
+
"statement": "Arthur Thornwell was in Library during the murder.",
|
| 435 |
+
"true_value": true,
|
| 436 |
+
"loc_id": "L3",
|
| 437 |
+
"at_min": 1280
|
| 438 |
+
},
|
| 439 |
+
{
|
| 440 |
+
"fact_id": "F_sec1",
|
| 441 |
+
"statement": "A long-standing secret between him and Eleanor regarding her past as a prisoner before joining the staff.",
|
| 442 |
+
"true_value": true,
|
| 443 |
+
"loc_id": null,
|
| 444 |
+
"at_min": null
|
| 445 |
+
},
|
| 446 |
+
{
|
| 447 |
+
"fact_id": "F_sec2",
|
| 448 |
+
"statement": "Their rivalry has been simmering for years and now finally boils over with the murder.",
|
| 449 |
+
"true_value": true,
|
| 450 |
+
"loc_id": null,
|
| 451 |
+
"at_min": null
|
| 452 |
+
},
|
| 453 |
+
{
|
| 454 |
+
"fact_id": "F_sec3",
|
| 455 |
+
"statement": "Their partnership was more about power than mutual respect due to a falling out over an investment deal that went badly.",
|
| 456 |
+
"true_value": true,
|
| 457 |
+
"loc_id": null,
|
| 458 |
+
"at_min": null
|
| 459 |
+
},
|
| 460 |
+
{
|
| 461 |
+
"fact_id": "F_sec4",
|
| 462 |
+
"statement": "They have a past relationship that wasn’t officially documented but grew into something deeper than expected.",
|
| 463 |
+
"true_value": true,
|
| 464 |
+
"loc_id": null,
|
| 465 |
+
"at_min": null
|
| 466 |
+
}
|
| 467 |
+
],
|
| 468 |
+
"clues": [
|
| 469 |
+
{
|
| 470 |
+
"clue_id": "C_b1",
|
| 471 |
+
"name": "Partial fingerprint",
|
| 472 |
+
"reveal_text": "A fresh partial fingerprint on the ribbon, unaccounted for among the guests.",
|
| 473 |
+
"discoverable_at_loc_id": "L3",
|
| 474 |
+
"discovery_method": "forensic",
|
| 475 |
+
"supports_fact_id": "F_scene",
|
| 476 |
+
"points_to_sus_id": "S1",
|
| 477 |
+
"contradicts_alibi_of": "S1",
|
| 478 |
+
"is_red_herring": false,
|
| 479 |
+
"weight": 1.0
|
| 480 |
+
},
|
| 481 |
+
{
|
| 482 |
+
"clue_id": "C_b2",
|
| 483 |
+
"name": "Snagged thread",
|
| 484 |
+
"reveal_text": "A torn thread of dark cloth snagged on the library doorframe.",
|
| 485 |
+
"discoverable_at_loc_id": "L1",
|
| 486 |
+
"discovery_method": "forensic",
|
| 487 |
+
"supports_fact_id": "F_scene",
|
| 488 |
+
"points_to_sus_id": "S1",
|
| 489 |
+
"contradicts_alibi_of": "S1",
|
| 490 |
+
"is_red_herring": false,
|
| 491 |
+
"weight": 0.7
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
"clue_id": "C_h2",
|
| 495 |
+
"name": "Pawn ticket",
|
| 496 |
+
"reveal_text": "A pawn ticket for a family heirloom, recently surrendered.",
|
| 497 |
+
"discoverable_at_loc_id": "L2",
|
| 498 |
+
"discovery_method": "search",
|
| 499 |
+
"supports_fact_id": "F_sec2",
|
| 500 |
+
"points_to_sus_id": "S2",
|
| 501 |
+
"contradicts_alibi_of": null,
|
| 502 |
+
"is_red_herring": true,
|
| 503 |
+
"weight": 0.3
|
| 504 |
+
},
|
| 505 |
+
{
|
| 506 |
+
"clue_id": "C_h3",
|
| 507 |
+
"name": "A torn letter with his handwriting showing he had given approval for the risky investment.",
|
| 508 |
+
"reveal_text": "The torn letter reveals how they both knew the investment was reckless and Eleanor’s fear that it would ruin her career.",
|
| 509 |
+
"discoverable_at_loc_id": "L4",
|
| 510 |
+
"discovery_method": "search",
|
| 511 |
+
"supports_fact_id": "F_sec3",
|
| 512 |
+
"points_to_sus_id": "S3",
|
| 513 |
+
"contradicts_alibi_of": null,
|
| 514 |
+
"is_red_herring": true,
|
| 515 |
+
"weight": 0.3
|
| 516 |
+
},
|
| 517 |
+
{
|
| 518 |
+
"clue_id": "C_h4",
|
| 519 |
+
"name": "A stained handkerchief found near Eleanor's body, indicating it was used for a romantic gesture.",
|
| 520 |
+
"reveal_text": "The handkerchief reveals the depth of Billy’s feelings and their plan to commit this crime together.",
|
| 521 |
+
"discoverable_at_loc_id": "L1",
|
| 522 |
+
"discovery_method": "search",
|
| 523 |
+
"supports_fact_id": "F_sec4",
|
| 524 |
+
"points_to_sus_id": "S4",
|
| 525 |
+
"contradicts_alibi_of": null,
|
| 526 |
+
"is_red_herring": true,
|
| 527 |
+
"weight": 0.3
|
| 528 |
+
}
|
| 529 |
+
],
|
| 530 |
+
"solution": {
|
| 531 |
+
"culprit_sus_id": "S1",
|
| 532 |
+
"weapon_id": "W1",
|
| 533 |
+
"motive_id": "M1",
|
| 534 |
+
"minimal_clue_set": [
|
| 535 |
+
"C_b1"
|
| 536 |
+
],
|
| 537 |
+
"deduction_chain": [
|
| 538 |
+
"The false alibi of being in the Bedroom contradicts the time frame of the murder, suggesting that Arthur Thornwell was present at the Library.",
|
| 539 |
+
"The presence of a ribbon covering the crime scene indicates that someone tampered with it after the murder. This suggests that the killer had access to the Library and could have committed the act there.",
|
| 540 |
+
"The fact that Eleanor Blakeley's body was found in the Library, not in her bedroom as claimed by Arthur Thornwell, further supports the idea that he was present at the Library during the murder."
|
| 541 |
+
]
|
| 542 |
+
}
|
| 543 |
+
}
|
cases/prebaked/CASE-0004.json
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "CASE-0004",
|
| 3 |
+
"seed": 43017,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Living Room Affair",
|
| 6 |
+
"briefing": "A mysterious murder occurred in the Whispers Lounge around midnight last night.",
|
| 7 |
+
"knobs": {
|
| 8 |
+
"setting_hint": "",
|
| 9 |
+
"era_hint": "",
|
| 10 |
+
"tone_hint": "",
|
| 11 |
+
"n_suspects": 4,
|
| 12 |
+
"n_red_herrings": 2,
|
| 13 |
+
"alibi_tightness": 0.6,
|
| 14 |
+
"difficulty": "standard"
|
| 15 |
+
},
|
| 16 |
+
"setting": {
|
| 17 |
+
"name": "",
|
| 18 |
+
"description": "",
|
| 19 |
+
"locations": [
|
| 20 |
+
{
|
| 21 |
+
"loc_id": "L1",
|
| 22 |
+
"name": "living room",
|
| 23 |
+
"description": "",
|
| 24 |
+
"adjacent_to": [
|
| 25 |
+
"L2",
|
| 26 |
+
"L3",
|
| 27 |
+
"L4"
|
| 28 |
+
]
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"loc_id": "L2",
|
| 32 |
+
"name": "dining room",
|
| 33 |
+
"description": "",
|
| 34 |
+
"adjacent_to": [
|
| 35 |
+
"L1"
|
| 36 |
+
]
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"loc_id": "L3",
|
| 40 |
+
"name": "kitchen",
|
| 41 |
+
"description": "",
|
| 42 |
+
"adjacent_to": [
|
| 43 |
+
"L1"
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"loc_id": "L4",
|
| 48 |
+
"name": "bathroom",
|
| 49 |
+
"description": "",
|
| 50 |
+
"adjacent_to": [
|
| 51 |
+
"L1"
|
| 52 |
+
]
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
"murder_window": {
|
| 56 |
+
"start_min": 1260,
|
| 57 |
+
"end_min": 1320
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
"victim": {
|
| 61 |
+
"vic_id": "V1",
|
| 62 |
+
"name": "Isabella Carter",
|
| 63 |
+
"role": "a skilled software engineer at TechCorp",
|
| 64 |
+
"found_at_loc_id": "L1",
|
| 65 |
+
"found_at_min": 1325,
|
| 66 |
+
"cause_of_death": "stab wound to the heart from a concealed switchblade in the living room",
|
| 67 |
+
"time_of_death": {
|
| 68 |
+
"start_min": 1280,
|
| 69 |
+
"end_min": 1310
|
| 70 |
+
}
|
| 71 |
+
},
|
| 72 |
+
"weapon": {
|
| 73 |
+
"weapon_id": "W1",
|
| 74 |
+
"name": "switchblade",
|
| 75 |
+
"kind": "single-edged",
|
| 76 |
+
"origin_loc_id": "L1",
|
| 77 |
+
"requires_strength": false,
|
| 78 |
+
"leaves_trace": ""
|
| 79 |
+
},
|
| 80 |
+
"suspects": [
|
| 81 |
+
{
|
| 82 |
+
"sus_id": "S1",
|
| 83 |
+
"name": "Jesse Harper",
|
| 84 |
+
"role": "business partner at TechCorp, former IT security expert and hacker",
|
| 85 |
+
"persona_summary": "A ruthless man obsessed with hacking.",
|
| 86 |
+
"demeanour": "composed and cooperative on the surface, carefully measured",
|
| 87 |
+
"is_culprit": false,
|
| 88 |
+
"physical_capability": {
|
| 89 |
+
"strength": true,
|
| 90 |
+
"mobility": true
|
| 91 |
+
},
|
| 92 |
+
"personality": {
|
| 93 |
+
"composure": 0.72,
|
| 94 |
+
"aggression": 0.4,
|
| 95 |
+
"evasiveness": 0.3
|
| 96 |
+
},
|
| 97 |
+
"tells": [
|
| 98 |
+
"I'm not the one who would kill anyone, especially at such a late hour."
|
| 99 |
+
],
|
| 100 |
+
"knows_facts": [
|
| 101 |
+
"F_sec1"
|
| 102 |
+
],
|
| 103 |
+
"secrets": [
|
| 104 |
+
"His wife cheated on him months ago. He doesn't want to confront the truth about her infidelity because it will ruin his career."
|
| 105 |
+
],
|
| 106 |
+
"true_whereabouts": [
|
| 107 |
+
{
|
| 108 |
+
"window": {
|
| 109 |
+
"start_min": 1260,
|
| 110 |
+
"end_min": 1320
|
| 111 |
+
},
|
| 112 |
+
"loc_id": "L2",
|
| 113 |
+
"activity": "going about the evening",
|
| 114 |
+
"co_present_sus_ids": []
|
| 115 |
+
}
|
| 116 |
+
],
|
| 117 |
+
"stated_alibi": {
|
| 118 |
+
"claim_text": "I was in dining room the whole time.",
|
| 119 |
+
"claimed_segments": [
|
| 120 |
+
{
|
| 121 |
+
"window": {
|
| 122 |
+
"start_min": 1260,
|
| 123 |
+
"end_min": 1320
|
| 124 |
+
},
|
| 125 |
+
"loc_id": "L2",
|
| 126 |
+
"witness_sus_ids": []
|
| 127 |
+
}
|
| 128 |
+
]
|
| 129 |
+
},
|
| 130 |
+
"must_lie_about": [
|
| 131 |
+
"F_sec1"
|
| 132 |
+
],
|
| 133 |
+
"anchored_lies": [
|
| 134 |
+
{
|
| 135 |
+
"lie_id": "LIE_sec1",
|
| 136 |
+
"topic": "His wife cheated on him months ago. He doesn't w",
|
| 137 |
+
"claimed": "He's a software engineer, but he prefers to work from home at TechCorp.",
|
| 138 |
+
"truth_ref": "F_sec1",
|
| 139 |
+
"breaks_on": [
|
| 140 |
+
"C_h1"
|
| 141 |
+
],
|
| 142 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 143 |
+
}
|
| 144 |
+
],
|
| 145 |
+
"voice": null,
|
| 146 |
+
"visual": {
|
| 147 |
+
"subject_type": "suspect",
|
| 148 |
+
"palette": "noir",
|
| 149 |
+
"gender": "male",
|
| 150 |
+
"age_band": "50s",
|
| 151 |
+
"build": null,
|
| 152 |
+
"hair": null,
|
| 153 |
+
"attire": "business attire",
|
| 154 |
+
"mood": "guarded",
|
| 155 |
+
"accent_color": "#b8860b",
|
| 156 |
+
"location_tags": [],
|
| 157 |
+
"prop_tags": [],
|
| 158 |
+
"prompt_hint": "Jesse Harper: 45 years old, male, dressed in a suit and tie with a sharp white beard, clean-shaven face, confident demeanor., business attire"
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
{
|
| 162 |
+
"sus_id": "S2",
|
| 163 |
+
"name": "Emily Carter",
|
| 164 |
+
"role": "Isabella's lover and coworker, known for her fiery personality",
|
| 165 |
+
"persona_summary": "A passionate but somewhat dramatic woman.",
|
| 166 |
+
"demeanour": "rattled and evasive, voice tightening under pressure",
|
| 167 |
+
"is_culprit": true,
|
| 168 |
+
"physical_capability": {
|
| 169 |
+
"strength": true,
|
| 170 |
+
"mobility": true
|
| 171 |
+
},
|
| 172 |
+
"personality": {
|
| 173 |
+
"composure": 0.32,
|
| 174 |
+
"aggression": 0.55,
|
| 175 |
+
"evasiveness": 0.68
|
| 176 |
+
},
|
| 177 |
+
"tells": [
|
| 178 |
+
"I wouldn't do anything that could get me arrested or have consequences for my work."
|
| 179 |
+
],
|
| 180 |
+
"knows_facts": [
|
| 181 |
+
"F_scene",
|
| 182 |
+
"F_sec2"
|
| 183 |
+
],
|
| 184 |
+
"secrets": [
|
| 185 |
+
"She was coerced into an affair by Isabella due to financial troubles."
|
| 186 |
+
],
|
| 187 |
+
"true_whereabouts": [
|
| 188 |
+
{
|
| 189 |
+
"window": {
|
| 190 |
+
"start_min": 1260,
|
| 191 |
+
"end_min": 1280
|
| 192 |
+
},
|
| 193 |
+
"loc_id": "L2",
|
| 194 |
+
"activity": "mingling in plain sight",
|
| 195 |
+
"co_present_sus_ids": []
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
"window": {
|
| 199 |
+
"start_min": 1280,
|
| 200 |
+
"end_min": 1310
|
| 201 |
+
},
|
| 202 |
+
"loc_id": "L1",
|
| 203 |
+
"activity": "alone with the victim",
|
| 204 |
+
"co_present_sus_ids": []
|
| 205 |
+
}
|
| 206 |
+
],
|
| 207 |
+
"stated_alibi": {
|
| 208 |
+
"claim_text": "I was in the dining room all day long.",
|
| 209 |
+
"claimed_segments": [
|
| 210 |
+
{
|
| 211 |
+
"window": {
|
| 212 |
+
"start_min": 1260,
|
| 213 |
+
"end_min": 1320
|
| 214 |
+
},
|
| 215 |
+
"loc_id": "L2",
|
| 216 |
+
"witness_sus_ids": []
|
| 217 |
+
}
|
| 218 |
+
]
|
| 219 |
+
},
|
| 220 |
+
"must_lie_about": [
|
| 221 |
+
"F_scene"
|
| 222 |
+
],
|
| 223 |
+
"anchored_lies": [
|
| 224 |
+
{
|
| 225 |
+
"lie_id": "LIE_alibi",
|
| 226 |
+
"topic": "where you were during the murder",
|
| 227 |
+
"claimed": "I was in the dining room all day long.",
|
| 228 |
+
"truth_ref": "F_scene",
|
| 229 |
+
"breaks_on": [
|
| 230 |
+
"C_b1",
|
| 231 |
+
"C_b2"
|
| 232 |
+
],
|
| 233 |
+
"fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
|
| 234 |
+
}
|
| 235 |
+
],
|
| 236 |
+
"voice": null,
|
| 237 |
+
"visual": {
|
| 238 |
+
"subject_type": "suspect",
|
| 239 |
+
"palette": "noir",
|
| 240 |
+
"gender": "female",
|
| 241 |
+
"age_band": "20s",
|
| 242 |
+
"build": null,
|
| 243 |
+
"hair": null,
|
| 244 |
+
"attire": "business casual but stylish",
|
| 245 |
+
"mood": "guarded",
|
| 246 |
+
"accent_color": "#3a6ea5",
|
| 247 |
+
"location_tags": [],
|
| 248 |
+
"prop_tags": [],
|
| 249 |
+
"prompt_hint": "Emily Carter: 29 years old, female, wearing a pair of high heels and a short dress with bright lipstick and eyeshadow, playful demeanor., business casual but stylish"
|
| 250 |
+
}
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"sus_id": "S3",
|
| 254 |
+
"name": "Thomas Johnson",
|
| 255 |
+
"role": "a rival software engineer at TechCorp, known for his ruthless ambition",
|
| 256 |
+
"persona_summary": "He's a jack-of-all-trades who often goes above and beyond to protect his company’s interests.",
|
| 257 |
+
"demeanour": "guarded and weary, giving away as little as possible",
|
| 258 |
+
"is_culprit": false,
|
| 259 |
+
"physical_capability": {
|
| 260 |
+
"strength": true,
|
| 261 |
+
"mobility": true
|
| 262 |
+
},
|
| 263 |
+
"personality": {
|
| 264 |
+
"composure": 0.6,
|
| 265 |
+
"aggression": 0.25,
|
| 266 |
+
"evasiveness": 0.55
|
| 267 |
+
},
|
| 268 |
+
"tells": [
|
| 269 |
+
"I'm the one who was there at night, but I wasn't the only one."
|
| 270 |
+
],
|
| 271 |
+
"knows_facts": [
|
| 272 |
+
"F_sec3"
|
| 273 |
+
],
|
| 274 |
+
"secrets": [
|
| 275 |
+
"He secretly monitored Isabella’s online activities with the help of an app installed on her phone. He saw she was working late at night due to stress about their relationship, which was causing tension between them."
|
| 276 |
+
],
|
| 277 |
+
"true_whereabouts": [
|
| 278 |
+
{
|
| 279 |
+
"window": {
|
| 280 |
+
"start_min": 1260,
|
| 281 |
+
"end_min": 1320
|
| 282 |
+
},
|
| 283 |
+
"loc_id": "L4",
|
| 284 |
+
"activity": "going about the evening",
|
| 285 |
+
"co_present_sus_ids": []
|
| 286 |
+
}
|
| 287 |
+
],
|
| 288 |
+
"stated_alibi": {
|
| 289 |
+
"claim_text": "I was in bathroom the whole time.",
|
| 290 |
+
"claimed_segments": [
|
| 291 |
+
{
|
| 292 |
+
"window": {
|
| 293 |
+
"start_min": 1260,
|
| 294 |
+
"end_min": 1320
|
| 295 |
+
},
|
| 296 |
+
"loc_id": "L4",
|
| 297 |
+
"witness_sus_ids": []
|
| 298 |
+
}
|
| 299 |
+
]
|
| 300 |
+
},
|
| 301 |
+
"must_lie_about": [
|
| 302 |
+
"F_sec3"
|
| 303 |
+
],
|
| 304 |
+
"anchored_lies": [
|
| 305 |
+
{
|
| 306 |
+
"lie_id": "LIE_sec3",
|
| 307 |
+
"topic": "He secretly monitored Isabella’s online activiti",
|
| 308 |
+
"claimed": "Tom has a competitive nature but doesn't like people knowing he's always watching others.",
|
| 309 |
+
"truth_ref": "F_sec3",
|
| 310 |
+
"breaks_on": [
|
| 311 |
+
"C_h3"
|
| 312 |
+
],
|
| 313 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 314 |
+
}
|
| 315 |
+
],
|
| 316 |
+
"voice": null,
|
| 317 |
+
"visual": {
|
| 318 |
+
"subject_type": "suspect",
|
| 319 |
+
"palette": "noir",
|
| 320 |
+
"gender": "male",
|
| 321 |
+
"age_band": "50s",
|
| 322 |
+
"build": null,
|
| 323 |
+
"hair": null,
|
| 324 |
+
"attire": "professional attire with jewelry",
|
| 325 |
+
"mood": "tense",
|
| 326 |
+
"accent_color": "#9a9aa0",
|
| 327 |
+
"location_tags": [],
|
| 328 |
+
"prop_tags": [],
|
| 329 |
+
"prompt_hint": "Thomas Johnson: 47 years old, male, dressed in a business suit with a stern look and a gold chain around his neck, aggressive demeanor., professional attire with jewelry"
|
| 330 |
+
}
|
| 331 |
+
},
|
| 332 |
+
{
|
| 333 |
+
"sus_id": "S4",
|
| 334 |
+
"name": "Lucas Green",
|
| 335 |
+
"role": "the servant in the household, known for his mild demeanor and reliability",
|
| 336 |
+
"persona_summary": "A kind-hearted man with a keen sense of duty.",
|
| 337 |
+
"demeanour": "cool and self-assured, almost amused by the questioning",
|
| 338 |
+
"is_culprit": false,
|
| 339 |
+
"physical_capability": {
|
| 340 |
+
"strength": true,
|
| 341 |
+
"mobility": true
|
| 342 |
+
},
|
| 343 |
+
"personality": {
|
| 344 |
+
"composure": 0.88,
|
| 345 |
+
"aggression": 0.3,
|
| 346 |
+
"evasiveness": 0.35
|
| 347 |
+
},
|
| 348 |
+
"tells": [
|
| 349 |
+
"I didn't do anything that would get me fired or in trouble."
|
| 350 |
+
],
|
| 351 |
+
"knows_facts": [
|
| 352 |
+
"F_sec4"
|
| 353 |
+
],
|
| 354 |
+
"secrets": [
|
| 355 |
+
"He secretly took care of Isabella's family, including her children, due to financial difficulties. He was paid by Thomas Johnson who wanted to keep an eye on the house but could not because it belonged to TechCorp."
|
| 356 |
+
],
|
| 357 |
+
"true_whereabouts": [
|
| 358 |
+
{
|
| 359 |
+
"window": {
|
| 360 |
+
"start_min": 1260,
|
| 361 |
+
"end_min": 1320
|
| 362 |
+
},
|
| 363 |
+
"loc_id": "L2",
|
| 364 |
+
"activity": "going about the evening",
|
| 365 |
+
"co_present_sus_ids": []
|
| 366 |
+
}
|
| 367 |
+
],
|
| 368 |
+
"stated_alibi": {
|
| 369 |
+
"claim_text": "I was in dining room the whole time.",
|
| 370 |
+
"claimed_segments": [
|
| 371 |
+
{
|
| 372 |
+
"window": {
|
| 373 |
+
"start_min": 1260,
|
| 374 |
+
"end_min": 1320
|
| 375 |
+
},
|
| 376 |
+
"loc_id": "L2",
|
| 377 |
+
"witness_sus_ids": []
|
| 378 |
+
}
|
| 379 |
+
]
|
| 380 |
+
},
|
| 381 |
+
"must_lie_about": [
|
| 382 |
+
"F_sec4"
|
| 383 |
+
],
|
| 384 |
+
"anchored_lies": [
|
| 385 |
+
{
|
| 386 |
+
"lie_id": "LIE_sec4",
|
| 387 |
+
"topic": "He secretly took care of Isabella's family, incl",
|
| 388 |
+
"claimed": "Lucas is a very discreet person and doesn't like to speak about his past or responsibilities.",
|
| 389 |
+
"truth_ref": "F_sec4",
|
| 390 |
+
"breaks_on": [
|
| 391 |
+
"C_h4"
|
| 392 |
+
],
|
| 393 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 394 |
+
}
|
| 395 |
+
],
|
| 396 |
+
"voice": null,
|
| 397 |
+
"visual": {
|
| 398 |
+
"subject_type": "suspect",
|
| 399 |
+
"palette": "noir",
|
| 400 |
+
"gender": "male",
|
| 401 |
+
"age_band": "middle-aged",
|
| 402 |
+
"build": null,
|
| 403 |
+
"hair": null,
|
| 404 |
+
"attire": "clean, formal attire",
|
| 405 |
+
"mood": "tense",
|
| 406 |
+
"accent_color": "#6b8f71",
|
| 407 |
+
"location_tags": [],
|
| 408 |
+
"prop_tags": [],
|
| 409 |
+
"prompt_hint": "Lucas Green: 35 years old, male, dressed in clean work clothes with no visible signs of emotion, mild demeanor and steady eyes., clean, formal attire"
|
| 410 |
+
}
|
| 411 |
+
}
|
| 412 |
+
],
|
| 413 |
+
"culprit": {
|
| 414 |
+
"sus_id": "S2",
|
| 415 |
+
"true_motive": {
|
| 416 |
+
"motive_id": "M1",
|
| 417 |
+
"category": "revenge",
|
| 418 |
+
"summary": "Emily Carter murdered Isabella Carter out of jealousy over her success and love for the victim."
|
| 419 |
+
},
|
| 420 |
+
"method_narrative": "The killer, Emily Carter, used a switchblade to commit the murder in the living room between 21:00 and 22:00. She then claimed she was in the dining room during the crime.",
|
| 421 |
+
"alibi_lie": {
|
| 422 |
+
"claimed_loc_id": "L2",
|
| 423 |
+
"actual_loc_id": "L1",
|
| 424 |
+
"contradicted_by_clue_ids": [
|
| 425 |
+
"C_b1",
|
| 426 |
+
"C_b2"
|
| 427 |
+
]
|
| 428 |
+
}
|
| 429 |
+
},
|
| 430 |
+
"relationships": [],
|
| 431 |
+
"facts": [
|
| 432 |
+
{
|
| 433 |
+
"fact_id": "F_scene",
|
| 434 |
+
"statement": "Emily Carter was in living room during the murder.",
|
| 435 |
+
"true_value": true,
|
| 436 |
+
"loc_id": "L1",
|
| 437 |
+
"at_min": 1280
|
| 438 |
+
},
|
| 439 |
+
{
|
| 440 |
+
"fact_id": "F_sec1",
|
| 441 |
+
"statement": "His wife cheated on him months ago. He doesn't want to confront the truth about her infidelity because it will ruin his career.",
|
| 442 |
+
"true_value": true,
|
| 443 |
+
"loc_id": null,
|
| 444 |
+
"at_min": null
|
| 445 |
+
},
|
| 446 |
+
{
|
| 447 |
+
"fact_id": "F_sec2",
|
| 448 |
+
"statement": "She was coerced into an affair by Isabella due to financial troubles.",
|
| 449 |
+
"true_value": true,
|
| 450 |
+
"loc_id": null,
|
| 451 |
+
"at_min": null
|
| 452 |
+
},
|
| 453 |
+
{
|
| 454 |
+
"fact_id": "F_sec3",
|
| 455 |
+
"statement": "He secretly monitored Isabella’s online activities with the help of an app installed on her phone. He saw she was working late at night due to stress about their relationship, which was causing tension between them.",
|
| 456 |
+
"true_value": true,
|
| 457 |
+
"loc_id": null,
|
| 458 |
+
"at_min": null
|
| 459 |
+
},
|
| 460 |
+
{
|
| 461 |
+
"fact_id": "F_sec4",
|
| 462 |
+
"statement": "He secretly took care of Isabella's family, including her children, due to financial difficulties. He was paid by Thomas Johnson who wanted to keep an eye on the house but could not because it belonged to TechCorp.",
|
| 463 |
+
"true_value": true,
|
| 464 |
+
"loc_id": null,
|
| 465 |
+
"at_min": null
|
| 466 |
+
}
|
| 467 |
+
],
|
| 468 |
+
"clues": [
|
| 469 |
+
{
|
| 470 |
+
"clue_id": "C_b1",
|
| 471 |
+
"name": "Smudged tumbler",
|
| 472 |
+
"reveal_text": "A tumbler left where the body fell, its rim marked with a recent lip-print.",
|
| 473 |
+
"discoverable_at_loc_id": "L1",
|
| 474 |
+
"discovery_method": "forensic",
|
| 475 |
+
"supports_fact_id": "F_scene",
|
| 476 |
+
"points_to_sus_id": "S2",
|
| 477 |
+
"contradicts_alibi_of": "S2",
|
| 478 |
+
"is_red_herring": false,
|
| 479 |
+
"weight": 1.0
|
| 480 |
+
},
|
| 481 |
+
{
|
| 482 |
+
"clue_id": "C_b2",
|
| 483 |
+
"name": "Snagged thread",
|
| 484 |
+
"reveal_text": "A torn thread of dark cloth snagged on the living room doorframe.",
|
| 485 |
+
"discoverable_at_loc_id": "L2",
|
| 486 |
+
"discovery_method": "forensic",
|
| 487 |
+
"supports_fact_id": "F_scene",
|
| 488 |
+
"points_to_sus_id": "S2",
|
| 489 |
+
"contradicts_alibi_of": "S2",
|
| 490 |
+
"is_red_herring": false,
|
| 491 |
+
"weight": 0.7
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
"clue_id": "C_h1",
|
| 495 |
+
"name": "a receipt for a high-profile hack",
|
| 496 |
+
"reveal_text": "The evidence is an invoice showing he received $20k in fees for a sophisticated phishing operation. He was hacking into corporate email accounts of major clients.",
|
| 497 |
+
"discoverable_at_loc_id": "L3",
|
| 498 |
+
"discovery_method": "search",
|
| 499 |
+
"supports_fact_id": "F_sec1",
|
| 500 |
+
"points_to_sus_id": "S1",
|
| 501 |
+
"contradicts_alibi_of": null,
|
| 502 |
+
"is_red_herring": true,
|
| 503 |
+
"weight": 0.3
|
| 504 |
+
},
|
| 505 |
+
{
|
| 506 |
+
"clue_id": "C_h3",
|
| 507 |
+
"name": "a pawn ticket from the department store where Isabella bought her evening dress",
|
| 508 |
+
"reveal_text": "The pawn ticket is for an engagement ring worth $30k, which was purchased as collateral by Thomas to keep an eye on Isabella. He needed the money urgently because his company had just filed bankruptcy.",
|
| 509 |
+
"discoverable_at_loc_id": "L4",
|
| 510 |
+
"discovery_method": "search",
|
| 511 |
+
"supports_fact_id": "F_sec3",
|
| 512 |
+
"points_to_sus_id": "S3",
|
| 513 |
+
"contradicts_alibi_of": null,
|
| 514 |
+
"is_red_herring": true,
|
| 515 |
+
"weight": 0.3
|
| 516 |
+
},
|
| 517 |
+
{
|
| 518 |
+
"clue_id": "C_h4",
|
| 519 |
+
"name": "a stain of Isabella's perfume found on the carpet near her bed",
|
| 520 |
+
"reveal_text": "The stain was left by Lucas during his routine cleaning duties. He confessed that he had been hired by Thomas Johnson, who offered him a job as a nanny if he kept an eye on the house for free.",
|
| 521 |
+
"discoverable_at_loc_id": "L2",
|
| 522 |
+
"discovery_method": "search",
|
| 523 |
+
"supports_fact_id": "F_sec4",
|
| 524 |
+
"points_to_sus_id": "S4",
|
| 525 |
+
"contradicts_alibi_of": null,
|
| 526 |
+
"is_red_herring": true,
|
| 527 |
+
"weight": 0.3
|
| 528 |
+
}
|
| 529 |
+
],
|
| 530 |
+
"solution": {
|
| 531 |
+
"culprit_sus_id": "S2",
|
| 532 |
+
"weapon_id": "W1",
|
| 533 |
+
"motive_id": "M1",
|
| 534 |
+
"minimal_clue_set": [
|
| 535 |
+
"C_b1"
|
| 536 |
+
],
|
| 537 |
+
"deduction_chain": [
|
| 538 |
+
"The switchblade murder weapon and a torn piece of Isabella's dress were found on Emily Carter's person, indicating she committed the crime.",
|
| 539 |
+
"The alibi claim about being in the dining room is false because the body was found in the living room where Emily Carter had access to the switchblade.",
|
| 540 |
+
"The presence of her fingerprints on the murder weapon and a torn piece of Isabella's dress suggests she was present during the murder."
|
| 541 |
+
]
|
| 542 |
+
}
|
| 543 |
+
}
|
cases/prebaked/CASE-0005.json
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "CASE-0005",
|
| 3 |
+
"seed": 43021,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Unlucky Door",
|
| 6 |
+
"briefing": "Detective investigating a double murder at The Unlucky Door, a renowned theater in the heart of downtown. The victims were found in Room 3 during a suspicious disappearance investigation.",
|
| 7 |
+
"knobs": {
|
| 8 |
+
"setting_hint": "",
|
| 9 |
+
"era_hint": "",
|
| 10 |
+
"tone_hint": "",
|
| 11 |
+
"n_suspects": 4,
|
| 12 |
+
"n_red_herrings": 2,
|
| 13 |
+
"alibi_tightness": 0.6,
|
| 14 |
+
"difficulty": "standard"
|
| 15 |
+
},
|
| 16 |
+
"setting": {
|
| 17 |
+
"name": "The Unlucky Door",
|
| 18 |
+
"description": "",
|
| 19 |
+
"locations": [
|
| 20 |
+
{
|
| 21 |
+
"loc_id": "L1",
|
| 22 |
+
"name": "Main Lobby",
|
| 23 |
+
"description": "",
|
| 24 |
+
"adjacent_to": [
|
| 25 |
+
"L2",
|
| 26 |
+
"L3",
|
| 27 |
+
"L4"
|
| 28 |
+
]
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"loc_id": "L2",
|
| 32 |
+
"name": "Actor's Studio",
|
| 33 |
+
"description": "",
|
| 34 |
+
"adjacent_to": [
|
| 35 |
+
"L1"
|
| 36 |
+
]
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"loc_id": "L3",
|
| 40 |
+
"name": "Manager's Office",
|
| 41 |
+
"description": "",
|
| 42 |
+
"adjacent_to": [
|
| 43 |
+
"L1"
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"loc_id": "L4",
|
| 48 |
+
"name": "Director's Study",
|
| 49 |
+
"description": "",
|
| 50 |
+
"adjacent_to": [
|
| 51 |
+
"L1"
|
| 52 |
+
]
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
"murder_window": {
|
| 56 |
+
"start_min": 1260,
|
| 57 |
+
"end_min": 1320
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
"victim": {
|
| 61 |
+
"vic_id": "V1",
|
| 62 |
+
"name": "Jasmine Lee",
|
| 63 |
+
"role": "Actress and Manager of the theater",
|
| 64 |
+
"found_at_loc_id": "L1",
|
| 65 |
+
"found_at_min": 1325,
|
| 66 |
+
"cause_of_death": "Accidental overdose with lethal amount of sleeping pills in Room 3",
|
| 67 |
+
"time_of_death": {
|
| 68 |
+
"start_min": 1280,
|
| 69 |
+
"end_min": 1310
|
| 70 |
+
}
|
| 71 |
+
},
|
| 72 |
+
"weapon": {
|
| 73 |
+
"weapon_id": "W1",
|
| 74 |
+
"name": "Bottle opener",
|
| 75 |
+
"kind": "surgical scalpel found embedded in her shoulder wound",
|
| 76 |
+
"origin_loc_id": "L1",
|
| 77 |
+
"requires_strength": false,
|
| 78 |
+
"leaves_trace": ""
|
| 79 |
+
},
|
| 80 |
+
"suspects": [
|
| 81 |
+
{
|
| 82 |
+
"sus_id": "S1",
|
| 83 |
+
"name": "Victor Chen",
|
| 84 |
+
"role": "Jasmine's ex-boyfriend and business partner",
|
| 85 |
+
"persona_summary": "An envious businessman, Victor is obsessed with Jasmine's success but secretly jealous of their close relationship. He was caught shoplifting a valuable diamond ring from her jewelry box in Room 4.",
|
| 86 |
+
"demeanour": "visibly frightened and on edge, dreading every question",
|
| 87 |
+
"is_culprit": false,
|
| 88 |
+
"physical_capability": {
|
| 89 |
+
"strength": true,
|
| 90 |
+
"mobility": true
|
| 91 |
+
},
|
| 92 |
+
"personality": {
|
| 93 |
+
"composure": 0.2,
|
| 94 |
+
"aggression": 0.3,
|
| 95 |
+
"evasiveness": 0.7
|
| 96 |
+
},
|
| 97 |
+
"tells": [
|
| 98 |
+
"I was just checking on her from upstairs."
|
| 99 |
+
],
|
| 100 |
+
"knows_facts": [
|
| 101 |
+
"F_sec1"
|
| 102 |
+
],
|
| 103 |
+
"secrets": [
|
| 104 |
+
"The real reason he was there to see her die, as she was the key to securing his share of her fortune, which includes her beloved dog Coco (a black and white poodle)"
|
| 105 |
+
],
|
| 106 |
+
"true_whereabouts": [
|
| 107 |
+
{
|
| 108 |
+
"window": {
|
| 109 |
+
"start_min": 1260,
|
| 110 |
+
"end_min": 1320
|
| 111 |
+
},
|
| 112 |
+
"loc_id": "L2",
|
| 113 |
+
"activity": "going about the evening",
|
| 114 |
+
"co_present_sus_ids": []
|
| 115 |
+
}
|
| 116 |
+
],
|
| 117 |
+
"stated_alibi": {
|
| 118 |
+
"claim_text": "I was in Actor's Studio the whole time.",
|
| 119 |
+
"claimed_segments": [
|
| 120 |
+
{
|
| 121 |
+
"window": {
|
| 122 |
+
"start_min": 1260,
|
| 123 |
+
"end_min": 1320
|
| 124 |
+
},
|
| 125 |
+
"loc_id": "L2",
|
| 126 |
+
"witness_sus_ids": []
|
| 127 |
+
}
|
| 128 |
+
]
|
| 129 |
+
},
|
| 130 |
+
"must_lie_about": [
|
| 131 |
+
"F_sec1"
|
| 132 |
+
],
|
| 133 |
+
"anchored_lies": [
|
| 134 |
+
{
|
| 135 |
+
"lie_id": "LIE_sec1",
|
| 136 |
+
"topic": "The real reason he was there to see her die, as ",
|
| 137 |
+
"claimed": "He was merely looking for an opportunity to claim his half of her inheritance before her disappearance.",
|
| 138 |
+
"truth_ref": "F_sec1",
|
| 139 |
+
"breaks_on": [
|
| 140 |
+
"C_h1"
|
| 141 |
+
],
|
| 142 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 143 |
+
}
|
| 144 |
+
],
|
| 145 |
+
"voice": null,
|
| 146 |
+
"visual": {
|
| 147 |
+
"subject_type": "suspect",
|
| 148 |
+
"palette": "noir",
|
| 149 |
+
"gender": "male",
|
| 150 |
+
"age_band": "40s",
|
| 151 |
+
"build": null,
|
| 152 |
+
"hair": null,
|
| 153 |
+
"attire": "Black tie evening dress complete with top hat and monocle as he pretends to be an esteemed theater owner while plotting the murder of his business partner's love interest.",
|
| 154 |
+
"mood": "tense",
|
| 155 |
+
"accent_color": "#b8860b",
|
| 156 |
+
"location_tags": [],
|
| 157 |
+
"prop_tags": [],
|
| 158 |
+
"prompt_hint": "Tall and imposing in a tailored suit with slicked-back hair and a gold chain around his neck, he is confident but somewhat aggressive., Black tie evening dress complete with top hat and monocle as he pretends to be an esteemed theater owner while plotting the murder of his business partner's love interest."
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
{
|
| 162 |
+
"sus_id": "S2",
|
| 163 |
+
"name": "Liliana Rodriguez",
|
| 164 |
+
"role": "Jasmine's lover and friend",
|
| 165 |
+
"persona_summary": "A charming but flirtatious woman with a sharp tongue, Liliana had been trying to break up with Jasmine for weeks. Her secret: she was jealous of their close relationship and secretly obsessed with finding the diamonds that could help her get custody of Coco (which had not happened)",
|
| 166 |
+
"demeanour": "hostile and defensive, bristling at any hint of suspicion",
|
| 167 |
+
"is_culprit": true,
|
| 168 |
+
"physical_capability": {
|
| 169 |
+
"strength": true,
|
| 170 |
+
"mobility": true
|
| 171 |
+
},
|
| 172 |
+
"personality": {
|
| 173 |
+
"composure": 0.55,
|
| 174 |
+
"aggression": 0.88,
|
| 175 |
+
"evasiveness": 0.4
|
| 176 |
+
},
|
| 177 |
+
"tells": [
|
| 178 |
+
"She was just checking on Jasmine because she had information for her boss."
|
| 179 |
+
],
|
| 180 |
+
"knows_facts": [
|
| 181 |
+
"F_scene",
|
| 182 |
+
"F_sec2"
|
| 183 |
+
],
|
| 184 |
+
"secrets": [
|
| 185 |
+
"The real reason for her jealousy was to frame Jasmine as a liar in front of others, including her boss Victor"
|
| 186 |
+
],
|
| 187 |
+
"true_whereabouts": [
|
| 188 |
+
{
|
| 189 |
+
"window": {
|
| 190 |
+
"start_min": 1260,
|
| 191 |
+
"end_min": 1280
|
| 192 |
+
},
|
| 193 |
+
"loc_id": "L2",
|
| 194 |
+
"activity": "mingling in plain sight",
|
| 195 |
+
"co_present_sus_ids": []
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
"window": {
|
| 199 |
+
"start_min": 1280,
|
| 200 |
+
"end_min": 1310
|
| 201 |
+
},
|
| 202 |
+
"loc_id": "L1",
|
| 203 |
+
"activity": "alone with the victim",
|
| 204 |
+
"co_present_sus_ids": []
|
| 205 |
+
}
|
| 206 |
+
],
|
| 207 |
+
"stated_alibi": {
|
| 208 |
+
"claim_text": "I was in the Actor's Studio at all times and have no knowledge of Jasmine Lee's murder.",
|
| 209 |
+
"claimed_segments": [
|
| 210 |
+
{
|
| 211 |
+
"window": {
|
| 212 |
+
"start_min": 1260,
|
| 213 |
+
"end_min": 1320
|
| 214 |
+
},
|
| 215 |
+
"loc_id": "L2",
|
| 216 |
+
"witness_sus_ids": []
|
| 217 |
+
}
|
| 218 |
+
]
|
| 219 |
+
},
|
| 220 |
+
"must_lie_about": [
|
| 221 |
+
"F_scene"
|
| 222 |
+
],
|
| 223 |
+
"anchored_lies": [
|
| 224 |
+
{
|
| 225 |
+
"lie_id": "LIE_alibi",
|
| 226 |
+
"topic": "where you were during the murder",
|
| 227 |
+
"claimed": "I was in the Actor's Studio at all times and have no knowledge of Jasmine Lee's murder.",
|
| 228 |
+
"truth_ref": "F_scene",
|
| 229 |
+
"breaks_on": [
|
| 230 |
+
"C_b1",
|
| 231 |
+
"C_b2"
|
| 232 |
+
],
|
| 233 |
+
"fallback": "All right - I stepped out for a moment, but I had nothing to do with this."
|
| 234 |
+
}
|
| 235 |
+
],
|
| 236 |
+
"voice": null,
|
| 237 |
+
"visual": {
|
| 238 |
+
"subject_type": "suspect",
|
| 239 |
+
"palette": "noir",
|
| 240 |
+
"gender": "female",
|
| 241 |
+
"age_band": "20s",
|
| 242 |
+
"build": null,
|
| 243 |
+
"hair": null,
|
| 244 |
+
"attire": "Flapper dress with a small cocktail dress underlayer for added allure, complete with high heels and a pair of gloves to match her flirtatious persona.",
|
| 245 |
+
"mood": "tense",
|
| 246 |
+
"accent_color": "#3a6ea5",
|
| 247 |
+
"location_tags": [],
|
| 248 |
+
"prop_tags": [],
|
| 249 |
+
"prompt_hint": "Radiant and flirtatious with long black hair tied in a bun, she has perfect dental braces and a bright smile but hides a secret ring on her finger that matches the diamonds she was obsessed with finding., Flapper dress with a small cocktail dress underlayer for added allure, complete with high heels and a pair of gloves to match her flirtatious persona."
|
| 250 |
+
}
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"sus_id": "S3",
|
| 254 |
+
"name": "Dr. Elena Sanchez",
|
| 255 |
+
"role": "Manager of the theater's security department, a woman with no connection to the murder except for a grudging respect for Jasmine's reputation in the industry",
|
| 256 |
+
"persona_summary": "A highly competent and discreet doctor who has only ever been present when Jazzy was performing or at her manager's office.",
|
| 257 |
+
"demeanour": "composed and cooperative on the surface, carefully measured",
|
| 258 |
+
"is_culprit": false,
|
| 259 |
+
"physical_capability": {
|
| 260 |
+
"strength": true,
|
| 261 |
+
"mobility": true
|
| 262 |
+
},
|
| 263 |
+
"personality": {
|
| 264 |
+
"composure": 0.72,
|
| 265 |
+
"aggression": 0.4,
|
| 266 |
+
"evasiveness": 0.3
|
| 267 |
+
},
|
| 268 |
+
"tells": [
|
| 269 |
+
"I was just checking on the security protocols as she was conducting her daily rounds."
|
| 270 |
+
],
|
| 271 |
+
"knows_facts": [
|
| 272 |
+
"F_sec3"
|
| 273 |
+
],
|
| 274 |
+
"secrets": [
|
| 275 |
+
"She had an old habit of adding blood samples from actors' wounds to her medical files, hoping to improve their recovery rates. Her latest sample in Room 3 included a faint hint of Jasmine’s victim's blood."
|
| 276 |
+
],
|
| 277 |
+
"true_whereabouts": [
|
| 278 |
+
{
|
| 279 |
+
"window": {
|
| 280 |
+
"start_min": 1260,
|
| 281 |
+
"end_min": 1320
|
| 282 |
+
},
|
| 283 |
+
"loc_id": "L4",
|
| 284 |
+
"activity": "going about the evening",
|
| 285 |
+
"co_present_sus_ids": []
|
| 286 |
+
}
|
| 287 |
+
],
|
| 288 |
+
"stated_alibi": {
|
| 289 |
+
"claim_text": "I was in Director's Study the whole time.",
|
| 290 |
+
"claimed_segments": [
|
| 291 |
+
{
|
| 292 |
+
"window": {
|
| 293 |
+
"start_min": 1260,
|
| 294 |
+
"end_min": 1320
|
| 295 |
+
},
|
| 296 |
+
"loc_id": "L4",
|
| 297 |
+
"witness_sus_ids": []
|
| 298 |
+
}
|
| 299 |
+
]
|
| 300 |
+
},
|
| 301 |
+
"must_lie_about": [
|
| 302 |
+
"F_sec3"
|
| 303 |
+
],
|
| 304 |
+
"anchored_lies": [
|
| 305 |
+
{
|
| 306 |
+
"lie_id": "LIE_sec3",
|
| 307 |
+
"topic": "She had an old habit of adding blood samples fro",
|
| 308 |
+
"claimed": "She was merely taking notes on Jazzy's daily practice and recovery process for the theater's performance staff.",
|
| 309 |
+
"truth_ref": "F_sec3",
|
| 310 |
+
"breaks_on": [
|
| 311 |
+
"C_h3"
|
| 312 |
+
],
|
| 313 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 314 |
+
}
|
| 315 |
+
],
|
| 316 |
+
"voice": null,
|
| 317 |
+
"visual": {
|
| 318 |
+
"subject_type": "suspect",
|
| 319 |
+
"palette": "noir",
|
| 320 |
+
"gender": "female",
|
| 321 |
+
"age_band": "50s",
|
| 322 |
+
"build": null,
|
| 323 |
+
"hair": null,
|
| 324 |
+
"attire": "White coat and surgical mask as an investigator might expect during the autopsy process but maintains professionalism by wearing a dark suit over it, with a clean appearance despite the stress of the investigation.",
|
| 325 |
+
"mood": "tense",
|
| 326 |
+
"accent_color": "#9a9aa0",
|
| 327 |
+
"location_tags": [],
|
| 328 |
+
"prop_tags": [],
|
| 329 |
+
"prompt_hint": "Serene and composed in a professional white coat with neatly pressed hair, she has sharp green eyes that seem to pierce everyone around her. She exudes a calm demeanor even under suspicion., White coat and surgical mask as an investigator might expect during the autopsy process but maintains professionalism by wearing a dark suit over it, with a clean appearance despite the stress of the investigation."
|
| 330 |
+
}
|
| 331 |
+
},
|
| 332 |
+
{
|
| 333 |
+
"sus_id": "S4",
|
| 334 |
+
"name": "James Baker",
|
| 335 |
+
"role": "Victor's employee, a male friend of both Victor and Jasmine who had been working there for years with no criminal history or conflict.",
|
| 336 |
+
"persona_summary": "A hardworking but reserved man with no secrets from anyone at work. His only interest was helping his boss solve problems.",
|
| 337 |
+
"demeanour": "rattled and evasive, voice tightening under pressure",
|
| 338 |
+
"is_culprit": false,
|
| 339 |
+
"physical_capability": {
|
| 340 |
+
"strength": true,
|
| 341 |
+
"mobility": true
|
| 342 |
+
},
|
| 343 |
+
"personality": {
|
| 344 |
+
"composure": 0.32,
|
| 345 |
+
"aggression": 0.55,
|
| 346 |
+
"evasiveness": 0.68
|
| 347 |
+
},
|
| 348 |
+
"tells": [
|
| 349 |
+
"I was just looking for information about our department’s procedures."
|
| 350 |
+
],
|
| 351 |
+
"knows_facts": [
|
| 352 |
+
"F_sec4"
|
| 353 |
+
],
|
| 354 |
+
"secrets": [
|
| 355 |
+
"He knew the diamonds were stolen but had never found a reason to inform on Victor, afraid of hurting Jasmine's reputation as an actress and manager"
|
| 356 |
+
],
|
| 357 |
+
"true_whereabouts": [
|
| 358 |
+
{
|
| 359 |
+
"window": {
|
| 360 |
+
"start_min": 1260,
|
| 361 |
+
"end_min": 1320
|
| 362 |
+
},
|
| 363 |
+
"loc_id": "L2",
|
| 364 |
+
"activity": "going about the evening",
|
| 365 |
+
"co_present_sus_ids": []
|
| 366 |
+
}
|
| 367 |
+
],
|
| 368 |
+
"stated_alibi": {
|
| 369 |
+
"claim_text": "I was in Actor's Studio the whole time.",
|
| 370 |
+
"claimed_segments": [
|
| 371 |
+
{
|
| 372 |
+
"window": {
|
| 373 |
+
"start_min": 1260,
|
| 374 |
+
"end_min": 1320
|
| 375 |
+
},
|
| 376 |
+
"loc_id": "L2",
|
| 377 |
+
"witness_sus_ids": []
|
| 378 |
+
}
|
| 379 |
+
]
|
| 380 |
+
},
|
| 381 |
+
"must_lie_about": [
|
| 382 |
+
"F_sec4"
|
| 383 |
+
],
|
| 384 |
+
"anchored_lies": [
|
| 385 |
+
{
|
| 386 |
+
"lie_id": "LIE_sec4",
|
| 387 |
+
"topic": "He knew the diamonds were stolen but had never f",
|
| 388 |
+
"claimed": "James merely wanted to help clear the rumors about Victor’s past.",
|
| 389 |
+
"truth_ref": "F_sec4",
|
| 390 |
+
"breaks_on": [
|
| 391 |
+
"C_h4"
|
| 392 |
+
],
|
| 393 |
+
"fallback": "Fine, that part is true - but it has nothing to do with the murder."
|
| 394 |
+
}
|
| 395 |
+
],
|
| 396 |
+
"voice": null,
|
| 397 |
+
"visual": {
|
| 398 |
+
"subject_type": "suspect",
|
| 399 |
+
"palette": "noir",
|
| 400 |
+
"gender": "male",
|
| 401 |
+
"age_band": "30s",
|
| 402 |
+
"build": null,
|
| 403 |
+
"hair": null,
|
| 404 |
+
"attire": "Business suit complete with dark ties and a pocket watch, giving off an air of seriousness but showing no signs of distress or concern. His lack of interaction suggests he is hiding something from everyone.",
|
| 405 |
+
"mood": "guarded",
|
| 406 |
+
"accent_color": "#6b8f71",
|
| 407 |
+
"location_tags": [],
|
| 408 |
+
"prop_tags": [],
|
| 409 |
+
"prompt_hint": "Reliable and dependable with a straight face as he sorts through papers from Room 4, completely unaware of the mystery unfolding around him., Business suit complete with dark ties and a pocket watch, giving off an air of seriousness but showing no signs of distress or concern. His lack of interaction suggests he is hiding something from everyone."
|
| 410 |
+
}
|
| 411 |
+
}
|
| 412 |
+
],
|
| 413 |
+
"culprit": {
|
| 414 |
+
"sus_id": "S2",
|
| 415 |
+
"true_motive": {
|
| 416 |
+
"motive_id": "M1",
|
| 417 |
+
"category": "revenge",
|
| 418 |
+
"summary": "Liliana Rodriguez killed Jasmine Lee because she felt threatened by Jasmine's success and wanted to eliminate her rival."
|
| 419 |
+
},
|
| 420 |
+
"method_narrative": "The murder took place in the Main Lobby around 21:30, with Liliana Rodriguez using a bottle opener as their weapon. The body was then moved into the Actor's Studio where they claimed they had never left it.",
|
| 421 |
+
"alibi_lie": {
|
| 422 |
+
"claimed_loc_id": "L2",
|
| 423 |
+
"actual_loc_id": "L1",
|
| 424 |
+
"contradicted_by_clue_ids": [
|
| 425 |
+
"C_b1",
|
| 426 |
+
"C_b2"
|
| 427 |
+
]
|
| 428 |
+
}
|
| 429 |
+
},
|
| 430 |
+
"relationships": [],
|
| 431 |
+
"facts": [
|
| 432 |
+
{
|
| 433 |
+
"fact_id": "F_scene",
|
| 434 |
+
"statement": "Liliana Rodriguez was in Main Lobby during the murder.",
|
| 435 |
+
"true_value": true,
|
| 436 |
+
"loc_id": "L1",
|
| 437 |
+
"at_min": 1280
|
| 438 |
+
},
|
| 439 |
+
{
|
| 440 |
+
"fact_id": "F_sec1",
|
| 441 |
+
"statement": "The real reason he was there to see her die, as she was the key to securing his share of her fortune, which includes her beloved dog Coco (a black and white poodle)",
|
| 442 |
+
"true_value": true,
|
| 443 |
+
"loc_id": null,
|
| 444 |
+
"at_min": null
|
| 445 |
+
},
|
| 446 |
+
{
|
| 447 |
+
"fact_id": "F_sec2",
|
| 448 |
+
"statement": "The real reason for her jealousy was to frame Jasmine as a liar in front of others, including her boss Victor",
|
| 449 |
+
"true_value": true,
|
| 450 |
+
"loc_id": null,
|
| 451 |
+
"at_min": null
|
| 452 |
+
},
|
| 453 |
+
{
|
| 454 |
+
"fact_id": "F_sec3",
|
| 455 |
+
"statement": "She had an old habit of adding blood samples from actors' wounds to her medical files, hoping to improve their recovery rates. Her latest sample in Room 3 included a faint hint of Jasmine’s victim's blood.",
|
| 456 |
+
"true_value": true,
|
| 457 |
+
"loc_id": null,
|
| 458 |
+
"at_min": null
|
| 459 |
+
},
|
| 460 |
+
{
|
| 461 |
+
"fact_id": "F_sec4",
|
| 462 |
+
"statement": "He knew the diamonds were stolen but had never found a reason to inform on Victor, afraid of hurting Jasmine's reputation as an actress and manager",
|
| 463 |
+
"true_value": true,
|
| 464 |
+
"loc_id": null,
|
| 465 |
+
"at_min": null
|
| 466 |
+
}
|
| 467 |
+
],
|
| 468 |
+
"clues": [
|
| 469 |
+
{
|
| 470 |
+
"clue_id": "C_b1",
|
| 471 |
+
"name": "Scuffed floorboard",
|
| 472 |
+
"reveal_text": "A freshly scuffed floorboard where someone braced against a struggle.",
|
| 473 |
+
"discoverable_at_loc_id": "L1",
|
| 474 |
+
"discovery_method": "forensic",
|
| 475 |
+
"supports_fact_id": "F_scene",
|
| 476 |
+
"points_to_sus_id": "S2",
|
| 477 |
+
"contradicts_alibi_of": "S2",
|
| 478 |
+
"is_red_herring": false,
|
| 479 |
+
"weight": 1.0
|
| 480 |
+
},
|
| 481 |
+
{
|
| 482 |
+
"clue_id": "C_b2",
|
| 483 |
+
"name": "Snagged thread",
|
| 484 |
+
"reveal_text": "A torn thread of dark cloth snagged on the main lobby doorframe.",
|
| 485 |
+
"discoverable_at_loc_id": "L2",
|
| 486 |
+
"discovery_method": "forensic",
|
| 487 |
+
"supports_fact_id": "F_scene",
|
| 488 |
+
"points_to_sus_id": "S2",
|
| 489 |
+
"contradicts_alibi_of": "S2",
|
| 490 |
+
"is_red_herring": false,
|
| 491 |
+
"weight": 0.7
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
"clue_id": "C_h1",
|
| 495 |
+
"name": "Old photograph",
|
| 496 |
+
"reveal_text": "A worn photograph they would rather no one had seen.",
|
| 497 |
+
"discoverable_at_loc_id": "L3",
|
| 498 |
+
"discovery_method": "search",
|
| 499 |
+
"supports_fact_id": "F_sec1",
|
| 500 |
+
"points_to_sus_id": "S1",
|
| 501 |
+
"contradicts_alibi_of": null,
|
| 502 |
+
"is_red_herring": true,
|
| 503 |
+
"weight": 0.3
|
| 504 |
+
},
|
| 505 |
+
{
|
| 506 |
+
"clue_id": "C_h3",
|
| 507 |
+
"name": "A torn piece of paper with bloodstains from an actor's wound, matching her file exactly",
|
| 508 |
+
"reveal_text": "The note is found as she leaves Room 3 after the autopsy, leading investigators to pursue it through security records.",
|
| 509 |
+
"discoverable_at_loc_id": "L4",
|
| 510 |
+
"discovery_method": "search",
|
| 511 |
+
"supports_fact_id": "F_sec3",
|
| 512 |
+
"points_to_sus_id": "S3",
|
| 513 |
+
"contradicts_alibi_of": null,
|
| 514 |
+
"is_red_herring": true,
|
| 515 |
+
"weight": 0.3
|
| 516 |
+
},
|
| 517 |
+
{
|
| 518 |
+
"clue_id": "C_h4",
|
| 519 |
+
"name": "A receipt for diamond purchases matching those stolen from Jasmine’s jewelry box in Room 4",
|
| 520 |
+
"reveal_text": "The receipt is found while James was sorting through files, revealing his involvement in the theft but not knowing the full extent of it.",
|
| 521 |
+
"discoverable_at_loc_id": "L2",
|
| 522 |
+
"discovery_method": "search",
|
| 523 |
+
"supports_fact_id": "F_sec4",
|
| 524 |
+
"points_to_sus_id": "S4",
|
| 525 |
+
"contradicts_alibi_of": null,
|
| 526 |
+
"is_red_herring": true,
|
| 527 |
+
"weight": 0.3
|
| 528 |
+
}
|
| 529 |
+
],
|
| 530 |
+
"solution": {
|
| 531 |
+
"culprit_sus_id": "S2",
|
| 532 |
+
"weapon_id": "W1",
|
| 533 |
+
"motive_id": "M1",
|
| 534 |
+
"minimal_clue_set": [
|
| 535 |
+
"C_b1"
|
| 536 |
+
],
|
| 537 |
+
"deduction_chain": [
|
| 538 |
+
"Liliana Rodriguez used the bottle opener as their weapon during the murder.",
|
| 539 |
+
"The stained bottle opener was found in the Main Lobby around 21:30, suggesting a timeframe consistent with the time of death.",
|
| 540 |
+
"A fingerprint on the bottle opener matches mine, implicating me in Jasmine Lee's murder."
|
| 541 |
+
]
|
| 542 |
+
}
|
| 543 |
+
}
|
cases/seeds/GRAYMOOR-3107.json
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "GRAYMOOR-3107",
|
| 3 |
+
"city": "GRAYMOOR",
|
| 4 |
+
"district": "HARBOR DISTRICT - 7TH PRECINCT",
|
| 5 |
+
"title": "THE ATRIUM FALL",
|
| 6 |
+
"tagline": "Detective, your presence is required.",
|
| 7 |
+
"weather": "Rain. 4C. Wind off the water.",
|
| 8 |
+
"victim": {
|
| 9 |
+
"name": "MARA VANCE",
|
| 10 |
+
"role": "Principal Architect - Graymoor Maritime Museum",
|
| 11 |
+
"age": 47,
|
| 12 |
+
"sprite": "victim",
|
| 13 |
+
"bio": "Designed half the new waterfront. Tonight the Maritime Museum opened to the city. By midnight she was dead on its atrium floor."
|
| 14 |
+
},
|
| 15 |
+
"scene": "North Atrium - Graymoor Maritime Museum",
|
| 16 |
+
"tod": "11:48 PM",
|
| 17 |
+
"found": "Opening gala. Found by a caterer at 11:52 PM, twelve meters below the mezzanine rail.",
|
| 18 |
+
"cause": "Fall from the third-floor mezzanine. Rail height: 1.1m. She did not climb it alone.",
|
| 19 |
+
"facts": [
|
| 20 |
+
["CITY", "Graymoor"],
|
| 21 |
+
["VICTIM", "Mara Vance, 47"],
|
| 22 |
+
["SCENE", "Maritime Museum, North Atrium"],
|
| 23 |
+
["TIME OF DEATH", "11:48 PM"],
|
| 24 |
+
["CAUSE", "Fall - 12m"],
|
| 25 |
+
["VERDICT", "Homicide (staged)"]
|
| 26 |
+
],
|
| 27 |
+
"bootLines": [
|
| 28 |
+
"02:14. The phone drags you up out of half a sleep.",
|
| 29 |
+
"Harbor District. The new Maritime Museum, opening night.",
|
| 30 |
+
"A woman went over the mezzanine rail during the gala.",
|
| 31 |
+
"Mara Vance. The architect. She drew the building she died in.",
|
| 32 |
+
"Twelve meters down - past a rail she could not have cleared alone.",
|
| 33 |
+
"Four guests are still inside. None of them are leaving.",
|
| 34 |
+
"The rain hasn't let up in three days.",
|
| 35 |
+
"They're holding the scene. It's yours now, detective."
|
| 36 |
+
],
|
| 37 |
+
"storyBeats": [
|
| 38 |
+
{ "scene": "skyline", "kicker": "GRAYMOOR - 11:52 PM", "title": "A city that never dries out",
|
| 39 |
+
"text": "Three days of rain off the harbor, and tonight the whole city came to the waterfront anyway - to see the room Graymoor had been promising itself for a decade." },
|
| 40 |
+
{ "scene": "atrium", "kicker": "THE MARITIME MUSEUM", "title": "Opening night",
|
| 41 |
+
"text": "Glass and steel rising over black water. Champagne on the atrium floor, donors on the mezzanine. The proudest new room in Graymoor, and every important name was in it." },
|
| 42 |
+
{ "scene": "atrium", "kicker": "THE ARCHITECT", "title": "Mara Vance",
|
| 43 |
+
"text": "She designed all of it - half the new waterfront bears her hand. Tonight was supposed to be her crown. At midnight she was meant to take the podium and say something the whole city would remember." },
|
| 44 |
+
{ "scene": "mezzanine", "kicker": "11:48 PM", "title": "The fall",
|
| 45 |
+
"text": "She never reached the podium. At 11:48 she went over the third-floor mezzanine rail - twelve meters down to the atrium floor. Under the music, the gala never even heard it." },
|
| 46 |
+
{ "scene": "interro", "kicker": "FOUR STAYED", "title": "The ones who didn't run",
|
| 47 |
+
"text": "When the sirens came, the crowd scattered into the rain. Four did not. A financier. Her partner of twenty years. The night-security man. The museum's curator. Each had a reason to be near that rail." },
|
| 48 |
+
{ "scene": "seawall", "kicker": "YOUR CASE NOW", "title": "Detective",
|
| 49 |
+
"text": "They're holding the scene for you. The rail didn't push her - someone did. One of those four is going to lie straight to your face. Find the crack, and follow it all the way down." }
|
| 50 |
+
],
|
| 51 |
+
"suspects": [
|
| 52 |
+
{
|
| 53 |
+
"id": "wexler", "name": "DESMOND WEXLER", "role": "Board Chairman & Financier", "age": 58,
|
| 54 |
+
"sprite": "wexler", "gender": "male", "tag": "THE MONEY", "suspicion": 30,
|
| 55 |
+
"motive": "Vance traced a hole in the construction fund straight to his accounts.",
|
| 56 |
+
"alibi": "Says he was on the gala floor giving the patron toast until midnight.",
|
| 57 |
+
"quote": "I built this museum with my checkbook. Why would I throw my own architect off it?",
|
| 58 |
+
"greet": "Detective. Make it quick - I have a board to reassure and a city watching.",
|
| 59 |
+
"default": "I answer to a foundation, not to you. Ask something that matters.",
|
| 60 |
+
"questions": [
|
| 61 |
+
{ "id": "q0", "q": "Where were you at 11:48?", "a": "On the floor, glass in hand. Three hundred people saw Desmond Wexler smiling. That is called an alibi.", "d": 2 },
|
| 62 |
+
{ "id": "q1", "q": "You and Vance - money trouble?", "a": "Creative people don't understand budgets. We had... disagreements. Disagreements are not murder.", "d": 9 },
|
| 63 |
+
{ "id": "q2", "q": "What was she going to announce at midnight?", "a": "The endowment, the donors, the usual theatre. Nothing about me. Nothing.", "d": 12 },
|
| 64 |
+
{ "id": "q3", "q": "Who wanted her gone?", "a": "Ask Calloway. The partnership was a sinking boat and Mara was the one bailing water.", "d": -3 },
|
| 65 |
+
{ "id": "q4", "q": "You seem very calm for a man whose architect just died.", "a": "I have buried partners before, detective. Grief is a private line item.", "d": 6 }
|
| 66 |
+
],
|
| 67 |
+
"present": {
|
| 68 |
+
"keycard": { "a": "My card never left the gala floor. Check it. You'll see I'm telling the truth - for once someone is.", "d": 4 },
|
| 69 |
+
"thread": { "a": "Those are Mara's words, not mine. She accused everyone that last week. Paranoid.", "d": 8 },
|
| 70 |
+
"receipt": { "a": "The bar? I don't drink at my own openings. That's not my signature.", "d": 2 },
|
| 71 |
+
"cctv": { "a": "That coat isn't mine. I wore black tie. Three hundred witnesses, remember.", "d": 1 },
|
| 72 |
+
"voicemail": { "a": "She left that for Calloway, not me. Listen to who she's threatening. It isn't Desmond Wexler.", "d": -2 },
|
| 73 |
+
"photo": { "a": "A drafting compass. Hers. She carried it everywhere - habit from the drawing board.", "d": 1 }
|
| 74 |
+
}
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"id": "iris", "name": "IRIS CALLOWAY", "role": "Co-Architect & Partner", "age": 41,
|
| 78 |
+
"sprite": "iris", "gender": "female", "tag": "THE PARTNER", "suspicion": 25,
|
| 79 |
+
"motive": "Vance was dissolving the firm at midnight - and taking sole credit for the museum.",
|
| 80 |
+
"alibi": "Claims she left the gala at 11:20 and walked the seawall to clear her head.",
|
| 81 |
+
"quote": "Twenty years I drew beside her. You think I'd erase that with one push?",
|
| 82 |
+
"greet": "I keep seeing her on the floor when I close my eyes. Ask me before I lose the nerve.",
|
| 83 |
+
"default": "I don't- I can't think straight. Please. What do you actually want to know?",
|
| 84 |
+
"questions": [
|
| 85 |
+
{ "id": "q0", "q": "Where were you at 11:48?", "a": "On the seawall. Alone. I needed air. I know how that sounds. I know.", "d": 8 },
|
| 86 |
+
{ "id": "q1", "q": "You left at 11:20 - you're sure?", "a": "Around then. Maybe later. It was a blur. The champagne, the noise...", "d": 14 },
|
| 87 |
+
{ "id": "q2", "q": "What happens to the firm now?", "a": "Don't. Don't make tonight about the firm. ... It was being dissolved. There. You happy?", "d": 16 },
|
| 88 |
+
{ "id": "q3", "q": "Did you go up to the mezzanine?", "a": "No. Why would I- no. I was never up there. I told you. The seawall.", "d": 18 },
|
| 89 |
+
{ "id": "q4", "q": "What did her last message say?", "a": "I didn't read it in time. I swear I didn't read it until after.", "d": 12 }
|
| 90 |
+
],
|
| 91 |
+
"present": {
|
| 92 |
+
"receipt": { "a": "That- that's from the bar, yes, but I left right after. One drink. I told you, one drink and I left.", "d": 16 },
|
| 93 |
+
"cctv": { "a": "That coat- lots of people have a pale coat. That isn't- you can't even see a face. That proves nothing.", "d": 20 },
|
| 94 |
+
"keycard": { "a": "My card at the service door at 11:41? No. Someone took it. Someone must have- I dropped it, maybe-", "d": 26 },
|
| 95 |
+
"voicemail": { "a": "(she goes very still) ...\"Meet me on the mezzanine at midnight. We end this properly.\" I never went. I never-", "d": 24 },
|
| 96 |
+
"thread": { "a": "We fought. Partners fight. You don't kill someone over a building, detective.", "d": 14 },
|
| 97 |
+
"photo": { "a": "Her compass. She'd have wanted that buried with her. ... Why is it on the floor and not in her bag?", "d": 10 }
|
| 98 |
+
}
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"id": "teo", "name": "TEO MARCHETTI", "role": "Night Security Supervisor", "age": 35,
|
| 102 |
+
"sprite": "teo", "gender": "male", "tag": "THE EX", "suspicion": 35,
|
| 103 |
+
"motive": "He and Vance were involved. She ended it cold three weeks ago.",
|
| 104 |
+
"alibi": "Says he was in the security office watching the camera bank all night.",
|
| 105 |
+
"quote": "I loved her. That's not a motive, that's a sentence I'm already serving.",
|
| 106 |
+
"greet": "You're going to look at me first. The ex, the guard with the keys. Go on. Get it over with.",
|
| 107 |
+
"default": "I watched those monitors all night. I watch everything. That's the job.",
|
| 108 |
+
"questions": [
|
| 109 |
+
{ "id": "q0", "q": "Where were you at 11:48?", "a": "Security office. The whole wall of screens was mine. I saw the gala. I didn't see her fall - wrong camera.", "d": 6 },
|
| 110 |
+
{ "id": "q1", "q": "You and Vance were together?", "a": "Were. Past tense, she made sure of that. Three weeks ago. A text. Twenty years she gave the firm and me a text.", "d": 11 },
|
| 111 |
+
{ "id": "q2", "q": "Who has keycard access to the mezzanine?", "a": "Staff. Me. The architects. It's a service door - half the building can open it.", "d": 4 },
|
| 112 |
+
{ "id": "q3", "q": "The atrium camera - what did it catch?", "a": "It... glitched. 11:40 to 11:50. Ten minutes of static. I logged it. I swear I logged it.", "d": 16 },
|
| 113 |
+
{ "id": "q4", "q": "Did you go near the atrium?", "a": "No. I stayed at my post. I had no reason to see her. Seeing her is the thing I'm trying to stop doing.", "d": 9 }
|
| 114 |
+
],
|
| 115 |
+
"present": {
|
| 116 |
+
"cctv": { "a": "See? The one still that survived. A pale coat. That's not me - I was in uniform. Black. Check the still.", "d": 6 },
|
| 117 |
+
"keycard": { "a": "My card logs all night at the office door. I never went up. The 11:41 swipe isn't mine - different door, different card.", "d": 4 },
|
| 118 |
+
"voicemail": { "a": "That wasn't left for me. She stopped leaving me anything weeks ago.", "d": 5 },
|
| 119 |
+
"thread": { "a": "Read your own evidence. Those texts aren't my number. She was fighting with her partner, not with me.", "d": 3 },
|
| 120 |
+
"receipt": { "a": "The bar receipt's signed I.C. That's not Teo Marchetti. I was sober at my post.", "d": 2 },
|
| 121 |
+
"photo": { "a": "Her compass on the floor. She never dropped that. Whoever was up there knocked it from her hand.", "d": 7 }
|
| 122 |
+
}
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
"id": "frost", "name": "DR. HELENA FROST", "role": "Museum Director & Curator", "age": 49,
|
| 126 |
+
"sprite": "frost", "gender": "female", "tag": "THE CURATOR", "suspicion": 28,
|
| 127 |
+
"motive": "Vance discovered forged provenance on the museum's centerpiece collection.",
|
| 128 |
+
"alibi": "Says she was greeting donors at the east entrance, never left the ground floor.",
|
| 129 |
+
"quote": "I curate the truth of objects, detective. I would never let a lie hang in my own house.",
|
| 130 |
+
"greet": "Twelve years assembling this collection, and the city will remember it as the night someone died. Ask.",
|
| 131 |
+
"default": "I deal in provenance - where a thing came from, who can vouch for it. So vouch for your questions.",
|
| 132 |
+
"questions": [
|
| 133 |
+
{ "id": "q0", "q": "Where were you at 11:48?", "a": "East entrance, receiving the cultural minister. A dozen donors will confirm it. I never touched the north stair.", "d": 4 },
|
| 134 |
+
{ "id": "q1", "q": "The collection - any disputes with Vance?", "a": "Architects and curators always disagree about light and labels. Nothing that ends in a fall.", "d": 10 },
|
| 135 |
+
{ "id": "q2", "q": "Did she question any of the artifacts?", "a": "(a pause) She asked about provenance on the flagship pieces. I gave her documents. She was... unsatisfied.", "d": 14 },
|
| 136 |
+
{ "id": "q3", "q": "What would exposure have cost you?", "a": "My reputation is the museum's reputation. But I had paperwork. Paper beats accusation, detective.", "d": 12 },
|
| 137 |
+
{ "id": "q4", "q": "Where is that paperwork now?", "a": "Filed. Safe. ... Some of it I was still - assembling. These things take time to verify properly.", "d": 13 }
|
| 138 |
+
],
|
| 139 |
+
"present": {
|
| 140 |
+
"photo": { "a": "A drafting compass. Hers, obviously. I fail to see what an architect's tool tells you about a curator.", "d": 5 },
|
| 141 |
+
"thread": { "a": "Those messages are between Vance and her partner. I am nowhere in them. Read carefully.", "d": 3 },
|
| 142 |
+
"voicemail": { "a": "A midnight meeting on the mezzanine. Not with me - I was at the east door all night. Ask the minister.", "d": 4 },
|
| 143 |
+
"keycard": { "a": "My access is logged to the gallery wing, not the atrium service door. I curate; I don't prowl stairwells.", "d": 3 },
|
| 144 |
+
"cctv": { "a": "A pale figure. I wore charcoal. The coat in that still is too long for me. Look at the proportions.", "d": 4 },
|
| 145 |
+
"receipt": { "a": "I don't frequent the bar during a gala I'm hosting. And that signature is not mine.", "d": 2 }
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
],
|
| 149 |
+
"evidence": [
|
| 150 |
+
{
|
| 151 |
+
"id": "thread", "name": "MESSAGE THREAD", "type": "PHONE", "icon": "phone", "time": "11:02 PM",
|
| 152 |
+
"found": "Recovered from victim's phone",
|
| 153 |
+
"summary": "Heated exchange between Vance and \"I.C.\" in the hour before her death.",
|
| 154 |
+
"thread": [
|
| 155 |
+
{ "from": "them", "who": "I.C.", "t": "10:51", "m": "You can't announce the dissolution tonight. Not like this. Not in front of the whole city." },
|
| 156 |
+
{ "from": "me", "who": "VANCE", "t": "10:53", "m": "It's done, Iris. The papers are signed. The museum is mine alone now." },
|
| 157 |
+
{ "from": "them", "who": "I.C.", "t": "10:55", "m": "Twenty years. You'd erase twenty years at a podium with champagne in your hand." },
|
| 158 |
+
{ "from": "me", "who": "VANCE", "t": "10:58", "m": "Come up to the mezzanine before midnight. I'll let you hear it from me first. That's the courtesy I owe you." },
|
| 159 |
+
{ "from": "them", "who": "I.C.", "t": "11:02", "m": "Don't do this to me. Please. I'm begging you, Mara." }
|
| 160 |
+
]
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
"id": "receipt", "name": "TORN BAR RECEIPT", "type": "PAPER", "icon": "receipt", "time": "11:09 PM",
|
| 164 |
+
"found": "Found crumpled in atrium planter",
|
| 165 |
+
"summary": "Atrium Bar tab. Two glasses, signed I.C. Timestamp contradicts her stated exit.",
|
| 166 |
+
"detail": "ATRIUM BAR - GALA\n2x Amaro, neat\n23:09\nSIGNED: I. Calloway\n- she said she left at 11:20, sober, alone."
|
| 167 |
+
},
|
| 168 |
+
{
|
| 169 |
+
"id": "cctv", "name": "CCTV STILL", "type": "IMAGE", "icon": "cctv", "time": "11:43 PM",
|
| 170 |
+
"found": "North mezzanine cam - sole surviving frame",
|
| 171 |
+
"summary": "A pale-coated figure at the mezzanine rail, five minutes before the fall.",
|
| 172 |
+
"detail": "CAM 7 / NORTH MEZZANINE\n23:43:11\n[feed corrupt 23:40-23:50 - 1 frame recovered]\nFigure: pale long coat, at the rail. Face not visible."
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"id": "voicemail", "name": "LAST VOICEMAIL", "type": "AUDIO", "icon": "voicemail", "time": "11:31 PM",
|
| 176 |
+
"found": "Left on I. Calloway's phone - unheard until 12:14 AM",
|
| 177 |
+
"summary": "Vance's final recorded words. A request to meet on the mezzanine at midnight.",
|
| 178 |
+
"transcript": "\"Iris, it's me. Meet me on the mezzanine at midnight - before the announcement. We end this properly, you and I. ... I owe you that much. Come alone.\"",
|
| 179 |
+
"dur": "0:19"
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
"id": "keycard", "name": "KEYCARD LOG", "type": "DATA", "icon": "keycard", "time": "11:41 PM",
|
| 183 |
+
"found": "Building access export",
|
| 184 |
+
"summary": "Mezzanine service door, swiped at 11:41 PM. The card belongs to I. Calloway.",
|
| 185 |
+
"rows": [
|
| 186 |
+
["23:18", "EAST ENTRANCE", "H. FROST", "ok"],
|
| 187 |
+
["23:20", "ATRIUM FLOOR", "I. CALLOWAY", "ok"],
|
| 188 |
+
["23:41", "MEZZ SERVICE DOOR", "I. CALLOWAY", "flag"],
|
| 189 |
+
["23:44", "SECURITY OFFICE", "T. MARCHETTI", "ok"],
|
| 190 |
+
["23:59", "GALA FLOOR", "D. WEXLER", "ok"]
|
| 191 |
+
]
|
| 192 |
+
},
|
| 193 |
+
{
|
| 194 |
+
"id": "photo", "name": "SCENE PHOTO", "type": "IMAGE", "icon": "photoEv", "time": "11:55 PM",
|
| 195 |
+
"found": "Forensics - atrium floor",
|
| 196 |
+
"summary": "A brass drafting compass beside the body - knocked from her hand, not dropped.",
|
| 197 |
+
"detail": "ATRIUM FLOOR / EVIDENCE MARKER 3\nBrass drafting compass, victim's.\nLanded 2.1m from body - consistent with a struggle at the rail, not a fall."
|
| 198 |
+
}
|
| 199 |
+
],
|
| 200 |
+
"timeline": [
|
| 201 |
+
{ "time": "9:00", "label": "Gala opens. Vance gives no public remarks.", "locked": true },
|
| 202 |
+
{ "time": "10:51", "label": "Vance & Calloway begin arguing by message.", "ev": "thread" },
|
| 203 |
+
{ "time": "11:09", "label": "Calloway at the Atrium Bar - two drinks.", "ev": "receipt" },
|
| 204 |
+
{ "time": "11:20", "label": "Calloway's card opens the atrium floor.", "ev": "keycard" },
|
| 205 |
+
{ "time": "11:31", "label": "Vance leaves voicemail: meet at midnight.", "ev": "voicemail" },
|
| 206 |
+
{ "time": "11:41", "label": "Mezzanine service door - Calloway's card.", "ev": "keycard", "conflict": true },
|
| 207 |
+
{ "time": "11:43", "label": "Pale-coated figure at the rail (CCTV).", "ev": "cctv", "conflict": true },
|
| 208 |
+
{ "time": "11:48", "label": "Mara Vance falls. Time of death.", "locked": true },
|
| 209 |
+
{ "time": "11:55", "label": "Compass found 2m from body - struggle.", "ev": "photo" }
|
| 210 |
+
],
|
| 211 |
+
"flashback": {
|
| 212 |
+
"title": "THE MEZZANINE - TWO ACCOUNTS",
|
| 213 |
+
"a": { "who": "IRIS CALLOWAY SAYS", "scene": "seawall", "lines": [
|
| 214 |
+
"I left at 11:20. I walked the seawall alone.",
|
| 215 |
+
"I never went up. I never saw her again.",
|
| 216 |
+
"The card at the service door wasn't me."
|
| 217 |
+
], "flags": [] },
|
| 218 |
+
"b": { "who": "THE EVIDENCE SAYS", "scene": "mezzanine", "lines": [
|
| 219 |
+
"Her card opened the mezzanine door at 11:41.",
|
| 220 |
+
"A pale coat stood at the rail at 11:43.",
|
| 221 |
+
"Her compass landed two meters out - a struggle."
|
| 222 |
+
], "flags": [1, 2] }
|
| 223 |
+
},
|
| 224 |
+
"motives": [
|
| 225 |
+
{ "id": "m_credit", "text": "To stop the firm's dissolution and the erasure of her career" },
|
| 226 |
+
{ "id": "m_money", "text": "To bury evidence of skimming the construction fund" },
|
| 227 |
+
{ "id": "m_love", "text": "Jealousy - a relationship ended coldly" },
|
| 228 |
+
{ "id": "m_forgery", "text": "To silence exposure of forged provenance" }
|
| 229 |
+
],
|
| 230 |
+
"sealed": {
|
| 231 |
+
"killer": "iris",
|
| 232 |
+
"correctMotive": "m_credit",
|
| 233 |
+
"keyEvidence": ["keycard", "voicemail", "cctv"],
|
| 234 |
+
"truth": "It was Iris Calloway. Twenty years of partnership, dissolved at a podium with the city watching. She answered the voicemail, climbed the service stair at 11:41, and met Mara at the rail. The argument that began in messages ended there. The compass fell from Mara's hand as she reached for the woman who'd drawn beside her half her life."
|
| 235 |
+
}
|
| 236 |
+
}
|
cases/seeds/tutorial.json
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"case_id": "tutorial-gilded-aerie",
|
| 3 |
+
"seed": 1989,
|
| 4 |
+
"schema_version": "1.0",
|
| 5 |
+
"title": "The Gilded Aerie",
|
| 6 |
+
"briefing": "1924. On the rooftop of the Gilded Aerie supper club, owner Cornelius Vane is found dead in his office, struck down with his own brass award. The club was full; everyone had a reason to resent him. Four people could have slipped away during the murder hour. Question them, search the rooms, and present what you find. One of them is lying about where they were - catch that lie, and you have your killer.",
|
| 7 |
+
"knobs": {
|
| 8 |
+
"setting_hint": "rooftop supper club",
|
| 9 |
+
"era_hint": "1924",
|
| 10 |
+
"tone_hint": "jazz-age noir",
|
| 11 |
+
"n_suspects": 4,
|
| 12 |
+
"n_red_herrings": 3,
|
| 13 |
+
"alibi_tightness": 0.6,
|
| 14 |
+
"difficulty": "gentle"
|
| 15 |
+
},
|
| 16 |
+
"setting": {
|
| 17 |
+
"name": "The Gilded Aerie",
|
| 18 |
+
"description": "A rooftop supper club above the city.",
|
| 19 |
+
"locations": [
|
| 20 |
+
{
|
| 21 |
+
"loc_id": "L1",
|
| 22 |
+
"name": "The Lounge",
|
| 23 |
+
"description": "The main club floor.",
|
| 24 |
+
"adjacent_to": [
|
| 25 |
+
"L2",
|
| 26 |
+
"L3",
|
| 27 |
+
"L5"
|
| 28 |
+
]
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"loc_id": "L2",
|
| 32 |
+
"name": "The Kitchen",
|
| 33 |
+
"description": "",
|
| 34 |
+
"adjacent_to": [
|
| 35 |
+
"L1"
|
| 36 |
+
]
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"loc_id": "L3",
|
| 40 |
+
"name": "The Terrace",
|
| 41 |
+
"description": "The open rooftop stage.",
|
| 42 |
+
"adjacent_to": [
|
| 43 |
+
"L1"
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"loc_id": "L4",
|
| 48 |
+
"name": "The Office",
|
| 49 |
+
"description": "Vane's private office.",
|
| 50 |
+
"adjacent_to": [
|
| 51 |
+
"L5"
|
| 52 |
+
]
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"loc_id": "L5",
|
| 56 |
+
"name": "The Cloakroom",
|
| 57 |
+
"description": "",
|
| 58 |
+
"adjacent_to": [
|
| 59 |
+
"L1",
|
| 60 |
+
"L4"
|
| 61 |
+
]
|
| 62 |
+
}
|
| 63 |
+
],
|
| 64 |
+
"murder_window": {
|
| 65 |
+
"start_min": 1260,
|
| 66 |
+
"end_min": 1320
|
| 67 |
+
}
|
| 68 |
+
},
|
| 69 |
+
"victim": {
|
| 70 |
+
"vic_id": "V1",
|
| 71 |
+
"name": "Cornelius Vane",
|
| 72 |
+
"role": "club owner",
|
| 73 |
+
"found_at_loc_id": "L4",
|
| 74 |
+
"found_at_min": 1325,
|
| 75 |
+
"cause_of_death": "blunt force from a brass statuette",
|
| 76 |
+
"time_of_death": {
|
| 77 |
+
"start_min": 1280,
|
| 78 |
+
"end_min": 1310
|
| 79 |
+
}
|
| 80 |
+
},
|
| 81 |
+
"weapon": {
|
| 82 |
+
"weapon_id": "W1",
|
| 83 |
+
"name": "brass award statuette",
|
| 84 |
+
"kind": "blunt object",
|
| 85 |
+
"origin_loc_id": "L4",
|
| 86 |
+
"requires_strength": false,
|
| 87 |
+
"leaves_trace": "blood and a dented base"
|
| 88 |
+
},
|
| 89 |
+
"suspects": [
|
| 90 |
+
{
|
| 91 |
+
"sus_id": "S1",
|
| 92 |
+
"name": "Margot Vane",
|
| 93 |
+
"role": "the widow and co-owner",
|
| 94 |
+
"persona_summary": "Poised and imperious in mourning black; clipped, controlled speech, and she resents every question.",
|
| 95 |
+
"is_culprit": true,
|
| 96 |
+
"physical_capability": {
|
| 97 |
+
"strength": true,
|
| 98 |
+
"mobility": true
|
| 99 |
+
},
|
| 100 |
+
"personality": {
|
| 101 |
+
"composure": 0.8,
|
| 102 |
+
"aggression": 0.5,
|
| 103 |
+
"evasiveness": 0.7
|
| 104 |
+
},
|
| 105 |
+
"tells": [
|
| 106 |
+
"a too-steady voice",
|
| 107 |
+
"smoothing her gloves"
|
| 108 |
+
],
|
| 109 |
+
"knows_facts": [
|
| 110 |
+
"F1",
|
| 111 |
+
"F2"
|
| 112 |
+
],
|
| 113 |
+
"secrets": [
|
| 114 |
+
"Cornelius was about to cut you out of the club; his death secures everything."
|
| 115 |
+
],
|
| 116 |
+
"true_whereabouts": [
|
| 117 |
+
{
|
| 118 |
+
"window": {
|
| 119 |
+
"start_min": 1260,
|
| 120 |
+
"end_min": 1280
|
| 121 |
+
},
|
| 122 |
+
"loc_id": "L1",
|
| 123 |
+
"activity": "greeting patrons in the lounge",
|
| 124 |
+
"co_present_sus_ids": [
|
| 125 |
+
"S2"
|
| 126 |
+
]
|
| 127 |
+
},
|
| 128 |
+
{
|
| 129 |
+
"window": {
|
| 130 |
+
"start_min": 1280,
|
| 131 |
+
"end_min": 1310
|
| 132 |
+
},
|
| 133 |
+
"loc_id": "L4",
|
| 134 |
+
"activity": "confronting Cornelius in the office",
|
| 135 |
+
"co_present_sus_ids": []
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"window": {
|
| 139 |
+
"start_min": 1310,
|
| 140 |
+
"end_min": 1320
|
| 141 |
+
},
|
| 142 |
+
"loc_id": "L5",
|
| 143 |
+
"activity": "composing herself in the cloakroom",
|
| 144 |
+
"co_present_sus_ids": []
|
| 145 |
+
}
|
| 146 |
+
],
|
| 147 |
+
"stated_alibi": {
|
| 148 |
+
"claim_text": "I was in the lounge the entire evening, in plain sight of a dozen guests.",
|
| 149 |
+
"claimed_segments": [
|
| 150 |
+
{
|
| 151 |
+
"window": {
|
| 152 |
+
"start_min": 1260,
|
| 153 |
+
"end_min": 1320
|
| 154 |
+
},
|
| 155 |
+
"loc_id": "L1",
|
| 156 |
+
"witness_sus_ids": [
|
| 157 |
+
"S2"
|
| 158 |
+
]
|
| 159 |
+
}
|
| 160 |
+
]
|
| 161 |
+
},
|
| 162 |
+
"must_lie_about": [
|
| 163 |
+
"F1"
|
| 164 |
+
],
|
| 165 |
+
"anchored_lies": [
|
| 166 |
+
{
|
| 167 |
+
"lie_id": "LIE_alibi",
|
| 168 |
+
"topic": "where you were during the murder",
|
| 169 |
+
"claimed": "I never once left the lounge; ask any of the patrons.",
|
| 170 |
+
"truth_ref": "F1",
|
| 171 |
+
"breaks_on": [
|
| 172 |
+
"C1",
|
| 173 |
+
"C2"
|
| 174 |
+
],
|
| 175 |
+
"fallback": "Fine - I stepped out to the cloakroom for my wrap, that is all. I never went near the office."
|
| 176 |
+
}
|
| 177 |
+
],
|
| 178 |
+
"voice": null,
|
| 179 |
+
"visual": {
|
| 180 |
+
"subject_type": "suspect",
|
| 181 |
+
"palette": "noir",
|
| 182 |
+
"gender": "female",
|
| 183 |
+
"age_band": "50s",
|
| 184 |
+
"build": null,
|
| 185 |
+
"hair": null,
|
| 186 |
+
"attire": "a black beaded evening gown",
|
| 187 |
+
"mood": "imperious",
|
| 188 |
+
"accent_color": "#b8860b",
|
| 189 |
+
"location_tags": [],
|
| 190 |
+
"prop_tags": [],
|
| 191 |
+
"prompt_hint": ""
|
| 192 |
+
}
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
"sus_id": "S2",
|
| 196 |
+
"name": "Eddie Marsh",
|
| 197 |
+
"role": "the bartender",
|
| 198 |
+
"persona_summary": "Quick and affable, sweats easily, and keeps glancing at the till.",
|
| 199 |
+
"is_culprit": false,
|
| 200 |
+
"physical_capability": {
|
| 201 |
+
"strength": true,
|
| 202 |
+
"mobility": true
|
| 203 |
+
},
|
| 204 |
+
"personality": {
|
| 205 |
+
"composure": 0.3,
|
| 206 |
+
"aggression": 0.2,
|
| 207 |
+
"evasiveness": 0.6
|
| 208 |
+
},
|
| 209 |
+
"tells": [
|
| 210 |
+
"wiping a bar that is already clean"
|
| 211 |
+
],
|
| 212 |
+
"knows_facts": [
|
| 213 |
+
"F3"
|
| 214 |
+
],
|
| 215 |
+
"secrets": [
|
| 216 |
+
"You have been skimming from the till for months."
|
| 217 |
+
],
|
| 218 |
+
"true_whereabouts": [
|
| 219 |
+
{
|
| 220 |
+
"window": {
|
| 221 |
+
"start_min": 1260,
|
| 222 |
+
"end_min": 1320
|
| 223 |
+
},
|
| 224 |
+
"loc_id": "L1",
|
| 225 |
+
"activity": "tending the bar",
|
| 226 |
+
"co_present_sus_ids": [
|
| 227 |
+
"S1",
|
| 228 |
+
"S4"
|
| 229 |
+
]
|
| 230 |
+
}
|
| 231 |
+
],
|
| 232 |
+
"stated_alibi": {
|
| 233 |
+
"claim_text": "I never left the bar; somebody always wanted a drink.",
|
| 234 |
+
"claimed_segments": [
|
| 235 |
+
{
|
| 236 |
+
"window": {
|
| 237 |
+
"start_min": 1260,
|
| 238 |
+
"end_min": 1320
|
| 239 |
+
},
|
| 240 |
+
"loc_id": "L1",
|
| 241 |
+
"witness_sus_ids": [
|
| 242 |
+
"S1",
|
| 243 |
+
"S4"
|
| 244 |
+
]
|
| 245 |
+
}
|
| 246 |
+
]
|
| 247 |
+
},
|
| 248 |
+
"must_lie_about": [
|
| 249 |
+
"F3"
|
| 250 |
+
],
|
| 251 |
+
"anchored_lies": [
|
| 252 |
+
{
|
| 253 |
+
"lie_id": "LIE_till",
|
| 254 |
+
"topic": "the till and the money",
|
| 255 |
+
"claimed": "The till always comes up square; I would never touch it.",
|
| 256 |
+
"truth_ref": "F3",
|
| 257 |
+
"breaks_on": [
|
| 258 |
+
"C3"
|
| 259 |
+
],
|
| 260 |
+
"fallback": "All right, I have borrowed against tips. It has nothing to do with Cornelius."
|
| 261 |
+
}
|
| 262 |
+
],
|
| 263 |
+
"voice": null,
|
| 264 |
+
"visual": {
|
| 265 |
+
"subject_type": "suspect",
|
| 266 |
+
"palette": "noir",
|
| 267 |
+
"gender": "male",
|
| 268 |
+
"age_band": "30s",
|
| 269 |
+
"build": null,
|
| 270 |
+
"hair": null,
|
| 271 |
+
"attire": "shirtsleeves and a bar apron",
|
| 272 |
+
"mood": "nervous",
|
| 273 |
+
"accent_color": "#3a6ea5",
|
| 274 |
+
"location_tags": [],
|
| 275 |
+
"prop_tags": [],
|
| 276 |
+
"prompt_hint": ""
|
| 277 |
+
}
|
| 278 |
+
},
|
| 279 |
+
{
|
| 280 |
+
"sus_id": "S3",
|
| 281 |
+
"name": "Lillian Frost",
|
| 282 |
+
"role": "the headline singer",
|
| 283 |
+
"persona_summary": "Theatrical and guarded about her private life, mourning beneath the glamour.",
|
| 284 |
+
"is_culprit": false,
|
| 285 |
+
"physical_capability": {
|
| 286 |
+
"strength": true,
|
| 287 |
+
"mobility": true
|
| 288 |
+
},
|
| 289 |
+
"personality": {
|
| 290 |
+
"composure": 0.6,
|
| 291 |
+
"aggression": 0.4,
|
| 292 |
+
"evasiveness": 0.5
|
| 293 |
+
},
|
| 294 |
+
"tells": [
|
| 295 |
+
"a brittle laugh"
|
| 296 |
+
],
|
| 297 |
+
"knows_facts": [
|
| 298 |
+
"F4"
|
| 299 |
+
],
|
| 300 |
+
"secrets": [
|
| 301 |
+
"You were secretly in love with Cornelius and quarreled with him."
|
| 302 |
+
],
|
| 303 |
+
"true_whereabouts": [
|
| 304 |
+
{
|
| 305 |
+
"window": {
|
| 306 |
+
"start_min": 1260,
|
| 307 |
+
"end_min": 1320
|
| 308 |
+
},
|
| 309 |
+
"loc_id": "L3",
|
| 310 |
+
"activity": "performing two sets on the terrace",
|
| 311 |
+
"co_present_sus_ids": []
|
| 312 |
+
}
|
| 313 |
+
],
|
| 314 |
+
"stated_alibi": {
|
| 315 |
+
"claim_text": "I was on the terrace, singing, the whole hour. Two hundred people saw me.",
|
| 316 |
+
"claimed_segments": [
|
| 317 |
+
{
|
| 318 |
+
"window": {
|
| 319 |
+
"start_min": 1260,
|
| 320 |
+
"end_min": 1320
|
| 321 |
+
},
|
| 322 |
+
"loc_id": "L3",
|
| 323 |
+
"witness_sus_ids": []
|
| 324 |
+
}
|
| 325 |
+
]
|
| 326 |
+
},
|
| 327 |
+
"must_lie_about": [
|
| 328 |
+
"F4"
|
| 329 |
+
],
|
| 330 |
+
"anchored_lies": [
|
| 331 |
+
{
|
| 332 |
+
"lie_id": "LIE_affair",
|
| 333 |
+
"topic": "your relationship with Cornelius",
|
| 334 |
+
"claimed": "Cornelius and I were strictly professional.",
|
| 335 |
+
"truth_ref": "F4",
|
| 336 |
+
"breaks_on": [
|
| 337 |
+
"C4"
|
| 338 |
+
],
|
| 339 |
+
"fallback": "We were close. I loved him. But I would never hurt him."
|
| 340 |
+
}
|
| 341 |
+
],
|
| 342 |
+
"voice": null,
|
| 343 |
+
"visual": {
|
| 344 |
+
"subject_type": "suspect",
|
| 345 |
+
"palette": "noir",
|
| 346 |
+
"gender": "female",
|
| 347 |
+
"age_band": "20s",
|
| 348 |
+
"build": null,
|
| 349 |
+
"hair": null,
|
| 350 |
+
"attire": "a silver sequined stage dress",
|
| 351 |
+
"mood": "guarded",
|
| 352 |
+
"accent_color": "#9a9aa0",
|
| 353 |
+
"location_tags": [],
|
| 354 |
+
"prop_tags": [],
|
| 355 |
+
"prompt_hint": ""
|
| 356 |
+
}
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
"sus_id": "S4",
|
| 360 |
+
"name": "Septimus Boone",
|
| 361 |
+
"role": "the club's accountant",
|
| 362 |
+
"persona_summary": "Meticulous and nervous, speaks in numbers, and is terrified of an audit.",
|
| 363 |
+
"is_culprit": false,
|
| 364 |
+
"physical_capability": {
|
| 365 |
+
"strength": true,
|
| 366 |
+
"mobility": true
|
| 367 |
+
},
|
| 368 |
+
"personality": {
|
| 369 |
+
"composure": 0.4,
|
| 370 |
+
"aggression": 0.2,
|
| 371 |
+
"evasiveness": 0.7
|
| 372 |
+
},
|
| 373 |
+
"tells": [
|
| 374 |
+
"polishing his spectacles"
|
| 375 |
+
],
|
| 376 |
+
"knows_facts": [
|
| 377 |
+
"F5"
|
| 378 |
+
],
|
| 379 |
+
"secrets": [
|
| 380 |
+
"You falsified the ledgers to hide your own embezzlement."
|
| 381 |
+
],
|
| 382 |
+
"true_whereabouts": [
|
| 383 |
+
{
|
| 384 |
+
"window": {
|
| 385 |
+
"start_min": 1260,
|
| 386 |
+
"end_min": 1320
|
| 387 |
+
},
|
| 388 |
+
"loc_id": "L1",
|
| 389 |
+
"activity": "going over receipts at a corner table",
|
| 390 |
+
"co_present_sus_ids": [
|
| 391 |
+
"S2"
|
| 392 |
+
]
|
| 393 |
+
}
|
| 394 |
+
],
|
| 395 |
+
"stated_alibi": {
|
| 396 |
+
"claim_text": "I sat at my usual corner table in the lounge with my books all night.",
|
| 397 |
+
"claimed_segments": [
|
| 398 |
+
{
|
| 399 |
+
"window": {
|
| 400 |
+
"start_min": 1260,
|
| 401 |
+
"end_min": 1320
|
| 402 |
+
},
|
| 403 |
+
"loc_id": "L1",
|
| 404 |
+
"witness_sus_ids": [
|
| 405 |
+
"S2"
|
| 406 |
+
]
|
| 407 |
+
}
|
| 408 |
+
]
|
| 409 |
+
},
|
| 410 |
+
"must_lie_about": [
|
| 411 |
+
"F5"
|
| 412 |
+
],
|
| 413 |
+
"anchored_lies": [
|
| 414 |
+
{
|
| 415 |
+
"lie_id": "LIE_ledger",
|
| 416 |
+
"topic": "the ledgers and the books",
|
| 417 |
+
"claimed": "The books are in perfect order, I assure you.",
|
| 418 |
+
"truth_ref": "F5",
|
| 419 |
+
"breaks_on": [
|
| 420 |
+
"C5"
|
| 421 |
+
],
|
| 422 |
+
"fallback": "There may be irregularities - mine, not murder. I was nowhere near him."
|
| 423 |
+
}
|
| 424 |
+
],
|
| 425 |
+
"voice": null,
|
| 426 |
+
"visual": {
|
| 427 |
+
"subject_type": "suspect",
|
| 428 |
+
"palette": "noir",
|
| 429 |
+
"gender": "male",
|
| 430 |
+
"age_band": "40s",
|
| 431 |
+
"build": null,
|
| 432 |
+
"hair": null,
|
| 433 |
+
"attire": "a rumpled three-piece suit",
|
| 434 |
+
"mood": "anxious",
|
| 435 |
+
"accent_color": "#6b8f71",
|
| 436 |
+
"location_tags": [],
|
| 437 |
+
"prop_tags": [],
|
| 438 |
+
"prompt_hint": ""
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
],
|
| 442 |
+
"culprit": {
|
| 443 |
+
"sus_id": "S1",
|
| 444 |
+
"true_motive": {
|
| 445 |
+
"motive_id": "M1",
|
| 446 |
+
"category": "greed",
|
| 447 |
+
"summary": "Cornelius was about to cut Margot out of the club; his death secures her inheritance."
|
| 448 |
+
},
|
| 449 |
+
"method_narrative": "You followed Cornelius to the office, argued about the will, and struck him with the brass statuette.",
|
| 450 |
+
"alibi_lie": {
|
| 451 |
+
"claimed_loc_id": "L1",
|
| 452 |
+
"actual_loc_id": "L4",
|
| 453 |
+
"contradicted_by_clue_ids": [
|
| 454 |
+
"C1",
|
| 455 |
+
"C2"
|
| 456 |
+
]
|
| 457 |
+
}
|
| 458 |
+
},
|
| 459 |
+
"relationships": [
|
| 460 |
+
{
|
| 461 |
+
"from_sus_id": "S1",
|
| 462 |
+
"to_sus_id": "S2",
|
| 463 |
+
"kind": "employer of",
|
| 464 |
+
"sentiment": 0.1,
|
| 465 |
+
"known_publicly": true
|
| 466 |
+
},
|
| 467 |
+
{
|
| 468 |
+
"from_sus_id": "S4",
|
| 469 |
+
"to_sus_id": "S2",
|
| 470 |
+
"kind": "friendly with",
|
| 471 |
+
"sentiment": 0.3,
|
| 472 |
+
"known_publicly": true
|
| 473 |
+
}
|
| 474 |
+
],
|
| 475 |
+
"facts": [
|
| 476 |
+
{
|
| 477 |
+
"fact_id": "F1",
|
| 478 |
+
"statement": "Margot was in the office during the murder window.",
|
| 479 |
+
"true_value": true,
|
| 480 |
+
"loc_id": "L4",
|
| 481 |
+
"at_min": 1290
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"fact_id": "F2",
|
| 485 |
+
"statement": "Margot smokes Gauloises cigarettes.",
|
| 486 |
+
"true_value": true,
|
| 487 |
+
"loc_id": null,
|
| 488 |
+
"at_min": null
|
| 489 |
+
},
|
| 490 |
+
{
|
| 491 |
+
"fact_id": "F3",
|
| 492 |
+
"statement": "Eddie has been skimming from the till.",
|
| 493 |
+
"true_value": true,
|
| 494 |
+
"loc_id": null,
|
| 495 |
+
"at_min": null
|
| 496 |
+
},
|
| 497 |
+
{
|
| 498 |
+
"fact_id": "F4",
|
| 499 |
+
"statement": "Lillian was secretly in love with Cornelius.",
|
| 500 |
+
"true_value": true,
|
| 501 |
+
"loc_id": null,
|
| 502 |
+
"at_min": null
|
| 503 |
+
},
|
| 504 |
+
{
|
| 505 |
+
"fact_id": "F5",
|
| 506 |
+
"statement": "Boone falsified the club's ledgers.",
|
| 507 |
+
"true_value": true,
|
| 508 |
+
"loc_id": null,
|
| 509 |
+
"at_min": null
|
| 510 |
+
}
|
| 511 |
+
],
|
| 512 |
+
"clues": [
|
| 513 |
+
{
|
| 514 |
+
"clue_id": "C1",
|
| 515 |
+
"name": "Cloakroom ticket (9:48 PM)",
|
| 516 |
+
"reveal_text": "A cloakroom claim ticket stamped 9:48 PM lies near the body - someone fetched a wrap mid-evening and ended up here.",
|
| 517 |
+
"discoverable_at_loc_id": "L4",
|
| 518 |
+
"discovery_method": "forensic",
|
| 519 |
+
"supports_fact_id": "F1",
|
| 520 |
+
"points_to_sus_id": "S1",
|
| 521 |
+
"contradicts_alibi_of": "S1",
|
| 522 |
+
"is_red_herring": false,
|
| 523 |
+
"weight": 1.0
|
| 524 |
+
},
|
| 525 |
+
{
|
| 526 |
+
"clue_id": "C2",
|
| 527 |
+
"name": "Gauloises ash",
|
| 528 |
+
"reveal_text": "Grey ash from a Gauloises cigarette on the office rug - an unusual brand.",
|
| 529 |
+
"discoverable_at_loc_id": "L4",
|
| 530 |
+
"discovery_method": "forensic",
|
| 531 |
+
"supports_fact_id": "F2",
|
| 532 |
+
"points_to_sus_id": "S1",
|
| 533 |
+
"contradicts_alibi_of": "S1",
|
| 534 |
+
"is_red_herring": false,
|
| 535 |
+
"weight": 0.7
|
| 536 |
+
},
|
| 537 |
+
{
|
| 538 |
+
"clue_id": "C3",
|
| 539 |
+
"name": "Doctored bar tab",
|
| 540 |
+
"reveal_text": "A bar ledger showing Eddie quietly forgiving his own tabs.",
|
| 541 |
+
"discoverable_at_loc_id": "L1",
|
| 542 |
+
"discovery_method": "document",
|
| 543 |
+
"supports_fact_id": "F3",
|
| 544 |
+
"points_to_sus_id": "S2",
|
| 545 |
+
"contradicts_alibi_of": null,
|
| 546 |
+
"is_red_herring": true,
|
| 547 |
+
"weight": 0.3
|
| 548 |
+
},
|
| 549 |
+
{
|
| 550 |
+
"clue_id": "C4",
|
| 551 |
+
"name": "Torn love letter",
|
| 552 |
+
"reveal_text": "A torn letter in Lillian's hand to Cornelius - equal parts love and fury.",
|
| 553 |
+
"discoverable_at_loc_id": "L5",
|
| 554 |
+
"discovery_method": "search",
|
| 555 |
+
"supports_fact_id": "F4",
|
| 556 |
+
"points_to_sus_id": "S3",
|
| 557 |
+
"contradicts_alibi_of": null,
|
| 558 |
+
"is_red_herring": true,
|
| 559 |
+
"weight": 0.3
|
| 560 |
+
},
|
| 561 |
+
{
|
| 562 |
+
"clue_id": "C5",
|
| 563 |
+
"name": "Altered ledger page",
|
| 564 |
+
"reveal_text": "A ledger page with figures scratched out and rewritten in Boone's hand.",
|
| 565 |
+
"discoverable_at_loc_id": "L4",
|
| 566 |
+
"discovery_method": "document",
|
| 567 |
+
"supports_fact_id": "F5",
|
| 568 |
+
"points_to_sus_id": "S4",
|
| 569 |
+
"contradicts_alibi_of": null,
|
| 570 |
+
"is_red_herring": true,
|
| 571 |
+
"weight": 0.3
|
| 572 |
+
}
|
| 573 |
+
],
|
| 574 |
+
"solution": {
|
| 575 |
+
"culprit_sus_id": "S1",
|
| 576 |
+
"weapon_id": "W1",
|
| 577 |
+
"motive_id": "M1",
|
| 578 |
+
"minimal_clue_set": [
|
| 579 |
+
"C1"
|
| 580 |
+
],
|
| 581 |
+
"deduction_chain": [
|
| 582 |
+
"Cornelius was killed in the office between 9:20 and 9:50.",
|
| 583 |
+
"Every suspect but Margot has a witness for that window.",
|
| 584 |
+
"Margot swears she never left the lounge - yet her cloakroom ticket, stamped 9:48, was found beside the body.",
|
| 585 |
+
"Her alibi is a lie; she was in the office. Margot Vane is the killer."
|
| 586 |
+
]
|
| 587 |
+
}
|
| 588 |
+
}
|
docs/FIELD_NOTES.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Field Notes: building a detective game where the AI *is* the game
|
| 2 |
+
|
| 3 |
+
*Build Small Hackathon - "Small models, big adventure"*
|
| 4 |
+
|
| 5 |
+
## The pitch
|
| 6 |
+
|
| 7 |
+
Case Zero is a murder-mystery game with no scripted cases and no content library. A single
|
| 8 |
+
**1.5B-parameter model** (Qwen2.5-1.5B-Instruct, Q4_K_M GGUF) invents the entire mystery
|
| 9 |
+
every time you play - the victim, the suspects, their secrets and motives, the timeline, the
|
| 10 |
+
weapon, the evidence, and the one who did it - and then **role-plays every suspect live**.
|
| 11 |
+
They remember what you asked. They lie. And when you present the piece of evidence that
|
| 12 |
+
contradicts an alibi, the lie cracks on screen.
|
| 13 |
+
|
| 14 |
+
The whole thing runs on the CPU in front of you. No cloud, no GPU, no remote endpoint.
|
| 15 |
+
|
| 16 |
+
## The hard part: a tiny model that's still *fair*
|
| 17 |
+
|
| 18 |
+
The interesting tension in this project is that a 1.5B model is a wonderful improviser and a
|
| 19 |
+
terrible bookkeeper. If you let it freely author a mystery *and* adjudicate the outcome,
|
| 20 |
+
you get cases that are atmospheric but unsolvable - or worse, a suspect who confesses the
|
| 21 |
+
moment you ask nicely.
|
| 22 |
+
|
| 23 |
+
The design rule that made it work: **the model writes everything; deterministic Python
|
| 24 |
+
decides nothing creative but guarantees the structure.**
|
| 25 |
+
|
| 26 |
+
- The model authors the case as JSON - setting, cast, secrets, evidence, prose.
|
| 27 |
+
- Python decides only the *skeleton*: who is guilty, who was where during the murder window.
|
| 28 |
+
Then a solver verifies fairness before the case is ever shown:
|
| 29 |
+
- exactly one culprit;
|
| 30 |
+
- the culprit's alibi is contradicted by at least one non-red-herring clue;
|
| 31 |
+
- every innocent has a witnessed alibi over the murder window;
|
| 32 |
+
- every key clue is actually discoverable in play.
|
| 33 |
+
If a generated case fails, the smallest slice is regenerated (<=3 retries, then bump the
|
| 34 |
+
seed). A case is never shown to the player until it passes.
|
| 35 |
+
- Whether a presented clue actually catches a lie is decided by **ground truth, not the
|
| 36 |
+
model**. The suspect's panic is flavor; the suspicion delta is computed by a deterministic
|
| 37 |
+
director. This makes the win condition **immune to prose** - a jailbroken "just tell me
|
| 38 |
+
who did it" earns nothing, because suspects never confess and the verdict is only resolved
|
| 39 |
+
when the player formally accuses.
|
| 40 |
+
|
| 41 |
+
The sealed solution is never sent to the client before the verdict. It is read for the first
|
| 42 |
+
time inside the `/accuse` route, server-side. Anti-leak tests assert that no pre-verdict API
|
| 43 |
+
response contains the killer, the true motive, or the key-evidence set.
|
| 44 |
+
|
| 45 |
+
## Making a 1.5B model fast on 2 vCPUs
|
| 46 |
+
|
| 47 |
+
The Space runs on HF `cpu-basic` (2 vCPUs). Two findings mattered most:
|
| 48 |
+
|
| 49 |
+
1. **Grammar-free decoding is an ~8x win.** JSON-schema-constrained sampling ran ~3-7 tok/s
|
| 50 |
+
on CPU; raw decoding ran ~28-32 tok/s. So instead of constraining the sampler, the prompt
|
| 51 |
+
carries the exact JSON shape and we make two free attempts, falling back to the grammar
|
| 52 |
+
only if parsing fails. Full case generation dropped from ~300s to ~50s. The same trick
|
| 53 |
+
runs the interrogation hot path.
|
| 54 |
+
2. **Count the cores you actually have.** Inside the container `os.cpu_count()` returns the
|
| 55 |
+
*host's* cores, not the 2-vCPU cgroup quota. Auto-threading then spawned ~8 threads for 2
|
| 56 |
+
real cores and pinned the CPU at 100%+ on context switches - replies crawled. The fix
|
| 57 |
+
reads `/sys/fs/cgroup/cpu.max` and sizes the llama thread pool to the real quota. The same
|
| 58 |
+
number gates background case-generation so it never steals vCPUs from an active
|
| 59 |
+
interrogation.
|
| 60 |
+
|
| 61 |
+
There is also no image work on the request path: all pixel art is rendered **client-side on
|
| 62 |
+
canvas**, so the server spends ~0 CPU on visuals and devotes both vCPUs to the model. Voices
|
| 63 |
+
(Supertonic ONNX) are synthesized sentence-by-sentence as the reply streams and cached per
|
| 64 |
+
line.
|
| 65 |
+
|
| 66 |
+
## Why it's a Gradio app with a frontend that doesn't look like one
|
| 67 |
+
|
| 68 |
+
The entire app is one `gradio.Server` (Gradio 6 "Server mode" - a FastAPI subclass launched
|
| 69 |
+
through Gradio, with Gradio API endpoints registered via `@server.api`). That single process
|
| 70 |
+
serves a hand-built **pixel-art noir SPA** (Preact + Vite) as static files *and* exposes the
|
| 71 |
+
game's JSON/SSE routes under `/api`. No separate frontend host. So it earns **Off-Brand** (a
|
| 72 |
+
custom frontend well past the default Gradio look) while staying unambiguously a Gradio
|
| 73 |
+
application.
|
| 74 |
+
|
| 75 |
+
## Shipping it
|
| 76 |
+
|
| 77 |
+
- **Off the Grid:** the open Qwen GGUF and Supertonic ONNX are baked into the Docker image at
|
| 78 |
+
build time, so the running container makes zero AI network calls. `scripts/net_audit.py`
|
| 79 |
+
runs a full playthrough under a socket guard and asserts zero non-loopback connections.
|
| 80 |
+
- **Llama Champion:** the model runs in-process through `llama-cpp-python`.
|
| 81 |
+
- **Docker SDK, not Gradio SDK:** llama-cpp-python ships only an sdist; the prebuilt linux
|
| 82 |
+
wheels are musl (they SIGILL on HF's glibc). The Dockerfile compiles llama.cpp from source
|
| 83 |
+
on `python:3.12-slim` (bookworm/gcc-12) with `-DGGML_NATIVE=OFF` for a portable build, and
|
| 84 |
+
bakes the weights.
|
| 85 |
+
|
| 86 |
+
## What I'd do next
|
| 87 |
+
|
| 88 |
+
- A small **fine-tune** of the 1.5B on noir-suspect dialogue and strict-JSON case authoring,
|
| 89 |
+
published to the Hub (the "Well-Tuned" badge), to tighten persona consistency and cut the
|
| 90 |
+
occasional malformed-JSON retry.
|
| 91 |
+
- A daily seeded case and a shareable "case file" card.
|
| 92 |
+
|
| 93 |
+
## Try it
|
| 94 |
+
|
| 95 |
+
- Space: `build-small-hackathon/case0`
|
| 96 |
+
- Stack: Qwen2.5-1.5B-Instruct (Apache-2.0) via llama.cpp, Supertonic ONNX voices, Preact
|
| 97 |
+
pixel-art SPA, all served by one `gradio.Server`.
|
docs/sample_agent_trace.json
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"what": "Case Zero - live agent trace. A 1.5B model (Qwen2.5-1.5B-Instruct via llama.cpp, on CPU, in-process) invents a whole murder mystery, then role-plays a suspect under interrogation. Suspicion is decided server-side by a deterministic director, not by the model; suspects never confess - you win by presenting the right evidence and accusing.",
|
| 3 |
+
"model": "Qwen2.5-1.5B-Instruct Q4_K_M (llama.cpp, CPU, in-process)",
|
| 4 |
+
"caseId": "gen-721096",
|
| 5 |
+
"generationSeconds": 77.6,
|
| 6 |
+
"title": "The Red Room",
|
| 7 |
+
"victim": "Sophia Johnson",
|
| 8 |
+
"suspects": [
|
| 9 |
+
{
|
| 10 |
+
"id": "S1",
|
| 11 |
+
"name": "Dr. Marcus Reeves",
|
| 12 |
+
"role": "Security Guard"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"id": "S2",
|
| 16 |
+
"name": "Ava Martinez",
|
| 17 |
+
"role": "Sophia Johnson’s Rival Artist"
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"id": "S3",
|
| 21 |
+
"name": "Richard White",
|
| 22 |
+
"role": "Sophia’s Rival Artist’s Lover"
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"id": "S4",
|
| 26 |
+
"name": "Ethan Thompson",
|
| 27 |
+
"role": "Sophia’s Business Partner"
|
| 28 |
+
}
|
| 29 |
+
],
|
| 30 |
+
"turns": [
|
| 31 |
+
{
|
| 32 |
+
"suspect": "Dr. Marcus Reeves",
|
| 33 |
+
"detective_asks": "Where were you when it happened?",
|
| 34 |
+
"suspect_reply": "Oh my God. It was an accident then?",
|
| 35 |
+
"suspicion": 39,
|
| 36 |
+
"delta": 0,
|
| 37 |
+
"flags": {
|
| 38 |
+
"rattled": false,
|
| 39 |
+
"contradictionExposed": false,
|
| 40 |
+
"cornered": false
|
| 41 |
+
}
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"suspect": "Dr. Marcus Reeves",
|
| 45 |
+
"detective_asks": "What was your relationship with the victim?",
|
| 46 |
+
"suspect_reply": "There's nothing more to it than that.",
|
| 47 |
+
"suspicion": 39,
|
| 48 |
+
"delta": 0,
|
| 49 |
+
"flags": {
|
| 50 |
+
"rattled": false,
|
| 51 |
+
"contradictionExposed": false,
|
| 52 |
+
"cornered": false
|
| 53 |
+
}
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"suspect": "Dr. Marcus Reeves",
|
| 57 |
+
"detective_asks": "Did you see anyone near the scene?",
|
| 58 |
+
"suspect_reply": "You're reaching, Detective.",
|
| 59 |
+
"suspicion": 39,
|
| 60 |
+
"delta": 0,
|
| 61 |
+
"flags": {
|
| 62 |
+
"rattled": false,
|
| 63 |
+
"contradictionExposed": false,
|
| 64 |
+
"cornered": false
|
| 65 |
+
}
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"suspect": "Dr. Marcus Reeves",
|
| 69 |
+
"detective_asks": "Is there anything you haven't told me?",
|
| 70 |
+
"suspect_reply": "Oh, Detective... It wasn't an accident after all.",
|
| 71 |
+
"suspicion": 39,
|
| 72 |
+
"delta": 0,
|
| 73 |
+
"flags": {
|
| 74 |
+
"rattled": false,
|
| 75 |
+
"contradictionExposed": false,
|
| 76 |
+
"cornered": false
|
| 77 |
+
}
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"suspect": "Dr. Marcus Reeves",
|
| 81 |
+
"detective_presents": "PARTIAL FINGERPRINT",
|
| 82 |
+
"suspect_reply": "That doesn't prove a thing, and you know it.",
|
| 83 |
+
"suspicion": 81,
|
| 84 |
+
"delta": 42,
|
| 85 |
+
"flags": {
|
| 86 |
+
"rattled": true,
|
| 87 |
+
"contradictionExposed": true,
|
| 88 |
+
"cornered": true
|
| 89 |
+
}
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"suspect": "Dr. Marcus Reeves",
|
| 93 |
+
"detective_presents": "SNAGGED THREAD",
|
| 94 |
+
"suspect_reply": "You can stare all you like - it wasn't me.",
|
| 95 |
+
"suspicion": 100,
|
| 96 |
+
"delta": 19,
|
| 97 |
+
"flags": {
|
| 98 |
+
"rattled": true,
|
| 99 |
+
"contradictionExposed": false,
|
| 100 |
+
"cornered": false
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
]
|
| 104 |
+
}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "case-zero"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Case Zero - a fully AI-driven detective game running on a single local small model (<=4B)."
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.12,<3.13"
|
| 7 |
+
license = { text = "Apache-2.0" }
|
| 8 |
+
authors = [{ name = "Hussein Eid" }]
|
| 9 |
+
dependencies = [
|
| 10 |
+
"gradio>=6.16,<7",
|
| 11 |
+
"pydantic>=2.13,<3",
|
| 12 |
+
"pydantic-settings>=2.3",
|
| 13 |
+
"pillow>=11.3,<13",
|
| 14 |
+
"numpy>=1.26",
|
| 15 |
+
# llama-cpp-python and supertonic are installed separately (heavy / platform wheels).
|
| 16 |
+
# See requirements.txt for the full pinned runtime set used on the HF Space.
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
[build-system]
|
| 20 |
+
requires = ["hatchling"]
|
| 21 |
+
build-backend = "hatchling.build"
|
| 22 |
+
|
| 23 |
+
[tool.hatch.build.targets.wheel]
|
| 24 |
+
packages = ["src/case_zero"]
|
| 25 |
+
|
| 26 |
+
[tool.ruff]
|
| 27 |
+
line-length = 100
|
| 28 |
+
target-version = "py312"
|
| 29 |
+
src = ["src"]
|
| 30 |
+
|
| 31 |
+
[tool.ruff.lint]
|
| 32 |
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
| 33 |
+
ignore = ["E501"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Case Zero - Hugging Face Space (Docker SDK, CPU). Python 3.12.
|
| 2 |
+
# Every model is open-weights and self-run. Total runtime params ~1.6B
|
| 3 |
+
# (Qwen2.5-1.5B + Supertonic ~0.1B), far under the 32B cap (<=4B Tiny Titan).
|
| 4 |
+
|
| 5 |
+
# LLM runtime: in-process llama.cpp, compiled from source in the Docker build (PyPI ships
|
| 6 |
+
# only an sdist; the prebuilt linux wheels are musl-only and fail on glibc). The Dockerfile
|
| 7 |
+
# supplies gcc + cmake and a portable (non-native) build; the compiled layer is cached.
|
| 8 |
+
llama-cpp-python==0.3.26
|
| 9 |
+
|
| 10 |
+
# gradio 6 ships `gradio.Server` (FastAPI-based custom-frontend / "Server mode"), which
|
| 11 |
+
# serves the Preact pixel-art SPA + the /api routes from one process. It relaxes the old
|
| 12 |
+
# pydantic<2.12 cap.
|
| 13 |
+
gradio>=6.16,<7
|
| 14 |
+
pydantic>=2.13,<3
|
| 15 |
+
pydantic-settings>=2.3,<3
|
| 16 |
+
pillow>=11.3,<13
|
| 17 |
+
numpy==2.2.6
|
| 18 |
+
|
| 19 |
+
# TTS: Supertonic (on-device ONNX voices). Pulls onnxruntime.
|
| 20 |
+
supertonic==1.3.1
|
| 21 |
+
|
| 22 |
+
# Used at startup to fetch the open GGUF weights onto the Space.
|
| 23 |
+
huggingface_hub==0.36.0
|
scripts/build_tutorial_case.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Author the hand-crafted tutorial case and write cases/seeds/tutorial.json.
|
| 2 |
+
|
| 3 |
+
The tutorial doubles as: the onboarding case that teaches the loop, a deterministic
|
| 4 |
+
demo case, and a golden test fixture. It is fully solvable by construction; the
|
| 5 |
+
solvability checker is run before it is written.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 14 |
+
sys.path.insert(0, str(ROOT / "src"))
|
| 15 |
+
|
| 16 |
+
from case_zero.constants import SEED_CASES_DIR # noqa: E402
|
| 17 |
+
from case_zero.persistence.case_store import save_case # noqa: E402
|
| 18 |
+
from case_zero.schemas import ( # noqa: E402
|
| 19 |
+
AlibiLie,
|
| 20 |
+
AlibiSegment,
|
| 21 |
+
AnchoredLie,
|
| 22 |
+
CaseFile,
|
| 23 |
+
Clue,
|
| 24 |
+
Culprit,
|
| 25 |
+
Difficulty,
|
| 26 |
+
DiscoveryMethod,
|
| 27 |
+
Fact,
|
| 28 |
+
GenerationKnobs,
|
| 29 |
+
Location,
|
| 30 |
+
Motive,
|
| 31 |
+
MotiveCategory,
|
| 32 |
+
PersonalityAxes,
|
| 33 |
+
PhysicalCapability,
|
| 34 |
+
Relationship,
|
| 35 |
+
Setting,
|
| 36 |
+
Solution,
|
| 37 |
+
StatedAlibi,
|
| 38 |
+
Suspect,
|
| 39 |
+
TimeWindow,
|
| 40 |
+
Victim,
|
| 41 |
+
VisualDescriptor,
|
| 42 |
+
Weapon,
|
| 43 |
+
WhereaboutsSegment,
|
| 44 |
+
)
|
| 45 |
+
from case_zero.schemas.enums import SubjectType # noqa: E402
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def m(hour: int, minute: int) -> int:
|
| 49 |
+
return hour * 60 + minute
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
WINDOW = TimeWindow(start_min=m(21, 0), end_min=m(22, 0))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _locations() -> tuple[Location, ...]:
|
| 56 |
+
return (
|
| 57 |
+
Location(loc_id="L1", name="The Lounge", description="The main club floor.",
|
| 58 |
+
adjacent_to=("L2", "L3", "L5")),
|
| 59 |
+
Location(loc_id="L2", name="The Kitchen", adjacent_to=("L1",)),
|
| 60 |
+
Location(loc_id="L3", name="The Terrace", description="The open rooftop stage.",
|
| 61 |
+
adjacent_to=("L1",)),
|
| 62 |
+
Location(loc_id="L4", name="The Office", description="Vane's private office.",
|
| 63 |
+
adjacent_to=("L5",)),
|
| 64 |
+
Location(loc_id="L5", name="The Cloakroom", adjacent_to=("L1", "L4")),
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _facts() -> tuple[Fact, ...]:
|
| 69 |
+
return (
|
| 70 |
+
Fact(fact_id="F1", statement="Margot was in the office during the murder window.",
|
| 71 |
+
true_value=True, loc_id="L4", at_min=m(21, 30)),
|
| 72 |
+
Fact(fact_id="F2", statement="Margot smokes Gauloises cigarettes.", true_value=True),
|
| 73 |
+
Fact(fact_id="F3", statement="Eddie has been skimming from the till.", true_value=True),
|
| 74 |
+
Fact(fact_id="F4", statement="Lillian was secretly in love with Cornelius.", true_value=True),
|
| 75 |
+
Fact(fact_id="F5", statement="Boone falsified the club's ledgers.", true_value=True),
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _clues() -> tuple[Clue, ...]:
|
| 80 |
+
return (
|
| 81 |
+
Clue(clue_id="C1", name="Cloakroom ticket (9:48 PM)",
|
| 82 |
+
reveal_text="A cloakroom claim ticket stamped 9:48 PM lies near the body - someone "
|
| 83 |
+
"fetched a wrap mid-evening and ended up here.",
|
| 84 |
+
discoverable_at_loc_id="L4", discovery_method=DiscoveryMethod.FORENSIC,
|
| 85 |
+
supports_fact_id="F1", points_to_sus_id="S1", contradicts_alibi_of="S1", weight=1.0),
|
| 86 |
+
Clue(clue_id="C2", name="Gauloises ash",
|
| 87 |
+
reveal_text="Grey ash from a Gauloises cigarette on the office rug - an unusual brand.",
|
| 88 |
+
discoverable_at_loc_id="L4", discovery_method=DiscoveryMethod.FORENSIC,
|
| 89 |
+
supports_fact_id="F2", points_to_sus_id="S1", contradicts_alibi_of="S1", weight=0.7),
|
| 90 |
+
Clue(clue_id="C3", name="Doctored bar tab",
|
| 91 |
+
reveal_text="A bar ledger showing Eddie quietly forgiving his own tabs.",
|
| 92 |
+
discoverable_at_loc_id="L1", discovery_method=DiscoveryMethod.DOCUMENT,
|
| 93 |
+
supports_fact_id="F3", points_to_sus_id="S2", is_red_herring=True, weight=0.3),
|
| 94 |
+
Clue(clue_id="C4", name="Torn love letter",
|
| 95 |
+
reveal_text="A torn letter in Lillian's hand to Cornelius - equal parts love and fury.",
|
| 96 |
+
discoverable_at_loc_id="L5", discovery_method=DiscoveryMethod.SEARCH,
|
| 97 |
+
supports_fact_id="F4", points_to_sus_id="S3", is_red_herring=True, weight=0.3),
|
| 98 |
+
Clue(clue_id="C5", name="Altered ledger page",
|
| 99 |
+
reveal_text="A ledger page with figures scratched out and rewritten in Boone's hand.",
|
| 100 |
+
discoverable_at_loc_id="L4", discovery_method=DiscoveryMethod.DOCUMENT,
|
| 101 |
+
supports_fact_id="F5", points_to_sus_id="S4", is_red_herring=True, weight=0.3),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _visual(age: str, attire: str, mood: str, accent: str, gender: str) -> VisualDescriptor:
|
| 106 |
+
return VisualDescriptor(subject_type=SubjectType.SUSPECT, palette="noir", age_band=age,
|
| 107 |
+
attire=attire, mood=mood, accent_color=accent, gender=gender)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _suspects() -> tuple[Suspect, ...]:
|
| 111 |
+
margot = Suspect(
|
| 112 |
+
sus_id="S1", name="Margot Vane", role="the widow and co-owner",
|
| 113 |
+
persona_summary="Poised and imperious in mourning black; clipped, controlled speech, and "
|
| 114 |
+
"she resents every question.",
|
| 115 |
+
is_culprit=True,
|
| 116 |
+
physical_capability=PhysicalCapability(strength=True, mobility=True),
|
| 117 |
+
personality=PersonalityAxes(composure=0.8, aggression=0.5, evasiveness=0.7),
|
| 118 |
+
tells=("a too-steady voice", "smoothing her gloves"),
|
| 119 |
+
knows_facts=("F1", "F2"),
|
| 120 |
+
secrets=("Cornelius was about to cut you out of the club; his death secures everything.",),
|
| 121 |
+
true_whereabouts=(
|
| 122 |
+
WhereaboutsSegment(window=TimeWindow(start_min=m(21, 0), end_min=m(21, 20)),
|
| 123 |
+
loc_id="L1", activity="greeting patrons in the lounge",
|
| 124 |
+
co_present_sus_ids=("S2",)),
|
| 125 |
+
WhereaboutsSegment(window=TimeWindow(start_min=m(21, 20), end_min=m(21, 50)),
|
| 126 |
+
loc_id="L4", activity="confronting Cornelius in the office"),
|
| 127 |
+
WhereaboutsSegment(window=TimeWindow(start_min=m(21, 50), end_min=m(22, 0)),
|
| 128 |
+
loc_id="L5", activity="composing herself in the cloakroom"),
|
| 129 |
+
),
|
| 130 |
+
stated_alibi=StatedAlibi(
|
| 131 |
+
claim_text="I was in the lounge the entire evening, in plain sight of a dozen guests.",
|
| 132 |
+
claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L1", witness_sus_ids=("S2",)),),
|
| 133 |
+
),
|
| 134 |
+
must_lie_about=("F1",),
|
| 135 |
+
anchored_lies=(
|
| 136 |
+
AnchoredLie(
|
| 137 |
+
lie_id="LIE_alibi", topic="where you were during the murder",
|
| 138 |
+
claimed="I never once left the lounge; ask any of the patrons.",
|
| 139 |
+
truth_ref="F1", breaks_on=("C1", "C2"),
|
| 140 |
+
fallback="Fine - I stepped out to the cloakroom for my wrap, that is all. I never "
|
| 141 |
+
"went near the office.",
|
| 142 |
+
),
|
| 143 |
+
),
|
| 144 |
+
visual=_visual("50s", "a black beaded evening gown", "imperious", "#b8860b", "female"),
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
eddie = Suspect(
|
| 148 |
+
sus_id="S2", name="Eddie Marsh", role="the bartender",
|
| 149 |
+
persona_summary="Quick and affable, sweats easily, and keeps glancing at the till.",
|
| 150 |
+
personality=PersonalityAxes(composure=0.3, aggression=0.2, evasiveness=0.6),
|
| 151 |
+
tells=("wiping a bar that is already clean",),
|
| 152 |
+
knows_facts=("F3",),
|
| 153 |
+
secrets=("You have been skimming from the till for months.",),
|
| 154 |
+
true_whereabouts=(
|
| 155 |
+
WhereaboutsSegment(window=WINDOW, loc_id="L1", activity="tending the bar",
|
| 156 |
+
co_present_sus_ids=("S1", "S4")),
|
| 157 |
+
),
|
| 158 |
+
stated_alibi=StatedAlibi(
|
| 159 |
+
claim_text="I never left the bar; somebody always wanted a drink.",
|
| 160 |
+
claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L1", witness_sus_ids=("S1", "S4")),),
|
| 161 |
+
),
|
| 162 |
+
must_lie_about=("F3",),
|
| 163 |
+
anchored_lies=(
|
| 164 |
+
AnchoredLie(lie_id="LIE_till", topic="the till and the money",
|
| 165 |
+
claimed="The till always comes up square; I would never touch it.",
|
| 166 |
+
truth_ref="F3", breaks_on=("C3",),
|
| 167 |
+
fallback="All right, I have borrowed against tips. It has nothing to do "
|
| 168 |
+
"with Cornelius."),
|
| 169 |
+
),
|
| 170 |
+
visual=_visual("30s", "shirtsleeves and a bar apron", "nervous", "#3a6ea5", "male"),
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
lillian = Suspect(
|
| 174 |
+
sus_id="S3", name="Lillian Frost", role="the headline singer",
|
| 175 |
+
persona_summary="Theatrical and guarded about her private life, mourning beneath the glamour.",
|
| 176 |
+
personality=PersonalityAxes(composure=0.6, aggression=0.4, evasiveness=0.5),
|
| 177 |
+
tells=("a brittle laugh",),
|
| 178 |
+
knows_facts=("F4",),
|
| 179 |
+
secrets=("You were secretly in love with Cornelius and quarreled with him.",),
|
| 180 |
+
true_whereabouts=(
|
| 181 |
+
WhereaboutsSegment(window=WINDOW, loc_id="L3", activity="performing two sets on the terrace"),
|
| 182 |
+
),
|
| 183 |
+
stated_alibi=StatedAlibi(
|
| 184 |
+
claim_text="I was on the terrace, singing, the whole hour. Two hundred people saw me.",
|
| 185 |
+
claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L3"),),
|
| 186 |
+
),
|
| 187 |
+
must_lie_about=("F4",),
|
| 188 |
+
anchored_lies=(
|
| 189 |
+
AnchoredLie(lie_id="LIE_affair", topic="your relationship with Cornelius",
|
| 190 |
+
claimed="Cornelius and I were strictly professional.",
|
| 191 |
+
truth_ref="F4", breaks_on=("C4",),
|
| 192 |
+
fallback="We were close. I loved him. But I would never hurt him."),
|
| 193 |
+
),
|
| 194 |
+
visual=_visual("20s", "a silver sequined stage dress", "guarded", "#9a9aa0", "female"),
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
boone = Suspect(
|
| 198 |
+
sus_id="S4", name="Septimus Boone", role="the club's accountant",
|
| 199 |
+
persona_summary="Meticulous and nervous, speaks in numbers, and is terrified of an audit.",
|
| 200 |
+
personality=PersonalityAxes(composure=0.4, aggression=0.2, evasiveness=0.7),
|
| 201 |
+
tells=("polishing his spectacles",),
|
| 202 |
+
knows_facts=("F5",),
|
| 203 |
+
secrets=("You falsified the ledgers to hide your own embezzlement.",),
|
| 204 |
+
true_whereabouts=(
|
| 205 |
+
WhereaboutsSegment(window=WINDOW, loc_id="L1", activity="going over receipts at a corner table",
|
| 206 |
+
co_present_sus_ids=("S2",)),
|
| 207 |
+
),
|
| 208 |
+
stated_alibi=StatedAlibi(
|
| 209 |
+
claim_text="I sat at my usual corner table in the lounge with my books all night.",
|
| 210 |
+
claimed_segments=(AlibiSegment(window=WINDOW, loc_id="L1", witness_sus_ids=("S2",)),),
|
| 211 |
+
),
|
| 212 |
+
must_lie_about=("F5",),
|
| 213 |
+
anchored_lies=(
|
| 214 |
+
AnchoredLie(lie_id="LIE_ledger", topic="the ledgers and the books",
|
| 215 |
+
claimed="The books are in perfect order, I assure you.",
|
| 216 |
+
truth_ref="F5", breaks_on=("C5",),
|
| 217 |
+
fallback="There may be irregularities - mine, not murder. I was nowhere "
|
| 218 |
+
"near him."),
|
| 219 |
+
),
|
| 220 |
+
visual=_visual("40s", "a rumpled three-piece suit", "anxious", "#6b8f71", "male"),
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
return (margot, eddie, lillian, boone)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def build_tutorial_case() -> CaseFile:
|
| 227 |
+
return CaseFile(
|
| 228 |
+
case_id="tutorial-gilded-aerie",
|
| 229 |
+
seed=1989,
|
| 230 |
+
title="The Gilded Aerie",
|
| 231 |
+
briefing=(
|
| 232 |
+
"1924. On the rooftop of the Gilded Aerie supper club, owner Cornelius Vane is found "
|
| 233 |
+
"dead in his office, struck down with his own brass award. The club was full; everyone "
|
| 234 |
+
"had a reason to resent him. Four people could have slipped away during the murder hour. "
|
| 235 |
+
"Question them, search the rooms, and present what you find. One of them is lying about "
|
| 236 |
+
"where they were - catch that lie, and you have your killer."
|
| 237 |
+
),
|
| 238 |
+
knobs=GenerationKnobs(setting_hint="rooftop supper club", era_hint="1924",
|
| 239 |
+
tone_hint="jazz-age noir", n_suspects=4, n_red_herrings=3,
|
| 240 |
+
difficulty=Difficulty.GENTLE),
|
| 241 |
+
setting=Setting(name="The Gilded Aerie", description="A rooftop supper club above the city.",
|
| 242 |
+
locations=_locations(), murder_window=WINDOW),
|
| 243 |
+
victim=Victim(vic_id="V1", name="Cornelius Vane", role="club owner", found_at_loc_id="L4",
|
| 244 |
+
found_at_min=m(22, 5), cause_of_death="blunt force from a brass statuette",
|
| 245 |
+
time_of_death=TimeWindow(start_min=m(21, 20), end_min=m(21, 50))),
|
| 246 |
+
weapon=Weapon(weapon_id="W1", name="brass award statuette", kind="blunt object",
|
| 247 |
+
origin_loc_id="L4", requires_strength=False, leaves_trace="blood and a dented base"),
|
| 248 |
+
suspects=_suspects(),
|
| 249 |
+
culprit=Culprit(
|
| 250 |
+
sus_id="S1",
|
| 251 |
+
true_motive=Motive(motive_id="M1", category=MotiveCategory.GREED,
|
| 252 |
+
summary="Cornelius was about to cut Margot out of the club; his death "
|
| 253 |
+
"secures her inheritance."),
|
| 254 |
+
method_narrative="You followed Cornelius to the office, argued about the will, and "
|
| 255 |
+
"struck him with the brass statuette.",
|
| 256 |
+
alibi_lie=AlibiLie(claimed_loc_id="L1", actual_loc_id="L4",
|
| 257 |
+
contradicted_by_clue_ids=("C1", "C2")),
|
| 258 |
+
),
|
| 259 |
+
relationships=(
|
| 260 |
+
Relationship(from_sus_id="S1", to_sus_id="S2", kind="employer of", sentiment=0.1),
|
| 261 |
+
Relationship(from_sus_id="S4", to_sus_id="S2", kind="friendly with", sentiment=0.3),
|
| 262 |
+
),
|
| 263 |
+
facts=_facts(),
|
| 264 |
+
clues=_clues(),
|
| 265 |
+
solution=Solution(
|
| 266 |
+
culprit_sus_id="S1", weapon_id="W1", motive_id="M1", minimal_clue_set=("C1",),
|
| 267 |
+
deduction_chain=(
|
| 268 |
+
"Cornelius was killed in the office between 9:20 and 9:50.",
|
| 269 |
+
"Every suspect but Margot has a witness for that window.",
|
| 270 |
+
"Margot swears she never left the lounge - yet her cloakroom ticket, stamped 9:48, "
|
| 271 |
+
"was found beside the body.",
|
| 272 |
+
"Her alibi is a lie; she was in the office. Margot Vane is the killer.",
|
| 273 |
+
),
|
| 274 |
+
),
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def main() -> None:
|
| 279 |
+
case = build_tutorial_case()
|
| 280 |
+
out = SEED_CASES_DIR / "tutorial.json"
|
| 281 |
+
save_case(case, out)
|
| 282 |
+
print(f"wrote {out} ({len(case.suspects)} suspects, {len(case.clues)} clues)")
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
if __name__ == "__main__":
|
| 286 |
+
main()
|
scripts/curate_prebaked.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Re-filter, title-case, and renumber the pre-baked pool so only clean, exciting cases ship.
|
| 2 |
+
|
| 3 |
+
Run after ``prebake_cases.py``. Drops any case that fails the (hardened) quality filter,
|
| 4 |
+
tidies the title's capitalisation, and rewrites the survivors as a contiguous CASE-0001..N.
|
| 5 |
+
No model needed - this only reads and rewrites JSON.
|
| 6 |
+
|
| 7 |
+
python scripts/curate_prebaked.py
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 16 |
+
sys.path.insert(0, str(ROOT / "src"))
|
| 17 |
+
sys.path.insert(0, str(ROOT / "scripts"))
|
| 18 |
+
|
| 19 |
+
from prebake_cases import _is_exciting # noqa: E402
|
| 20 |
+
|
| 21 |
+
from case_zero.persistence.case_store import load_case, save_case # noqa: E402
|
| 22 |
+
from case_zero.persistence.paths import prebaked_cases_dir # noqa: E402
|
| 23 |
+
|
| 24 |
+
_SMALL = {"a", "an", "the", "of", "in", "at", "on", "and", "or", "to", "for", "with", "by", "from"}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _titlecase(t: str) -> str:
|
| 28 |
+
words = t.split()
|
| 29 |
+
out = []
|
| 30 |
+
for i, w in enumerate(words):
|
| 31 |
+
lw = w.lower()
|
| 32 |
+
if i != 0 and lw in _SMALL:
|
| 33 |
+
out.append(lw)
|
| 34 |
+
else:
|
| 35 |
+
out.append(w[:1].upper() + w[1:] if w else w)
|
| 36 |
+
return " ".join(out)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main() -> int:
|
| 40 |
+
d = prebaked_cases_dir()
|
| 41 |
+
files = sorted(d.glob("CASE-*.json"))
|
| 42 |
+
survivors = []
|
| 43 |
+
for p in files:
|
| 44 |
+
case = load_case(p)
|
| 45 |
+
ok, why = _is_exciting(case)
|
| 46 |
+
if not ok:
|
| 47 |
+
print(f"DROP {p.stem}: {why} -- '{case.title}'")
|
| 48 |
+
continue
|
| 49 |
+
survivors.append(case)
|
| 50 |
+
|
| 51 |
+
for p in files:
|
| 52 |
+
p.unlink()
|
| 53 |
+
for i, case in enumerate(survivors, 1):
|
| 54 |
+
cid = f"CASE-{i:04d}"
|
| 55 |
+
case = case.model_copy(update={"case_id": cid, "title": _titlecase(case.title)})
|
| 56 |
+
save_case(case, d / f"{cid}.json")
|
| 57 |
+
cast = ", ".join(f"{s.name} ({s.visual.gender[:1].upper()})" for s in case.suspects)
|
| 58 |
+
print(f"KEEP {cid}: '{case.title}' - victim {case.victim.name} | {cast}")
|
| 59 |
+
|
| 60 |
+
print(f"\nFINAL POOL: {len(survivors)} cases")
|
| 61 |
+
return 0
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
if __name__ == "__main__":
|
| 65 |
+
raise SystemExit(main())
|
scripts/fetch_models.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Open-weights fetcher. Downloads the models once so the runtime can run locally:
|
| 2 |
+
- the LLM GGUF (Qwen2.5-1.5B-Instruct, Q4_K_M) into models/llm/;
|
| 3 |
+
- the Supertonic ONNX TTS model into its cache.
|
| 4 |
+
|
| 5 |
+
Run once locally (or it is invoked automatically at app startup if the GGUF is missing).
|
| 6 |
+
|
| 7 |
+
python scripts/fetch_models.py
|
| 8 |
+
python scripts/fetch_models.py --llm-only
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 18 |
+
sys.path.insert(0, str(ROOT / "src"))
|
| 19 |
+
|
| 20 |
+
LLM_DIR = ROOT / "models" / "llm"
|
| 21 |
+
# The single model the game runs on: Qwen2.5-1.5B (fast, <=4B Tiny Titan).
|
| 22 |
+
LLM_CANDIDATES = (
|
| 23 |
+
("Qwen/Qwen2.5-1.5B-Instruct-GGUF", "qwen2.5-1.5b-instruct-q4_k_m.gguf",
|
| 24 |
+
LLM_DIR / "qwen2.5-1.5b-instruct-q4_k_m.gguf"),
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
def _prefetch_supertonic() -> bool:
|
| 28 |
+
"""Cache the Supertonic ONNX model so the runtime never downloads (off-grid)."""
|
| 29 |
+
try:
|
| 30 |
+
from supertonic import TTS
|
| 31 |
+
|
| 32 |
+
TTS(auto_download=True)
|
| 33 |
+
print("[fetch] Supertonic model cached")
|
| 34 |
+
return True
|
| 35 |
+
except Exception as exc:
|
| 36 |
+
print(f"[fetch] Supertonic prefetch failed: {exc}", file=sys.stderr)
|
| 37 |
+
return False
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _download(repo: str, filename: str, dest: Path) -> bool:
|
| 41 |
+
from huggingface_hub import hf_hub_download
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
path = hf_hub_download(repo_id=repo, filename=filename)
|
| 45 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
dest.write_bytes(Path(path).read_bytes())
|
| 47 |
+
print(f"[fetch] {repo}/{filename} -> {dest} ({dest.stat().st_size} bytes)")
|
| 48 |
+
return True
|
| 49 |
+
except Exception as exc:
|
| 50 |
+
print(f"[fetch] FAILED {repo}/{filename}: {exc}", file=sys.stderr)
|
| 51 |
+
return False
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def main() -> int:
|
| 55 |
+
parser = argparse.ArgumentParser(description="Fetch open weights for Case Zero")
|
| 56 |
+
parser.add_argument("--llm-only", action="store_true")
|
| 57 |
+
parser.add_argument("--tts-only", action="store_true")
|
| 58 |
+
args = parser.parse_args()
|
| 59 |
+
|
| 60 |
+
ok = True
|
| 61 |
+
if not args.tts_only:
|
| 62 |
+
llm_ok = False
|
| 63 |
+
for repo, filename, dest in LLM_CANDIDATES:
|
| 64 |
+
if _download(repo, filename, dest):
|
| 65 |
+
llm_ok = True
|
| 66 |
+
break
|
| 67 |
+
ok &= llm_ok
|
| 68 |
+
if not args.llm_only:
|
| 69 |
+
ok &= _prefetch_supertonic()
|
| 70 |
+
return 0 if ok else 1
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
if __name__ == "__main__":
|
| 74 |
+
raise SystemExit(main())
|
scripts/net_audit.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Off-grid proof: run a full playthrough with a socket guard and assert that no
|
| 2 |
+
non-loopback network connection is attempted.
|
| 3 |
+
|
| 4 |
+
python scripts/net_audit.py
|
| 5 |
+
|
| 6 |
+
Exits non-zero if any outbound connection is made. This backs the "Off the Grid"
|
| 7 |
+
claim: the served game (case load, interrogation, evidence, accusation, and the
|
| 8 |
+
on-device voice) reaches the network zero times.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import contextlib
|
| 14 |
+
import os
|
| 15 |
+
import socket
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 20 |
+
sys.path.insert(0, str(ROOT / "src"))
|
| 21 |
+
|
| 22 |
+
# Mirror the deployed container: weights are baked in, analytics off, hub offline -
|
| 23 |
+
# any accidental download would surface as a violation rather than silently succeed.
|
| 24 |
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
| 25 |
+
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
| 26 |
+
os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
|
| 27 |
+
|
| 28 |
+
_violations: list[str] = []
|
| 29 |
+
_orig_connect = socket.socket.connect
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _is_loopback(host: str) -> bool:
|
| 33 |
+
return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _guard(self, address): # type: ignore[no-untyped-def]
|
| 37 |
+
host = address[0] if isinstance(address, tuple) else str(address)
|
| 38 |
+
if not _is_loopback(str(host)):
|
| 39 |
+
_violations.append(str(host))
|
| 40 |
+
raise OSError(f"net_audit: blocked non-loopback connect to {host}")
|
| 41 |
+
return _orig_connect(self, address)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def main() -> int:
|
| 45 |
+
socket.socket.connect = _guard # type: ignore[method-assign]
|
| 46 |
+
|
| 47 |
+
from starlette.testclient import TestClient
|
| 48 |
+
|
| 49 |
+
from case_zero.api import build_server
|
| 50 |
+
|
| 51 |
+
client = TestClient(build_server())
|
| 52 |
+
|
| 53 |
+
# A full play loop over the golden case - load, interrogate, present the breaking
|
| 54 |
+
# exhibit, read a hint, then accuse - all server-authoritative, no network.
|
| 55 |
+
run = client.post("/api/case", json={"caseId": "GRAYMOOR-3107"}).json()["runId"]
|
| 56 |
+
client.post(f"/api/run/{run}/interrogate/iris", json={"questionId": "q3"})
|
| 57 |
+
client.post(f"/api/run/{run}/interrogate/iris", json={"presentEvidenceId": "keycard"})
|
| 58 |
+
client.get(f"/api/run/{run}/hint", params={"screen": "interro"})
|
| 59 |
+
client.post(
|
| 60 |
+
f"/api/run/{run}/accuse",
|
| 61 |
+
json={
|
| 62 |
+
"suspectId": "iris",
|
| 63 |
+
"motiveId": "m_credit",
|
| 64 |
+
"evidenceIds": ["keycard", "voicemail", "cctv"],
|
| 65 |
+
},
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# Exercise the on-device voice path too (local Supertonic ONNX). Best-effort: a missing
|
| 69 |
+
# voice model is fine; a *network* attempt would already be recorded as a violation.
|
| 70 |
+
with contextlib.suppress(Exception):
|
| 71 |
+
client.post(f"/api/run/{run}/tts/iris", json={"text": "I was in the library all night."})
|
| 72 |
+
|
| 73 |
+
if _violations:
|
| 74 |
+
print(f"FAIL: {len(_violations)} outbound connection(s): {sorted(set(_violations))}")
|
| 75 |
+
return 1
|
| 76 |
+
print("PASS: full playthrough made zero non-loopback connections (Off the Grid verified).")
|
| 77 |
+
return 0
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
raise SystemExit(main())
|
scripts/prebake_cases.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pre-bake a pool of full, model-authored cases for instant New Case serving.
|
| 2 |
+
|
| 3 |
+
Generation on a 2-vCPU Space takes ~1-2 minutes, so the player would otherwise stare at a
|
| 4 |
+
loading screen. This script runs the SAME in-process llama.cpp generator offline, keeps only
|
| 5 |
+
solvable, well-formed, "exciting" cases (distinct human suspects, a real motive, no
|
| 6 |
+
detective/officer suspects, a gender mix), assigns each a stable Case ID, and writes the full
|
| 7 |
+
sealed CaseFile JSON to ``cases/prebaked/``. The Space ships these and serves one instantly on
|
| 8 |
+
New Case while still running every interrogation live (and generating fresh cases when the
|
| 9 |
+
hardware allows). The pre-baked cases are authored by the local model - no cloud, still
|
| 10 |
+
Off-the-Grid.
|
| 11 |
+
|
| 12 |
+
python scripts/prebake_cases.py [target_count] [start_seed]
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import re
|
| 18 |
+
import sys
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 22 |
+
sys.path.insert(0, str(ROOT / "src"))
|
| 23 |
+
|
| 24 |
+
from case_zero.config import get_settings # noqa: E402
|
| 25 |
+
from case_zero.generator.pipeline import generate_case # noqa: E402
|
| 26 |
+
from case_zero.llm.backend import make_backend # noqa: E402
|
| 27 |
+
from case_zero.persistence.case_store import save_case # noqa: E402
|
| 28 |
+
from case_zero.persistence.paths import prebaked_cases_dir # noqa: E402
|
| 29 |
+
from case_zero.schemas.case import CaseFile # noqa: E402
|
| 30 |
+
|
| 31 |
+
_BAD_ROLE = re.compile(
|
| 32 |
+
r"\b(detective|officer|investigator|police|inspector|sergeant|constable|cop|agent)\b",
|
| 33 |
+
re.IGNORECASE,
|
| 34 |
+
)
|
| 35 |
+
# Filler names a small model reaches for - they read as obviously fake and kill the mood.
|
| 36 |
+
_PLACEHOLDER_NAMES = {
|
| 37 |
+
"john doe", "jane doe", "john smith", "jane smith", "joe bloggs", "richard roe",
|
| 38 |
+
"mary major", "john q public", "tom johnson", "tom smith", "jack smith", "jane roe",
|
| 39 |
+
"john brown", "bob smith", "foo bar", "first last", "name surname",
|
| 40 |
+
}
|
| 41 |
+
def _name_malformed(n: str) -> bool:
|
| 42 |
+
# The model sometimes bakes a gender/age/label into the name: "John Smith, Male",
|
| 43 |
+
# "Lara White, 45". Reject anything with a comma, digits, or a gender word.
|
| 44 |
+
return bool("," in n or any(c.isdigit() for c in n) or re.search(r"\b(male|female)\b", n, re.I))
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _name_prefix(n: str) -> str:
|
| 48 |
+
return " ".join(n.lower().replace(",", " ").split()[:2])
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _is_exciting(case: CaseFile) -> tuple[bool, str]:
|
| 52 |
+
"""Reject bland or malformed cases; keep ones that will read well to a judge."""
|
| 53 |
+
title = (case.title or "").strip()
|
| 54 |
+
if len(title) < 5:
|
| 55 |
+
return False, "weak title"
|
| 56 |
+
vname = case.victim.name.strip()
|
| 57 |
+
if not vname or " " not in vname or _name_malformed(vname):
|
| 58 |
+
return False, f"victim needs a clean full name: '{vname}'"
|
| 59 |
+
names = [s.name.strip() for s in case.suspects]
|
| 60 |
+
low = [n.lower() for n in names]
|
| 61 |
+
if len(set(low)) != len(names):
|
| 62 |
+
return False, "duplicate suspect names"
|
| 63 |
+
if any(len(n) < 3 or " " not in n for n in names):
|
| 64 |
+
return False, "suspect needs a full name"
|
| 65 |
+
if any(_name_malformed(n) for n in names):
|
| 66 |
+
return False, f"malformed name (comma/digit/gender): {names}"
|
| 67 |
+
if any(_name_prefix(n) in _PLACEHOLDER_NAMES for n in names):
|
| 68 |
+
return False, f"placeholder name: {names}"
|
| 69 |
+
roles = [s.role.strip().lower() for s in case.suspects]
|
| 70 |
+
if len(set(roles)) < len(roles):
|
| 71 |
+
return False, "duplicate suspect roles"
|
| 72 |
+
for s in case.suspects:
|
| 73 |
+
if _BAD_ROLE.search(s.role) or _BAD_ROLE.search(s.name):
|
| 74 |
+
return False, f"detective-like suspect: {s.name} ({s.role})"
|
| 75 |
+
if not any((s.visual.gender or "").lower().startswith("f") for s in case.suspects):
|
| 76 |
+
return False, "no female suspect"
|
| 77 |
+
if not any((s.visual.gender or "").lower().startswith("m") for s in case.suspects):
|
| 78 |
+
return False, "no male suspect"
|
| 79 |
+
# A real culprit with a written motive and method makes the mystery land.
|
| 80 |
+
if not (case.culprit.method_narrative or "").strip():
|
| 81 |
+
return False, "no method narrative"
|
| 82 |
+
return True, "ok"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def main() -> int:
|
| 86 |
+
target = int(sys.argv[1]) if len(sys.argv) > 1 else 10
|
| 87 |
+
start_seed = int(sys.argv[2]) if len(sys.argv) > 2 else 42000
|
| 88 |
+
max_attempts = target * 4 + 8
|
| 89 |
+
|
| 90 |
+
backend = make_backend(get_settings())
|
| 91 |
+
out_dir = prebaked_cases_dir()
|
| 92 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 93 |
+
|
| 94 |
+
kept: list[CaseFile] = []
|
| 95 |
+
seed = start_seed
|
| 96 |
+
attempts = 0
|
| 97 |
+
while len(kept) < target and attempts < max_attempts:
|
| 98 |
+
attempts += 1
|
| 99 |
+
try:
|
| 100 |
+
result = generate_case(backend, seed=seed)
|
| 101 |
+
except Exception as exc: # generation hiccup - skip this seed
|
| 102 |
+
print(f"[seed {seed}] generation error: {exc}")
|
| 103 |
+
seed += 1
|
| 104 |
+
continue
|
| 105 |
+
seed += 1
|
| 106 |
+
if not result.report.ok:
|
| 107 |
+
print(f"[seed {seed - 1}] unsolvable, skipped")
|
| 108 |
+
continue
|
| 109 |
+
ok, why = _is_exciting(result.case)
|
| 110 |
+
if not ok:
|
| 111 |
+
print(f"[seed {seed - 1}] rejected: {why} -- '{result.case.title}'")
|
| 112 |
+
continue
|
| 113 |
+
case_id = f"CASE-{len(kept) + 1:04d}"
|
| 114 |
+
case = result.case.model_copy(update={"case_id": case_id})
|
| 115 |
+
save_case(case, out_dir / f"{case_id}.json")
|
| 116 |
+
kept.append(case)
|
| 117 |
+
cast = ", ".join(f"{s.name} ({s.visual.gender[:1].upper()})" for s in case.suspects)
|
| 118 |
+
print(f"[KEEP {case_id}] '{case.title}' - victim {case.victim.name} | {cast}")
|
| 119 |
+
|
| 120 |
+
print(f"\nDONE: kept {len(kept)}/{target} in {attempts} attempts -> {out_dir}")
|
| 121 |
+
return 0 if kept else 1
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
raise SystemExit(main())
|
src/case_zero/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Case Zero - a fully AI-driven detective game on a single local small model.
|
| 2 |
+
|
| 3 |
+
The local LLM generates the entire mystery and drives every suspect. Deterministic
|
| 4 |
+
code is only a fairness referee and a reliability layer; it never authors content.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
__version__ = "0.1.0"
|
src/case_zero/api/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTTP API layer for Case Zero.
|
| 2 |
+
|
| 3 |
+
The whole game is served by a single ``gradio.Server`` (a FastAPI subclass): it mounts
|
| 4 |
+
the built Preact pixel-art frontend as static files at ``/`` and exposes the game's JSON
|
| 5 |
+
/ SSE endpoints under ``/api``. Nothing is hosted outside this one Gradio process, so the
|
| 6 |
+
app stays a self-contained Off-the-Grid Hugging Face Space.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from .server import build_server
|
| 12 |
+
|
| 13 |
+
__all__ = ["build_server"]
|
src/case_zero/api/case_adapter.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Project a generated ``CaseFile`` into the PUBLIC wire view (the same contract the
|
| 2 |
+
golden case uses). Deterministic: it synthesizes the display surface from the case's
|
| 3 |
+
own fields. Nothing from the sealed solution (culprit, true motive id, breaker chain)
|
| 4 |
+
is exposed; the true motive's *text* appears only as one option among the decoys.
|
| 5 |
+
|
| 6 |
+
Generated evidence renders as paper exhibits (reveal_text); richer per-type payloads
|
| 7 |
+
(threads, keycard tables, voicemail) and authored tags/quotes are a later enhancement.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from ..schemas.case import CaseFile
|
| 13 |
+
from ..schemas.clue import Clue
|
| 14 |
+
from .public_view import (
|
| 15 |
+
FlashbackAccount,
|
| 16 |
+
PublicCase,
|
| 17 |
+
PublicEvidence,
|
| 18 |
+
PublicFlashback,
|
| 19 |
+
PublicMotive,
|
| 20 |
+
PublicSuspect,
|
| 21 |
+
PublicVictim,
|
| 22 |
+
StoryBeat,
|
| 23 |
+
SuggestedQuestion,
|
| 24 |
+
TimelineBeat,
|
| 25 |
+
)
|
| 26 |
+
from .questions import FIXED_QUESTIONS
|
| 27 |
+
|
| 28 |
+
_SCENES = ("skyline", "atrium", "interro", "seawall", "mezzanine", "desk")
|
| 29 |
+
_WEATHER = (
|
| 30 |
+
"Rain, and a wind off the water.", "Fog thick enough to lean on.",
|
| 31 |
+
"A dry cold that gets into the joints.", "Sleet against every window.",
|
| 32 |
+
)
|
| 33 |
+
_GREETS = (
|
| 34 |
+
"Detective. Let's get this over with.",
|
| 35 |
+
"I already told the others everything I know.",
|
| 36 |
+
"Ask what you came to ask.",
|
| 37 |
+
"I have nothing to hide, if that's what you think.",
|
| 38 |
+
)
|
| 39 |
+
_APPARENT = (
|
| 40 |
+
"Was close enough to the victim to have a reason - and a chance.",
|
| 41 |
+
"Had more to lose tonight than they're letting on.",
|
| 42 |
+
"Their story has a seam in it, if you pull the right thread.",
|
| 43 |
+
"Stood to gain from a death no one saw coming.",
|
| 44 |
+
)
|
| 45 |
+
_DECOY_MOTIVES = (
|
| 46 |
+
"To bury a debt that was about to surface.",
|
| 47 |
+
"Jealousy that had festered for years.",
|
| 48 |
+
"To keep a buried secret buried.",
|
| 49 |
+
"To settle an old score, finally.",
|
| 50 |
+
"Fear of being exposed and ruined.",
|
| 51 |
+
)
|
| 52 |
+
_ICONS = ("photoEv", "receipt", "keycard", "compass", "phone", "cctv")
|
| 53 |
+
# Noir city names so CITY reads as an actual city, not the venue (the venue goes in SCENE).
|
| 54 |
+
_CITIES = (
|
| 55 |
+
"Graymoor", "Blackport", "Ashmoor", "Harrowgate", "Duskwater", "Coldhaven",
|
| 56 |
+
"Ravenreach", "Mortlake", "Greyharbor", "Thornehaven", "Mistport", "Hollowmere",
|
| 57 |
+
"Saltmarsh", "Ironhaven", "Blackwater", "Fellgate",
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _hash(s: str) -> int:
|
| 62 |
+
h = 0
|
| 63 |
+
for ch in s:
|
| 64 |
+
h = (h * 31 + ord(ch)) & 0x7FFFFFFF
|
| 65 |
+
return h
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _clock(minute: int) -> str:
|
| 69 |
+
h = (minute // 60) % 24
|
| 70 |
+
m = minute % 60
|
| 71 |
+
suffix = "AM" if h < 12 else "PM"
|
| 72 |
+
h12 = h % 12 or 12
|
| 73 |
+
return f"{h12}:{m:02d} {suffix}"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _loc_name(case: CaseFile, loc_id: str) -> str:
|
| 77 |
+
for loc in case.setting.locations:
|
| 78 |
+
if loc.loc_id == loc_id:
|
| 79 |
+
return loc.name
|
| 80 |
+
return loc_id
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _suspect_public(case: CaseFile, idx: int) -> PublicSuspect:
|
| 84 |
+
s = case.suspects[idx]
|
| 85 |
+
seed = _hash(s.sus_id)
|
| 86 |
+
role_word = (s.role.split()[-1] if s.role else "").strip(".,").upper()
|
| 87 |
+
tag = f"THE {role_word}" if role_word else "PERSON OF INTEREST"
|
| 88 |
+
quote = (s.persona_summary.split(".")[0] + ".") if s.persona_summary else "I've nothing to hide."
|
| 89 |
+
gender = "female" if ((s.visual.gender if s.visual else "") or "").lower().startswith("f") else "male"
|
| 90 |
+
return PublicSuspect(
|
| 91 |
+
id=s.sus_id,
|
| 92 |
+
name=s.name,
|
| 93 |
+
role=s.role,
|
| 94 |
+
age=30 + (seed % 35),
|
| 95 |
+
sprite=s.sus_id,
|
| 96 |
+
gender=gender,
|
| 97 |
+
tag=tag[:22],
|
| 98 |
+
baseline_suspicion=25 + (seed % 16),
|
| 99 |
+
motive=_APPARENT[idx % len(_APPARENT)],
|
| 100 |
+
alibi=s.stated_alibi.claim_text,
|
| 101 |
+
quote=quote,
|
| 102 |
+
greet=_GREETS[idx % len(_GREETS)],
|
| 103 |
+
suggested_questions=tuple(SuggestedQuestion(id=q, q=text) for q, text in FIXED_QUESTIONS),
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _evidence_public(case: CaseFile, clue: Clue, idx: int) -> PublicEvidence:
|
| 108 |
+
at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
|
| 109 |
+
time = _clock(at) if at is not None else _clock(case.setting.murder_window.start_min + 7 * (idx + 1))
|
| 110 |
+
return PublicEvidence(
|
| 111 |
+
id=clue.clue_id,
|
| 112 |
+
name=clue.name.upper(),
|
| 113 |
+
type=clue.discovery_method.value.upper(),
|
| 114 |
+
icon=_ICONS[idx % len(_ICONS)],
|
| 115 |
+
time=time,
|
| 116 |
+
found=f"Recovered from {_loc_name(case, clue.discoverable_at_loc_id)}.",
|
| 117 |
+
summary=clue.reveal_text,
|
| 118 |
+
detail=clue.reveal_text,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _timeline(case: CaseFile) -> tuple[TimelineBeat, ...]:
|
| 123 |
+
beats: list[TimelineBeat] = []
|
| 124 |
+
w = case.setting.murder_window
|
| 125 |
+
beats.append(TimelineBeat(time=_clock(w.start_min), label="The evening is under way; everyone is in the house.", locked=True))
|
| 126 |
+
culprit = case.culprit.sus_id
|
| 127 |
+
for i, clue in enumerate(case.clues):
|
| 128 |
+
at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
|
| 129 |
+
t = at if at is not None else w.start_min + 7 * (i + 1)
|
| 130 |
+
conflict = clue.contradicts_alibi_of == culprit
|
| 131 |
+
beats.append(TimelineBeat(time=_clock(t), label=clue.reveal_text[:80], ev=clue.clue_id, conflict=conflict))
|
| 132 |
+
beats.append(TimelineBeat(time=_clock(case.victim.time_of_death.start_min), label=f"{case.victim.name} is killed. Time of death.", locked=True))
|
| 133 |
+
beats.sort(key=lambda b: b.time)
|
| 134 |
+
return tuple(beats)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _flashback(case: CaseFile) -> PublicFlashback:
|
| 138 |
+
culprit = next((s for s in case.suspects if s.sus_id == case.culprit.sus_id), case.suspects[0])
|
| 139 |
+
claimed = _loc_name(case, case.culprit.alibi_lie.claimed_loc_id)
|
| 140 |
+
crime = _loc_name(case, case.victim.found_at_loc_id)
|
| 141 |
+
breakers = [c for c in case.clues if c.contradicts_alibi_of == culprit.sus_id][:3]
|
| 142 |
+
return PublicFlashback(
|
| 143 |
+
title="TWO ACCOUNTS",
|
| 144 |
+
a=FlashbackAccount(
|
| 145 |
+
who=f"{culprit.name} SAYS",
|
| 146 |
+
scene=claimed,
|
| 147 |
+
lines=(culprit.stated_alibi.claim_text, f"I was in {claimed} the whole time.", "I never went near it."),
|
| 148 |
+
flags=(),
|
| 149 |
+
),
|
| 150 |
+
b=FlashbackAccount(
|
| 151 |
+
who="THE EVIDENCE SAYS",
|
| 152 |
+
scene=crime,
|
| 153 |
+
lines=tuple(c.reveal_text[:90] for c in breakers) or ("The evidence places someone at the scene.",),
|
| 154 |
+
flags=tuple(range(len(breakers))),
|
| 155 |
+
),
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _motives(case: CaseFile, seed: int) -> tuple[PublicMotive, ...]:
|
| 160 |
+
true_text = case.culprit.true_motive.summary
|
| 161 |
+
decoys = [d for d in _DECOY_MOTIVES if d.lower() != true_text.lower()]
|
| 162 |
+
chosen = [PublicMotive(id="M1", text=true_text)]
|
| 163 |
+
for i in range(3):
|
| 164 |
+
chosen.append(PublicMotive(id=f"MD{i + 1}", text=decoys[(seed + i) % len(decoys)]))
|
| 165 |
+
# stable shuffle by seed so the true option isn't always first
|
| 166 |
+
rot = seed % len(chosen)
|
| 167 |
+
return tuple(chosen[rot:] + chosen[:rot])
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _story_beats(case: CaseFile) -> tuple[StoryBeat, ...]:
|
| 171 |
+
v = case.victim
|
| 172 |
+
return (
|
| 173 |
+
StoryBeat(scene="skyline", kicker=case.setting.name.upper(), title="The call", text=case.briefing),
|
| 174 |
+
StoryBeat(scene="atrium", kicker="THE VICTIM", title=v.name, text=f"{v.name}, {v.role}. {v.cause_of_death}"),
|
| 175 |
+
StoryBeat(scene="interro", kicker="THOSE WHO STAYED", title="Persons of interest",
|
| 176 |
+
text="Each of them had a reason to be here tonight, and a story you'll need to take apart."),
|
| 177 |
+
StoryBeat(scene="seawall", kicker="YOUR CASE NOW", title="Detective",
|
| 178 |
+
text="One of them is lying to your face. Find the crack in the account and follow it down."),
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def casefile_to_public(case: CaseFile) -> PublicCase:
|
| 183 |
+
seed = case.seed
|
| 184 |
+
tod = case.victim.time_of_death.start_min
|
| 185 |
+
building = case.setting.name
|
| 186 |
+
room = _loc_name(case, case.victim.found_at_loc_id)
|
| 187 |
+
scene = f"{building} — {room}" # building + room; the painter keys off the room
|
| 188 |
+
city = _CITIES[seed % len(_CITIES)]
|
| 189 |
+
return PublicCase(
|
| 190 |
+
id=case.case_id,
|
| 191 |
+
city=city,
|
| 192 |
+
district=building,
|
| 193 |
+
title=case.title,
|
| 194 |
+
tagline="Detective, your presence is required.",
|
| 195 |
+
weather=_WEATHER[seed % len(_WEATHER)],
|
| 196 |
+
victim=PublicVictim(name=case.victim.name, role=case.victim.role, age=40 + (seed % 30), sprite="victim", bio=case.briefing),
|
| 197 |
+
scene=scene,
|
| 198 |
+
tod=_clock(tod),
|
| 199 |
+
found=f"Found in {scene} at {_clock(case.victim.found_at_min)}.",
|
| 200 |
+
cause=case.victim.cause_of_death,
|
| 201 |
+
facts=(
|
| 202 |
+
("CITY", city),
|
| 203 |
+
("VICTIM", f"{case.victim.name}"),
|
| 204 |
+
("SCENE", scene),
|
| 205 |
+
("TIME OF DEATH", _clock(tod)),
|
| 206 |
+
("CAUSE", case.victim.cause_of_death),
|
| 207 |
+
("VERDICT", "Homicide"),
|
| 208 |
+
),
|
| 209 |
+
boot_lines=(
|
| 210 |
+
"The phone drags you up out of half a sleep.",
|
| 211 |
+
f"{case.setting.name}. A death no one saw coming.",
|
| 212 |
+
f"{case.victim.name} - {case.victim.role}.",
|
| 213 |
+
"They're holding the scene. It's yours now, detective.",
|
| 214 |
+
),
|
| 215 |
+
story_beats=_story_beats(case),
|
| 216 |
+
suspects=tuple(_suspect_public(case, i) for i in range(len(case.suspects))),
|
| 217 |
+
evidence=tuple(_evidence_public(case, c, i) for i, c in enumerate(case.clues)),
|
| 218 |
+
timeline=_timeline(case),
|
| 219 |
+
flashback=_flashback(case),
|
| 220 |
+
motives=_motives(case, seed),
|
| 221 |
+
)
|
src/case_zero/api/dto.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wire DTOs for the public game API.
|
| 2 |
+
|
| 3 |
+
These are the exact shapes the Preact client exchanges with the server. The client
|
| 4 |
+
speaks camelCase; pydantic aliases map that to snake_case internally. The sealed
|
| 5 |
+
``CaseFile``/``Solution`` never appears here - only the public ``PlayerCaseView``
|
| 6 |
+
projection is ever returned before the verdict.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 12 |
+
|
| 13 |
+
from .public_view import PublicCase
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class _CamelModel(BaseModel):
|
| 17 |
+
"""Base: accept snake_case or camelCase on input, emit camelCase on output."""
|
| 18 |
+
|
| 19 |
+
model_config = ConfigDict(populate_by_name=True)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class NewCaseRequest(_CamelModel):
|
| 23 |
+
seed: int | None = None
|
| 24 |
+
case_id: str | None = Field(default=None, alias="caseId")
|
| 25 |
+
difficulty: str | None = None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class CaseResponse(_CamelModel):
|
| 29 |
+
case_id: str = Field(alias="caseId")
|
| 30 |
+
run_id: str = Field(alias="runId")
|
| 31 |
+
case: PublicCase
|
src/case_zero/api/hints.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Spoiler-safe contextual hints (Det. Hale). Server-side and case-aware, but they never
|
| 2 |
+
name the killer, motive, or key-evidence chain - only the screen and the player's visible
|
| 3 |
+
progress shape the guidance.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
_BASE = {
|
| 9 |
+
"briefing": "Read the file before you read people. Note the time of death - everything "
|
| 10 |
+
"turns on who could be where, and when.",
|
| 11 |
+
"story": "Don't look for the killer yet. The story tells you who was in the building and "
|
| 12 |
+
"why; the lies come when you start asking questions.",
|
| 13 |
+
"board": "Pick a suspect on the right and interrogate. Drag the exhibits as you think, "
|
| 14 |
+
"and examine anything that doesn't sit right.",
|
| 15 |
+
"evidence": "Examine each exhibit, then pin the ones that contradict a statement.",
|
| 16 |
+
"timeline": "Find the two events that fight each other. Where a statement collides with a "
|
| 17 |
+
"fact, an alibi falls apart.",
|
| 18 |
+
"flashback": "Where an account and the evidence diverge in oxblood is where the lie lives.",
|
| 19 |
+
"notes": "Line up motive against opportunity. Who could be where the evidence says, when "
|
| 20 |
+
"it says - and who had the most to lose?",
|
| 21 |
+
"accuse": "Don't guess. Means, then motive, then attach the evidence chain that proves it.",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def hint_for(screen: str, suspicion: dict[str, int]) -> str:
|
| 26 |
+
if screen == "interro":
|
| 27 |
+
hot = any(v >= 65 for v in suspicion.values())
|
| 28 |
+
if hot:
|
| 29 |
+
return (
|
| 30 |
+
"Their composure is cracking - press the contradiction now and don't let them "
|
| 31 |
+
"retreat to their alibi."
|
| 32 |
+
)
|
| 33 |
+
return (
|
| 34 |
+
"Get their story on record with the soft questions, then present the exhibit that "
|
| 35 |
+
"contradicts it and watch their composure move."
|
| 36 |
+
)
|
| 37 |
+
return _BASE.get(screen, "Take the case, detective. Start with the file, then the people.")
|
src/case_zero/api/public_view.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The PUBLIC wire contract: the exact case shape the Preact client receives.
|
| 2 |
+
|
| 3 |
+
This mirrors the prototype's ``prototype/js/case.jsx`` (the data-model-by-example) in
|
| 4 |
+
camelCase. It is the ONLY case representation that ever reaches the browser before the
|
| 5 |
+
verdict. The sealed solution - killer, true motive, key-evidence chain - and every
|
| 6 |
+
per-question/per-evidence suspicion *delta* and scripted *answer* are deliberately absent:
|
| 7 |
+
those are produced live, server-side (the deltas alone would reveal the killer).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 13 |
+
from pydantic.alias_generators import to_camel
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class _Wire(BaseModel):
|
| 17 |
+
"""camelCase on the wire; snake_case in Python. Frozen - a view is never mutated."""
|
| 18 |
+
|
| 19 |
+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, frozen=True)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class PublicVictim(_Wire):
|
| 23 |
+
name: str
|
| 24 |
+
role: str
|
| 25 |
+
age: int
|
| 26 |
+
sprite: str
|
| 27 |
+
bio: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class SuggestedQuestion(_Wire):
|
| 31 |
+
id: str
|
| 32 |
+
q: str
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class PublicSuspect(_Wire):
|
| 36 |
+
id: str
|
| 37 |
+
name: str
|
| 38 |
+
role: str
|
| 39 |
+
age: int
|
| 40 |
+
sprite: str
|
| 41 |
+
gender: str = "male"
|
| 42 |
+
tag: str
|
| 43 |
+
baseline_suspicion: int
|
| 44 |
+
motive: str # apparent motive shown in the dossier - NOT proof
|
| 45 |
+
alibi: str
|
| 46 |
+
quote: str
|
| 47 |
+
greet: str
|
| 48 |
+
suggested_questions: tuple[SuggestedQuestion, ...]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class ThreadMessage(_Wire):
|
| 52 |
+
from_: str = Field(alias="from")
|
| 53 |
+
who: str
|
| 54 |
+
t: str
|
| 55 |
+
m: str
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class PublicEvidence(_Wire):
|
| 59 |
+
id: str
|
| 60 |
+
name: str
|
| 61 |
+
type: str # PHONE | PAPER | IMAGE | AUDIO | DATA
|
| 62 |
+
icon: str
|
| 63 |
+
time: str
|
| 64 |
+
found: str
|
| 65 |
+
summary: str
|
| 66 |
+
# Exactly one of these display payloads is populated, per type.
|
| 67 |
+
thread: tuple[ThreadMessage, ...] | None = None
|
| 68 |
+
detail: str | None = None
|
| 69 |
+
transcript: str | None = None
|
| 70 |
+
dur: str | None = None
|
| 71 |
+
rows: tuple[tuple[str, ...], ...] | None = None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class TimelineBeat(_Wire):
|
| 75 |
+
time: str
|
| 76 |
+
label: str
|
| 77 |
+
locked: bool = False
|
| 78 |
+
ev: str | None = None
|
| 79 |
+
conflict: bool = False
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class FlashbackAccount(_Wire):
|
| 83 |
+
who: str
|
| 84 |
+
scene: str
|
| 85 |
+
lines: tuple[str, ...]
|
| 86 |
+
flags: tuple[int, ...] = ()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class PublicFlashback(_Wire):
|
| 90 |
+
title: str
|
| 91 |
+
a: FlashbackAccount
|
| 92 |
+
b: FlashbackAccount
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class PublicMotive(_Wire):
|
| 96 |
+
id: str
|
| 97 |
+
text: str
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class StoryBeat(_Wire):
|
| 101 |
+
scene: str
|
| 102 |
+
kicker: str
|
| 103 |
+
title: str
|
| 104 |
+
text: str
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class PublicCase(_Wire):
|
| 108 |
+
id: str
|
| 109 |
+
city: str
|
| 110 |
+
district: str
|
| 111 |
+
title: str
|
| 112 |
+
tagline: str
|
| 113 |
+
weather: str
|
| 114 |
+
victim: PublicVictim
|
| 115 |
+
scene: str
|
| 116 |
+
tod: str
|
| 117 |
+
found: str
|
| 118 |
+
cause: str
|
| 119 |
+
facts: tuple[tuple[str, str], ...]
|
| 120 |
+
boot_lines: tuple[str, ...]
|
| 121 |
+
story_beats: tuple[StoryBeat, ...]
|
| 122 |
+
suspects: tuple[PublicSuspect, ...]
|
| 123 |
+
evidence: tuple[PublicEvidence, ...]
|
| 124 |
+
timeline: tuple[TimelineBeat, ...]
|
| 125 |
+
flashback: PublicFlashback
|
| 126 |
+
motives: tuple[PublicMotive, ...]
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def golden_to_public(data: dict) -> PublicCase:
|
| 130 |
+
"""Project a sealed golden/stored case dict into the PUBLIC view.
|
| 131 |
+
|
| 132 |
+
Strips the ``sealed`` block and, per suspect, the scripted ``answer``/``delta`` and
|
| 133 |
+
the ``present``/``default`` reply tables - leaving only question text the player sees.
|
| 134 |
+
"""
|
| 135 |
+
suspects = tuple(
|
| 136 |
+
PublicSuspect(
|
| 137 |
+
id=s["id"],
|
| 138 |
+
name=s["name"],
|
| 139 |
+
role=s["role"],
|
| 140 |
+
age=s["age"],
|
| 141 |
+
sprite=s["sprite"],
|
| 142 |
+
gender=s.get("gender", "male"),
|
| 143 |
+
tag=s["tag"],
|
| 144 |
+
baseline_suspicion=s["suspicion"],
|
| 145 |
+
motive=s["motive"],
|
| 146 |
+
alibi=s["alibi"],
|
| 147 |
+
quote=s["quote"],
|
| 148 |
+
greet=s["greet"],
|
| 149 |
+
suggested_questions=tuple(
|
| 150 |
+
SuggestedQuestion(id=q["id"], q=q["q"]) for q in s["questions"]
|
| 151 |
+
),
|
| 152 |
+
)
|
| 153 |
+
for s in data["suspects"]
|
| 154 |
+
)
|
| 155 |
+
return PublicCase(
|
| 156 |
+
id=data["id"],
|
| 157 |
+
city=data["city"],
|
| 158 |
+
district=data["district"],
|
| 159 |
+
title=data["title"],
|
| 160 |
+
tagline=data["tagline"],
|
| 161 |
+
weather=data["weather"],
|
| 162 |
+
victim=PublicVictim(**data["victim"]),
|
| 163 |
+
scene=data["scene"],
|
| 164 |
+
tod=data["tod"],
|
| 165 |
+
found=data["found"],
|
| 166 |
+
cause=data["cause"],
|
| 167 |
+
facts=tuple(tuple(pair) for pair in data["facts"]),
|
| 168 |
+
boot_lines=tuple(data["bootLines"]),
|
| 169 |
+
story_beats=tuple(StoryBeat(**b) for b in data.get("storyBeats", [])),
|
| 170 |
+
suspects=suspects,
|
| 171 |
+
evidence=tuple(PublicEvidence(**e) for e in data["evidence"]),
|
| 172 |
+
timeline=tuple(TimelineBeat(**b) for b in data["timeline"]),
|
| 173 |
+
flashback=PublicFlashback(
|
| 174 |
+
title=data["flashback"]["title"],
|
| 175 |
+
a=FlashbackAccount(**data["flashback"]["a"]),
|
| 176 |
+
b=FlashbackAccount(**data["flashback"]["b"]),
|
| 177 |
+
),
|
| 178 |
+
motives=tuple(PublicMotive(**m) for m in data["motives"]),
|
| 179 |
+
)
|
src/case_zero/api/questions.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generic suggested questions for generated cases. Their *answers* are produced live by
|
| 2 |
+
the interrogation engine, so the same set works for any case; only the text is public.
|
| 3 |
+
The golden case ships its own authored questions instead.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
FIXED_QUESTIONS: tuple[tuple[str, str], ...] = (
|
| 9 |
+
("q0", "Where were you when it happened?"),
|
| 10 |
+
("q1", "What was your relationship with the victim?"),
|
| 11 |
+
("q2", "Did you see anyone near the scene?"),
|
| 12 |
+
("q3", "Is there anything you haven't told me?"),
|
| 13 |
+
("q4", "Walk me through your evening."),
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
_BY_ID = dict(FIXED_QUESTIONS)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def question_text(qid: str) -> str | None:
|
| 20 |
+
return _BY_ID.get(qid)
|
src/case_zero/api/routes_case.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Case lifecycle: create a new case (generate, or load by ID) and fetch a public view.
|
| 2 |
+
|
| 3 |
+
New Case generates a fresh, solvable mystery via the in-process LLM (buffered one-ahead);
|
| 4 |
+
if generation is unavailable (no weights / load failure) it falls back to the GRAYMOOR-3107
|
| 5 |
+
golden case so the loop is always playable. Generated cases are persisted by Case ID, so a
|
| 6 |
+
shared ID reloads the identical mystery. The sealed solution is never in any response here.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, HTTPException
|
| 12 |
+
|
| 13 |
+
from ..persistence.golden import golden_exists, load_golden
|
| 14 |
+
from ..persistence.run_store import create_run
|
| 15 |
+
from .dto import CaseResponse, NewCaseRequest
|
| 16 |
+
from .public_view import golden_to_public
|
| 17 |
+
from .runtime import RUNTIME
|
| 18 |
+
|
| 19 |
+
router = APIRouter(prefix="/api", tags=["case"])
|
| 20 |
+
|
| 21 |
+
DEFAULT_CASE_ID = "GRAYMOOR-3107"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _golden_response(case_id: str) -> CaseResponse:
|
| 25 |
+
run = create_run(case_id)
|
| 26 |
+
return CaseResponse(case_id=case_id, run_id=run.run_id, case=golden_to_public(load_golden(case_id)))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.post("/case", response_model=CaseResponse)
|
| 30 |
+
def new_case(req: NewCaseRequest) -> CaseResponse:
|
| 31 |
+
if req.case_id:
|
| 32 |
+
if golden_exists(req.case_id):
|
| 33 |
+
return _golden_response(req.case_id)
|
| 34 |
+
loaded = RUNTIME.load_generated_run(req.case_id)
|
| 35 |
+
if loaded is not None:
|
| 36 |
+
public, run_id = loaded
|
| 37 |
+
return CaseResponse(case_id=public.id, run_id=run_id, case=public)
|
| 38 |
+
return _golden_response(DEFAULT_CASE_ID)
|
| 39 |
+
|
| 40 |
+
generated = RUNTIME.new_generated_run()
|
| 41 |
+
if generated is not None:
|
| 42 |
+
public, run_id = generated
|
| 43 |
+
return CaseResponse(case_id=public.id, run_id=run_id, case=public)
|
| 44 |
+
return _golden_response(DEFAULT_CASE_ID)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.get("/case/{case_id}", response_model=CaseResponse)
|
| 48 |
+
def get_case(case_id: str) -> CaseResponse:
|
| 49 |
+
if golden_exists(case_id):
|
| 50 |
+
return _golden_response(case_id)
|
| 51 |
+
loaded = RUNTIME.load_generated_run(case_id)
|
| 52 |
+
if loaded is not None:
|
| 53 |
+
public, run_id = loaded
|
| 54 |
+
return CaseResponse(case_id=public.id, run_id=run_id, case=public)
|
| 55 |
+
raise HTTPException(status_code=404, detail=f"case not found: {case_id}")
|
src/case_zero/api/routes_run.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-run routes: interrogate (scripted golden engine), hint, and accuse (verdict).
|
| 2 |
+
|
| 3 |
+
The sealed solution is read for the FIRST time at /accuse - never before. Suspicion is
|
| 4 |
+
computed and held server-side; the client only displays it.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, HTTPException
|
| 10 |
+
from fastapi.responses import FileResponse
|
| 11 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 12 |
+
|
| 13 |
+
from ..persistence.golden import load_golden
|
| 14 |
+
from ..persistence.run_store import get_run
|
| 15 |
+
from ..schemas.suspect import VoiceAssignment
|
| 16 |
+
from ..tts.assignment import assign_voice
|
| 17 |
+
from .hints import hint_for
|
| 18 |
+
from .questions import question_text
|
| 19 |
+
from .runtime import RUNTIME
|
| 20 |
+
from .scripted import CORNERED_DELTA, RATTLED_DELTA, scripted_turn
|
| 21 |
+
from .tts_service import TTS, voice_seed
|
| 22 |
+
|
| 23 |
+
router = APIRouter(prefix="/api/run", tags=["run"])
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class _Camel(BaseModel):
|
| 27 |
+
model_config = ConfigDict(populate_by_name=True)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class InterrogateBody(_Camel):
|
| 31 |
+
question_id: str | None = Field(default=None, alias="questionId")
|
| 32 |
+
free_text: str | None = Field(default=None, alias="freeText")
|
| 33 |
+
present_evidence_id: str | None = Field(default=None, alias="presentEvidenceId")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class Flags(_Camel):
|
| 37 |
+
rattled: bool = False
|
| 38 |
+
contradiction_exposed: bool = Field(default=False, alias="contradictionExposed")
|
| 39 |
+
cornered: bool = False
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class InterrogateResult(_Camel):
|
| 43 |
+
reply: str
|
| 44 |
+
suspicion_delta: int = Field(alias="suspicionDelta")
|
| 45 |
+
suspicion: int
|
| 46 |
+
flags: Flags
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.post("/{run_id}/interrogate/{sus_id}", response_model=InterrogateResult)
|
| 50 |
+
def interrogate(run_id: str, sus_id: str, body: InterrogateBody) -> InterrogateResult:
|
| 51 |
+
# Live (generated) run -> the in-process LLM engine.
|
| 52 |
+
live = RUNTIME.get(run_id)
|
| 53 |
+
if live is not None:
|
| 54 |
+
if not any(s.sus_id == sus_id for s in live.case.suspects):
|
| 55 |
+
raise HTTPException(status_code=404, detail="suspect not found")
|
| 56 |
+
if body.present_evidence_id is not None:
|
| 57 |
+
question = body.free_text or "Explain this to me."
|
| 58 |
+
clue_id = body.present_evidence_id
|
| 59 |
+
elif body.question_id is not None:
|
| 60 |
+
question = question_text(body.question_id) or "Tell me what happened."
|
| 61 |
+
clue_id = None
|
| 62 |
+
else:
|
| 63 |
+
question = body.free_text or "Tell me what happened."
|
| 64 |
+
clue_id = None
|
| 65 |
+
out = RUNTIME.interrogate_live(live, sus_id, question, clue_id)
|
| 66 |
+
return InterrogateResult(
|
| 67 |
+
reply=out["reply"],
|
| 68 |
+
suspicion_delta=out["suspicionDelta"],
|
| 69 |
+
suspicion=out["suspicion"],
|
| 70 |
+
flags=Flags(**out["flags"]),
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# Scripted (golden) run.
|
| 74 |
+
run = get_run(run_id)
|
| 75 |
+
if run is None:
|
| 76 |
+
raise HTTPException(status_code=404, detail="run not found")
|
| 77 |
+
golden = load_golden(run.case_id)
|
| 78 |
+
try:
|
| 79 |
+
reply, delta = scripted_turn(
|
| 80 |
+
golden,
|
| 81 |
+
sus_id,
|
| 82 |
+
question_id=body.question_id,
|
| 83 |
+
present_evidence_id=body.present_evidence_id,
|
| 84 |
+
free_text=body.free_text,
|
| 85 |
+
)
|
| 86 |
+
except KeyError as exc:
|
| 87 |
+
raise HTTPException(status_code=404, detail="suspect not found") from exc
|
| 88 |
+
|
| 89 |
+
cur = run.suspicion.get(sus_id, 0)
|
| 90 |
+
new_value = max(0, min(100, cur + delta))
|
| 91 |
+
run.suspicion[sus_id] = new_value
|
| 92 |
+
flags = Flags(
|
| 93 |
+
rattled=delta >= RATTLED_DELTA,
|
| 94 |
+
contradiction_exposed=delta >= RATTLED_DELTA,
|
| 95 |
+
cornered=delta >= CORNERED_DELTA,
|
| 96 |
+
)
|
| 97 |
+
return InterrogateResult(reply=reply, suspicion_delta=delta, suspicion=new_value, flags=flags)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@router.get("/{run_id}/hint")
|
| 101 |
+
def hint(run_id: str, screen: str = "") -> dict[str, str]:
|
| 102 |
+
live = RUNTIME.get(run_id)
|
| 103 |
+
if live is not None:
|
| 104 |
+
suspicion = {s.id: RUNTIME._suspicion(live, s.id) for s in live.public.suspects}
|
| 105 |
+
return {"hint": hint_for(screen, suspicion)}
|
| 106 |
+
run = get_run(run_id)
|
| 107 |
+
if run is None:
|
| 108 |
+
raise HTTPException(status_code=404, detail="run not found")
|
| 109 |
+
return {"hint": hint_for(screen, run.suspicion)}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class AccuseBody(_Camel):
|
| 113 |
+
suspect_id: str = Field(alias="suspectId")
|
| 114 |
+
motive_id: str = Field(alias="motiveId")
|
| 115 |
+
evidence_ids: list[str] = Field(default_factory=list, alias="evidenceIds")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _suspect_name(golden: dict, sus_id: str) -> str:
|
| 119 |
+
for s in golden["suspects"]:
|
| 120 |
+
if s["id"] == sus_id:
|
| 121 |
+
return s["name"]
|
| 122 |
+
return "the accused"
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@router.post("/{run_id}/accuse")
|
| 126 |
+
def accuse(run_id: str, body: AccuseBody) -> dict:
|
| 127 |
+
live = RUNTIME.get(run_id)
|
| 128 |
+
if live is not None:
|
| 129 |
+
return RUNTIME.accuse_live(live, body.suspect_id, body.motive_id, body.evidence_ids)
|
| 130 |
+
|
| 131 |
+
run = get_run(run_id)
|
| 132 |
+
if run is None:
|
| 133 |
+
raise HTTPException(status_code=404, detail="run not found")
|
| 134 |
+
golden = load_golden(run.case_id)
|
| 135 |
+
sealed = golden["sealed"]
|
| 136 |
+
|
| 137 |
+
killer_id = sealed["killer"]
|
| 138 |
+
killer_name = _suspect_name(golden, killer_id)
|
| 139 |
+
killer_correct = body.suspect_id == killer_id
|
| 140 |
+
motive_correct = body.motive_id == sealed["correctMotive"]
|
| 141 |
+
key = set(sealed.get("keyEvidence", []))
|
| 142 |
+
hits = len(key.intersection(body.evidence_ids))
|
| 143 |
+
|
| 144 |
+
# Graded: killer is the bulk; motive + a complete evidence chain round it out.
|
| 145 |
+
points = (60 if killer_correct else 0)
|
| 146 |
+
points += 20 if motive_correct else 0
|
| 147 |
+
points += round((hits / len(key)) * 20) if key else 0
|
| 148 |
+
|
| 149 |
+
if killer_correct:
|
| 150 |
+
truth = sealed.get("truth") or f"It was {killer_name}. The evidence held."
|
| 151 |
+
else:
|
| 152 |
+
accused = _suspect_name(golden, body.suspect_id)
|
| 153 |
+
truth = (
|
| 154 |
+
f"You charged {accused}. The case held for a night - but the keycard, the camera, "
|
| 155 |
+
f"and the voicemail all led past {accused} to {killer_name}, who walked out into the rain."
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
return {
|
| 159 |
+
"correct": killer_correct,
|
| 160 |
+
"verdict": {
|
| 161 |
+
"stamp": "CASE CLOSED" if killer_correct else "MISTRIAL",
|
| 162 |
+
"killerId": killer_id,
|
| 163 |
+
"killerName": killer_name,
|
| 164 |
+
"truth": truth,
|
| 165 |
+
},
|
| 166 |
+
"score": {
|
| 167 |
+
"points": points,
|
| 168 |
+
"max": 100,
|
| 169 |
+
"killerCorrect": killer_correct,
|
| 170 |
+
"motiveCorrect": motive_correct,
|
| 171 |
+
"evidenceHits": hits,
|
| 172 |
+
},
|
| 173 |
+
"stats": [],
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
class TtsBody(_Camel):
|
| 178 |
+
text: str = ""
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _voice_for(run_id: str, sus_id: str) -> VoiceAssignment:
|
| 182 |
+
live = RUNTIME.get(run_id)
|
| 183 |
+
if live is not None:
|
| 184 |
+
for s in live.case.suspects:
|
| 185 |
+
if s.sus_id == sus_id:
|
| 186 |
+
return assign_voice(s)
|
| 187 |
+
run = get_run(run_id)
|
| 188 |
+
if run is not None:
|
| 189 |
+
golden = load_golden(run.case_id)
|
| 190 |
+
s = next((x for x in golden["suspects"] if x["id"] == sus_id), None)
|
| 191 |
+
female = bool(s and (s.get("gender", "") or "").lower().startswith("f"))
|
| 192 |
+
return voice_seed(sus_id, female=female)
|
| 193 |
+
return voice_seed(sus_id)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@router.post("/{run_id}/tts/{sus_id}")
|
| 197 |
+
def tts(run_id: str, sus_id: str, body: TtsBody) -> FileResponse:
|
| 198 |
+
"""Synthesize the suspect's spoken reply with their on-device Supertonic voice."""
|
| 199 |
+
path = TTS.synth(body.text, _voice_for(run_id, sus_id))
|
| 200 |
+
if path is None:
|
| 201 |
+
raise HTTPException(status_code=503, detail="voice unavailable")
|
| 202 |
+
return FileResponse(path, media_type="audio/wav")
|
src/case_zero/api/runtime.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live game runtime: lazily builds the in-process llama.cpp backend, generates cases,
|
| 2 |
+
and holds live ``Session`` objects per run.
|
| 3 |
+
|
| 4 |
+
Single-flight is MANDATORY: ``llama_cpp.Llama`` is not thread-safe, so every model call
|
| 5 |
+
(generation + interrogation) runs under one lock - never concurrently, on any machine.
|
| 6 |
+
To keep that lock from ever blocking an interrogation, case generation only happens when
|
| 7 |
+
no one is playing yet: one case is prebuilt at startup, and each later New Case generates
|
| 8 |
+
synchronously while the player waits on the loading screen (no background generation runs
|
| 9 |
+
during play). On a 2-vCPU Space this also means the LLM is never oversubscribed.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import threading
|
| 15 |
+
import time
|
| 16 |
+
import uuid
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
from ..config import effective_cpus, get_settings
|
| 20 |
+
from ..engine.session import Session
|
| 21 |
+
from ..generator.pipeline import generate_case
|
| 22 |
+
from ..llm.backend import LLMBackend, LLMError, make_backend
|
| 23 |
+
from ..persistence.case_store import load_case, save_runtime_case
|
| 24 |
+
from ..persistence.paths import prebaked_cases_dir, runtime_cases_dir
|
| 25 |
+
from ..schemas.accusation import Accusation
|
| 26 |
+
from ..schemas.case import CaseFile
|
| 27 |
+
from ..schemas.enums import Relevance
|
| 28 |
+
from .case_adapter import casefile_to_public
|
| 29 |
+
from .public_view import PublicCase
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class LiveRun:
|
| 34 |
+
run_id: str
|
| 35 |
+
case: CaseFile
|
| 36 |
+
session: Session
|
| 37 |
+
public: PublicCase
|
| 38 |
+
baselines: dict[str, int]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class GameRuntime:
|
| 42 |
+
def __init__(self) -> None:
|
| 43 |
+
self._lock = threading.Lock() # MANDATORY single-flight over all model calls
|
| 44 |
+
self._backend: LLMBackend | None = None
|
| 45 |
+
self._backend_failed = False
|
| 46 |
+
self._runs: dict[str, LiveRun] = {}
|
| 47 |
+
self._buffer: CaseFile | None = None
|
| 48 |
+
self._buffer_lock = threading.Lock()
|
| 49 |
+
self._seed = int(time.time()) % 900_000 + 1000
|
| 50 |
+
# Pre-baked pool: full, model-authored cases shipped with the Space, served instantly
|
| 51 |
+
# on New Case so nobody waits ~2 min for live generation. Interrogation is still live.
|
| 52 |
+
self._prebaked: list[CaseFile] = []
|
| 53 |
+
self._prebaked_idx = 0
|
| 54 |
+
self._prebaked_loaded = False
|
| 55 |
+
# Only generate cases ahead-of-time in the background on a box with cores to spare;
|
| 56 |
+
# on the 2-vCPU Space that would steal the single model lock from an interrogation,
|
| 57 |
+
# so there we rely on the pre-baked pool and generate only on demand.
|
| 58 |
+
self._gen_ahead = effective_cpus() > 4
|
| 59 |
+
|
| 60 |
+
# ---- backend ----
|
| 61 |
+
def _get_backend(self) -> LLMBackend | None:
|
| 62 |
+
if self._backend is None and not self._backend_failed:
|
| 63 |
+
try:
|
| 64 |
+
self._backend = make_backend(get_settings())
|
| 65 |
+
except LLMError:
|
| 66 |
+
self._backend_failed = True
|
| 67 |
+
return self._backend
|
| 68 |
+
|
| 69 |
+
def available(self) -> bool:
|
| 70 |
+
return self._get_backend() is not None
|
| 71 |
+
|
| 72 |
+
def _next_seed(self) -> int:
|
| 73 |
+
self._seed += 1
|
| 74 |
+
return self._seed
|
| 75 |
+
|
| 76 |
+
# ---- generation ----
|
| 77 |
+
def _generate(self, seed: int) -> CaseFile:
|
| 78 |
+
backend = self._get_backend()
|
| 79 |
+
if backend is None:
|
| 80 |
+
raise LLMError("no backend")
|
| 81 |
+
with self._lock:
|
| 82 |
+
result = generate_case(backend, seed=seed)
|
| 83 |
+
save_runtime_case(result.case)
|
| 84 |
+
return result.case
|
| 85 |
+
|
| 86 |
+
def _prebuild(self) -> None:
|
| 87 |
+
try:
|
| 88 |
+
case = self._generate(self._next_seed())
|
| 89 |
+
with self._buffer_lock:
|
| 90 |
+
self._buffer = case
|
| 91 |
+
except Exception:
|
| 92 |
+
pass
|
| 93 |
+
|
| 94 |
+
def _load_prebaked(self) -> None:
|
| 95 |
+
if self._prebaked_loaded:
|
| 96 |
+
return
|
| 97 |
+
self._prebaked_loaded = True
|
| 98 |
+
pool_dir = prebaked_cases_dir()
|
| 99 |
+
if not pool_dir.is_dir():
|
| 100 |
+
return
|
| 101 |
+
for path in sorted(pool_dir.glob("*.json")):
|
| 102 |
+
try:
|
| 103 |
+
self._prebaked.append(load_case(path))
|
| 104 |
+
except Exception:
|
| 105 |
+
continue
|
| 106 |
+
# Start the rotation at a varied offset so a Space restart doesn't always serve the
|
| 107 |
+
# first case again (the seed is time-based), keeping New Case fresh across restarts.
|
| 108 |
+
if self._prebaked:
|
| 109 |
+
self._prebaked_idx = self._seed % len(self._prebaked)
|
| 110 |
+
|
| 111 |
+
def start_buffer(self) -> None:
|
| 112 |
+
"""Make the first New Case instant: load the shipped pool now and (only on a box with
|
| 113 |
+
cores to spare) prebuild one fresh live case in the background. On the 2-vCPU Space we
|
| 114 |
+
skip the background prebuild so the model lock stays free for the first interrogation -
|
| 115 |
+
the pre-baked pool already gives an instant case."""
|
| 116 |
+
self._load_prebaked()
|
| 117 |
+
if self._gen_ahead and self.available():
|
| 118 |
+
threading.Thread(target=self._prebuild, daemon=True).start()
|
| 119 |
+
|
| 120 |
+
def _take_buffered(self) -> CaseFile | None:
|
| 121 |
+
with self._buffer_lock:
|
| 122 |
+
case = self._buffer
|
| 123 |
+
self._buffer = None
|
| 124 |
+
return case
|
| 125 |
+
|
| 126 |
+
def _take_prebaked(self) -> CaseFile | None:
|
| 127 |
+
self._load_prebaked()
|
| 128 |
+
if not self._prebaked:
|
| 129 |
+
return None
|
| 130 |
+
case = self._prebaked[self._prebaked_idx % len(self._prebaked)]
|
| 131 |
+
self._prebaked_idx += 1
|
| 132 |
+
return case
|
| 133 |
+
|
| 134 |
+
def _maybe_refill(self) -> None:
|
| 135 |
+
"""Generate one fresh case in the background - capable hardware only (see _gen_ahead)."""
|
| 136 |
+
if self._gen_ahead and self._buffer is None and self.available():
|
| 137 |
+
threading.Thread(target=self._prebuild, daemon=True).start()
|
| 138 |
+
|
| 139 |
+
def new_generated_run(self) -> tuple[PublicCase, str] | None:
|
| 140 |
+
if not self.available():
|
| 141 |
+
return None
|
| 142 |
+
# Prefer a freshly generated case if one is ready; else serve the pre-baked pool
|
| 143 |
+
# instantly; only with neither do we generate synchronously (first run, no pool).
|
| 144 |
+
case = self._take_buffered() or self._take_prebaked()
|
| 145 |
+
if case is None:
|
| 146 |
+
try:
|
| 147 |
+
case = self._generate(self._next_seed())
|
| 148 |
+
except Exception:
|
| 149 |
+
return None
|
| 150 |
+
self._maybe_refill()
|
| 151 |
+
return self._register(case)
|
| 152 |
+
|
| 153 |
+
def load_generated_run(self, case_id: str) -> tuple[PublicCase, str] | None:
|
| 154 |
+
if not self.available():
|
| 155 |
+
return None
|
| 156 |
+
self._load_prebaked()
|
| 157 |
+
case = next((c for c in self._prebaked if c.case_id == case_id), None)
|
| 158 |
+
if case is None:
|
| 159 |
+
for directory in (prebaked_cases_dir(), runtime_cases_dir()):
|
| 160 |
+
path = directory / f"{case_id}.json"
|
| 161 |
+
if path.exists():
|
| 162 |
+
try:
|
| 163 |
+
case = load_case(path)
|
| 164 |
+
except Exception:
|
| 165 |
+
case = None
|
| 166 |
+
break
|
| 167 |
+
if case is None:
|
| 168 |
+
return None
|
| 169 |
+
return self._register(case)
|
| 170 |
+
|
| 171 |
+
def _register(self, case: CaseFile) -> tuple[PublicCase, str]:
|
| 172 |
+
public = casefile_to_public(case)
|
| 173 |
+
session = Session(case, self._get_backend()) # type: ignore[arg-type]
|
| 174 |
+
run_id = uuid.uuid4().hex
|
| 175 |
+
baselines = {s.id: s.baseline_suspicion for s in public.suspects}
|
| 176 |
+
self._runs[run_id] = LiveRun(run_id, case, session, public, baselines)
|
| 177 |
+
return public, run_id
|
| 178 |
+
|
| 179 |
+
def get(self, run_id: str) -> LiveRun | None:
|
| 180 |
+
return self._runs.get(run_id)
|
| 181 |
+
|
| 182 |
+
# ---- live turn / verdict ----
|
| 183 |
+
def _suspicion(self, run: LiveRun, sus_id: str) -> int:
|
| 184 |
+
st = run.session.state.state_for(sus_id)
|
| 185 |
+
base = run.baselines.get(sus_id, 25)
|
| 186 |
+
val = base + round(st.stress * 55) + (20 if st.broken_lie_ids else 0)
|
| 187 |
+
return max(0, min(100, val))
|
| 188 |
+
|
| 189 |
+
def interrogate_live(
|
| 190 |
+
self, run: LiveRun, sus_id: str, question: str, clue_id: str | None
|
| 191 |
+
) -> dict:
|
| 192 |
+
prev = self._suspicion(run, sus_id)
|
| 193 |
+
with self._lock:
|
| 194 |
+
final = None
|
| 195 |
+
for ev in run.session.interrogate(sus_id, question, presented_clue_id=clue_id):
|
| 196 |
+
if ev.final is not None:
|
| 197 |
+
final = ev.final
|
| 198 |
+
reply = final.turn.spoken if final else "…I have nothing to say to that."
|
| 199 |
+
after = self._suspicion(run, sus_id)
|
| 200 |
+
adj = final.adjudication if final else None
|
| 201 |
+
rattled = bool(adj and adj.relevance in (Relevance.DIRECT, Relevance.BREAKING))
|
| 202 |
+
cornered = bool(adj and adj.is_contradiction)
|
| 203 |
+
return {
|
| 204 |
+
"reply": reply,
|
| 205 |
+
"suspicionDelta": after - prev,
|
| 206 |
+
"suspicion": after,
|
| 207 |
+
"flags": {"rattled": rattled, "contradictionExposed": cornered, "cornered": cornered},
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
def accuse_live(self, run: LiveRun, suspect_id: str, motive_id: str, evidence_ids: list[str]) -> dict:
|
| 211 |
+
verdict = run.session.accuse(
|
| 212 |
+
Accusation(accused_sus_id=suspect_id, motive_id=motive_id, cited_clue_ids=tuple(evidence_ids))
|
| 213 |
+
)
|
| 214 |
+
culprit_id = run.case.culprit.sus_id
|
| 215 |
+
killer = run.case.suspect(culprit_id)
|
| 216 |
+
if verdict.culprit_correct:
|
| 217 |
+
truth = verdict.rationale or run.case.culprit.method_narrative
|
| 218 |
+
else:
|
| 219 |
+
accused = run.case.suspect(suspect_id).name if any(s.sus_id == suspect_id for s in run.case.suspects) else "the accused"
|
| 220 |
+
truth = (
|
| 221 |
+
f"You charged {accused}. The case held for a night - but the evidence led past "
|
| 222 |
+
f"them to {killer.name}, who walked out into the rain."
|
| 223 |
+
)
|
| 224 |
+
return {
|
| 225 |
+
"correct": verdict.culprit_correct,
|
| 226 |
+
"verdict": {
|
| 227 |
+
"stamp": "CASE CLOSED" if verdict.culprit_correct else "MISTRIAL",
|
| 228 |
+
"killerId": culprit_id,
|
| 229 |
+
"killerName": killer.name,
|
| 230 |
+
"truth": truth,
|
| 231 |
+
},
|
| 232 |
+
"score": {
|
| 233 |
+
"points": verdict.score,
|
| 234 |
+
"max": 100,
|
| 235 |
+
"killerCorrect": verdict.culprit_correct,
|
| 236 |
+
"motiveCorrect": verdict.motive_correct,
|
| 237 |
+
"evidenceHits": len(evidence_ids),
|
| 238 |
+
},
|
| 239 |
+
"stats": [],
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
RUNTIME = GameRuntime()
|
src/case_zero/api/scripted.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic scripted interrogation for golden / seed cases (no LLM).
|
| 2 |
+
|
| 3 |
+
Reads the golden's per-suspect answer/delta tables directly, so the golden case plays
|
| 4 |
+
instantly and identically every time - ideal for the demo, tests, and offline CI. The
|
| 5 |
+
live in-process llama.cpp engine replaces this for generated cases (same wire result).
|
| 6 |
+
The deltas never reach the client; only the suspect's spoken reply + the resulting
|
| 7 |
+
server-side suspicion do.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
RATTLED_DELTA = 14
|
| 13 |
+
CORNERED_DELTA = 24
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _suspect(golden: dict, sus_id: str) -> dict:
|
| 17 |
+
for s in golden["suspects"]:
|
| 18 |
+
if s["id"] == sus_id:
|
| 19 |
+
return s
|
| 20 |
+
raise KeyError(sus_id)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def scripted_turn(
|
| 24 |
+
golden: dict,
|
| 25 |
+
sus_id: str,
|
| 26 |
+
*,
|
| 27 |
+
question_id: str | None = None,
|
| 28 |
+
present_evidence_id: str | None = None,
|
| 29 |
+
free_text: str | None = None,
|
| 30 |
+
) -> tuple[str, int]:
|
| 31 |
+
"""Return (spoken_reply, suspicion_delta) for one scripted turn."""
|
| 32 |
+
suspect = _suspect(golden, sus_id)
|
| 33 |
+
default = suspect.get("default", "I've told you what I know, detective.")
|
| 34 |
+
|
| 35 |
+
if present_evidence_id is not None:
|
| 36 |
+
entry = (suspect.get("present") or {}).get(present_evidence_id)
|
| 37 |
+
if entry:
|
| 38 |
+
return entry["a"], int(entry["d"])
|
| 39 |
+
return default, 1
|
| 40 |
+
|
| 41 |
+
if question_id is not None:
|
| 42 |
+
for q in suspect.get("questions", []):
|
| 43 |
+
if q["id"] == question_id:
|
| 44 |
+
return q["a"], int(q["d"])
|
| 45 |
+
return default, 2
|
| 46 |
+
|
| 47 |
+
# free text: a small model would answer live; the scripted engine deflects.
|
| 48 |
+
return default, 2
|
src/case_zero/api/server.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build the single ``gradio.Server`` that powers Case Zero.
|
| 2 |
+
|
| 3 |
+
``gradio.Server`` is a FastAPI subclass, so all standard FastAPI methods work on it
|
| 4 |
+
(`.include_router`, `.get`, `.mount`, ...) plus a `.api()` decorator that adds Gradio's
|
| 5 |
+
queue + SSE streaming + concurrency control. We:
|
| 6 |
+
|
| 7 |
+
* register the game's JSON/SSE routers under ``/api``;
|
| 8 |
+
* serve the built Preact pixel-art SPA (``web/dist``).
|
| 9 |
+
|
| 10 |
+
The whole app runs 100% inside one Gradio process - no separate frontend host.
|
| 11 |
+
|
| 12 |
+
Static serving uses explicit routes (assets under ``/assets``, index at ``/``) rather than
|
| 13 |
+
a catch-all mount at ``/`` on purpose: a ``Mount("/")`` shadows Gradio's own internal
|
| 14 |
+
``/gradio_api/*`` routes (including its launch health check) and breaks startup.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
from fastapi import HTTPException
|
| 22 |
+
from fastapi.responses import FileResponse
|
| 23 |
+
from fastapi.staticfiles import StaticFiles
|
| 24 |
+
from gradio import Server
|
| 25 |
+
|
| 26 |
+
from ..constants import ASSETS_DIR
|
| 27 |
+
from .routes_case import router as case_router
|
| 28 |
+
from .routes_run import router as run_router
|
| 29 |
+
|
| 30 |
+
# src/case_zero/api/server.py -> repo root is three parents up from this file's dir.
|
| 31 |
+
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
| 32 |
+
WEB_DIST = (_REPO_ROOT / "web" / "dist").resolve()
|
| 33 |
+
_INDEX = WEB_DIST / "index.html"
|
| 34 |
+
_AUDIO_DIR = ASSETS_DIR / "ui"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def build_server() -> Server:
|
| 38 |
+
"""Return a configured (not yet launched) ``gradio.Server``."""
|
| 39 |
+
server = Server(title="Case Zero", docs_url=None, redoc_url=None, openapi_url=None)
|
| 40 |
+
|
| 41 |
+
server.include_router(case_router)
|
| 42 |
+
server.include_router(run_router)
|
| 43 |
+
|
| 44 |
+
# Gradio API endpoints (Server mode): the core game actions registered through Gradio's
|
| 45 |
+
# own API engine - queue + concurrency + gradio_client-callable + visible in the API view.
|
| 46 |
+
# This makes the app unambiguously a Gradio application (the custom SPA is the Off-Brand
|
| 47 |
+
# frontend on top). The SPA itself uses the REST routes above; these mirror them.
|
| 48 |
+
from .dto import NewCaseRequest
|
| 49 |
+
from .routes_case import new_case
|
| 50 |
+
from .routes_run import InterrogateBody, interrogate
|
| 51 |
+
|
| 52 |
+
@server.api(name="new_case")
|
| 53 |
+
def gr_new_case(case_id: str = "") -> dict:
|
| 54 |
+
"""Generate a fresh case (or load one by id); returns the public case JSON."""
|
| 55 |
+
return new_case(NewCaseRequest(case_id=case_id or None)).model_dump(by_alias=True)
|
| 56 |
+
|
| 57 |
+
@server.api(name="interrogate")
|
| 58 |
+
def gr_interrogate(run_id: str, suspect_id: str, question: str) -> dict:
|
| 59 |
+
"""Ask a suspect a free-text question; returns their reply + server suspicion."""
|
| 60 |
+
return interrogate(run_id, suspect_id, InterrogateBody(free_text=question)).model_dump(
|
| 61 |
+
by_alias=True
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
@server.get("/healthz")
|
| 65 |
+
def healthz() -> dict[str, bool]:
|
| 66 |
+
return {"ok": True}
|
| 67 |
+
|
| 68 |
+
# UI audio (sfx + ambient music) - a specific prefix, served locally (Off-the-Grid).
|
| 69 |
+
if _AUDIO_DIR.is_dir():
|
| 70 |
+
server.mount("/audio", StaticFiles(directory=str(_AUDIO_DIR)), name="audio")
|
| 71 |
+
|
| 72 |
+
if WEB_DIST.is_dir():
|
| 73 |
+
# Hashed JS/CSS live under /assets - a specific prefix that never collides with /api.
|
| 74 |
+
server.mount(
|
| 75 |
+
"/assets",
|
| 76 |
+
StaticFiles(directory=str(WEB_DIST / "assets")),
|
| 77 |
+
name="assets",
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
@server.get("/")
|
| 81 |
+
def _index() -> FileResponse:
|
| 82 |
+
return FileResponse(_INDEX)
|
| 83 |
+
|
| 84 |
+
@server.get("/{filename}")
|
| 85 |
+
def _root_or_spa(filename: str) -> FileResponse:
|
| 86 |
+
# Serve real root files (favicon.svg, icons.svg); otherwise fall back to the SPA
|
| 87 |
+
# index so single-segment client routes resolve. Single-segment matching means
|
| 88 |
+
# this never shadows /gradio_api/* or /api/* (those have deeper paths).
|
| 89 |
+
candidate = (WEB_DIST / filename).resolve()
|
| 90 |
+
if candidate.is_file() and candidate.is_relative_to(WEB_DIST):
|
| 91 |
+
return FileResponse(candidate)
|
| 92 |
+
if _INDEX.is_file():
|
| 93 |
+
return FileResponse(_INDEX)
|
| 94 |
+
raise HTTPException(status_code=404)
|
| 95 |
+
|
| 96 |
+
return server
|
src/case_zero/api/tts_service.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""On-device Supertonic voice synthesis for suspect replies.
|
| 2 |
+
|
| 3 |
+
Lazily loads the provider (or degrades to silent), synthesizes a WAV per reply, and
|
| 4 |
+
caches it on disk keyed by (voice, text) so re-asks are instant. Single-flight: synthesis
|
| 5 |
+
runs under a lock so it never oversubscribes the CPU it shares with the LLM.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import hashlib
|
| 11 |
+
import threading
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
from ..config import get_settings
|
| 15 |
+
from ..constants import PROJECT_ROOT
|
| 16 |
+
from ..schemas.suspect import VoiceAssignment
|
| 17 |
+
from ..tts.provider import make_tts_provider
|
| 18 |
+
|
| 19 |
+
_CACHE_DIR = PROJECT_ROOT / ".cache" / "tts"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TtsService:
|
| 23 |
+
def __init__(self) -> None:
|
| 24 |
+
self._provider = None
|
| 25 |
+
self._failed = False
|
| 26 |
+
self._lock = threading.Lock()
|
| 27 |
+
|
| 28 |
+
def _get(self):
|
| 29 |
+
if self._provider is None and not self._failed:
|
| 30 |
+
try:
|
| 31 |
+
self._provider = make_tts_provider(get_settings())
|
| 32 |
+
except Exception:
|
| 33 |
+
self._failed = True
|
| 34 |
+
return self._provider
|
| 35 |
+
|
| 36 |
+
def available(self) -> bool:
|
| 37 |
+
p = self._get()
|
| 38 |
+
return bool(p and getattr(p, "available", False))
|
| 39 |
+
|
| 40 |
+
def synth(self, text: str, voice: VoiceAssignment | None) -> Path | None:
|
| 41 |
+
p = self._get()
|
| 42 |
+
if not p or not getattr(p, "available", False) or not text.strip():
|
| 43 |
+
return None
|
| 44 |
+
sid = voice.speaker_id if voice else 0
|
| 45 |
+
scale = voice.length_scale if voice else 1.0
|
| 46 |
+
key = hashlib.sha256(f"{sid}|{scale}|{text}".encode()).hexdigest()[:16]
|
| 47 |
+
out = _CACHE_DIR / f"{key}.wav"
|
| 48 |
+
if out.exists():
|
| 49 |
+
return out
|
| 50 |
+
with self._lock:
|
| 51 |
+
if out.exists():
|
| 52 |
+
return out
|
| 53 |
+
return p.synth_to_file(text, voice, out)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
TTS = TtsService()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def voice_seed(sus_id: str, *, female: bool | None = None) -> VoiceAssignment:
|
| 60 |
+
"""A stable VoiceAssignment from a suspect id when no CaseFile suspect is available
|
| 61 |
+
(e.g. the golden case). Gender-matched if known, else any of the 10 voices."""
|
| 62 |
+
seed = int.from_bytes(hashlib.sha256(sus_id.encode()).digest()[:4], "big")
|
| 63 |
+
if female is True:
|
| 64 |
+
speaker = 5 + (seed % 5)
|
| 65 |
+
elif female is False:
|
| 66 |
+
speaker = seed % 5
|
| 67 |
+
else:
|
| 68 |
+
speaker = seed % 10
|
| 69 |
+
return VoiceAssignment(
|
| 70 |
+
engine="supertonic",
|
| 71 |
+
speaker_id=speaker,
|
| 72 |
+
length_scale=round(0.95 + (seed % 20) / 100.0, 3),
|
| 73 |
+
)
|
src/case_zero/audio/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio: SFX/music manifest and the procedural synth fallback."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from .manifest import MUSIC_DIR, MUSIC_TRACKS, SFX_DIR, SFX_EVENTS
|
| 6 |
+
from .synth import generate_music, generate_placeholder_pack, generate_sfx
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"MUSIC_DIR",
|
| 10 |
+
"MUSIC_TRACKS",
|
| 11 |
+
"SFX_DIR",
|
| 12 |
+
"SFX_EVENTS",
|
| 13 |
+
"generate_music",
|
| 14 |
+
"generate_placeholder_pack",
|
| 15 |
+
"generate_sfx",
|
| 16 |
+
]
|
src/case_zero/audio/manifest.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio asset manifest: UI sound events and music tracks.
|
| 2 |
+
|
| 3 |
+
Assets are committed under assets/ui and played by the browser at runtime (no model
|
| 4 |
+
loads during play). They are authored once - by an open audio model at pre-bake time,
|
| 5 |
+
or by the procedural synth fallback in ``synth.py``.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from ..constants import ASSETS_DIR
|
| 11 |
+
|
| 12 |
+
SFX_DIR = ASSETS_DIR / "ui" / "sfx"
|
| 13 |
+
MUSIC_DIR = ASSETS_DIR / "ui" / "music"
|
| 14 |
+
|
| 15 |
+
# event name -> filename. Event names are referenced by the UI's JS audio shim.
|
| 16 |
+
SFX_EVENTS: dict[str, str] = {
|
| 17 |
+
"click": "click.wav",
|
| 18 |
+
"select": "select.wav",
|
| 19 |
+
"present": "present.wav",
|
| 20 |
+
"accuse": "accuse.wav",
|
| 21 |
+
"success": "success.wav",
|
| 22 |
+
"fail": "fail.wav",
|
| 23 |
+
"page": "page.wav",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
MUSIC_TRACKS: tuple[str, ...] = ("ambient_theme.wav",)
|
src/case_zero/audio/synth.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Procedural audio synth (numpy + stdlib wave).
|
| 2 |
+
|
| 3 |
+
Generates calm, professional UI SFX and a sparse ambient music loop in the spirit of
|
| 4 |
+
Minecraft's quiet themes. Used as the offline placeholder until an open audio model
|
| 5 |
+
(Stable Audio Open / MusicGen) bakes higher-fidelity assets via scripts/prebake_audio.py.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import wave
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
from .manifest import MUSIC_DIR, SFX_DIR
|
| 16 |
+
|
| 17 |
+
SAMPLE_RATE = 22050
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _write_wav(path: Path, samples: np.ndarray) -> None:
|
| 21 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 22 |
+
clipped = np.clip(samples, -1.0, 1.0)
|
| 23 |
+
pcm = (clipped * 32767.0).astype("<i2")
|
| 24 |
+
with wave.open(str(path), "wb") as wav:
|
| 25 |
+
wav.setnchannels(1)
|
| 26 |
+
wav.setsampwidth(2)
|
| 27 |
+
wav.setframerate(SAMPLE_RATE)
|
| 28 |
+
wav.writeframes(pcm.tobytes())
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _t(duration: float) -> np.ndarray:
|
| 32 |
+
return np.linspace(0.0, duration, int(SAMPLE_RATE * duration), endpoint=False)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _adsr(n: int, attack: float = 0.01, release: float = 0.2) -> np.ndarray:
|
| 36 |
+
env = np.ones(n)
|
| 37 |
+
a = min(max(1, int(SAMPLE_RATE * attack)), n)
|
| 38 |
+
r = min(max(1, int(SAMPLE_RATE * release)), max(0, n - a))
|
| 39 |
+
if a:
|
| 40 |
+
env[:a] = np.linspace(0.0, 1.0, a)
|
| 41 |
+
if r:
|
| 42 |
+
env[-r:] = np.linspace(1.0, 0.0, r)
|
| 43 |
+
return env
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _tone(freq: float, duration: float, *, kind: str = "sine", gain: float = 0.4,
|
| 47 |
+
attack: float = 0.01, release: float = 0.2) -> np.ndarray:
|
| 48 |
+
t = _t(duration)
|
| 49 |
+
phase = 2 * np.pi * freq * t
|
| 50 |
+
if kind == "square":
|
| 51 |
+
wave_data = np.sign(np.sin(phase))
|
| 52 |
+
elif kind == "triangle":
|
| 53 |
+
wave_data = 2 / np.pi * np.arcsin(np.sin(phase))
|
| 54 |
+
else:
|
| 55 |
+
wave_data = np.sin(phase)
|
| 56 |
+
return wave_data * _adsr(len(t), attack, release) * gain
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _sweep(f0: float, f1: float, duration: float, gain: float = 0.35) -> np.ndarray:
|
| 60 |
+
t = _t(duration)
|
| 61 |
+
freqs = np.linspace(f0, f1, len(t))
|
| 62 |
+
phase = 2 * np.pi * np.cumsum(freqs) / SAMPLE_RATE
|
| 63 |
+
return np.sin(phase) * _adsr(len(t), 0.005, 0.15) * gain
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _noise(duration: float, gain: float = 0.25) -> np.ndarray:
|
| 67 |
+
n = int(SAMPLE_RATE * duration)
|
| 68 |
+
rng = np.random.default_rng(7)
|
| 69 |
+
return rng.uniform(-1, 1, n) * _adsr(n, 0.002, 0.08) * gain
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _sfx(name: str) -> np.ndarray:
|
| 73 |
+
if name == "click":
|
| 74 |
+
return _tone(660, 0.06, kind="triangle", release=0.05)
|
| 75 |
+
if name == "select":
|
| 76 |
+
return np.concatenate([_tone(523, 0.06, release=0.04), _tone(784, 0.08, release=0.06)])
|
| 77 |
+
if name == "present":
|
| 78 |
+
return _sweep(400, 900, 0.22)
|
| 79 |
+
if name == "accuse":
|
| 80 |
+
return _tone(110, 0.5, kind="triangle", gain=0.5, release=0.4)
|
| 81 |
+
if name == "success":
|
| 82 |
+
return np.concatenate([_tone(f, 0.12, release=0.1) for f in (523, 659, 784, 1046)])
|
| 83 |
+
if name == "fail":
|
| 84 |
+
return np.concatenate([_tone(f, 0.16, kind="triangle", release=0.12) for f in (392, 311, 233)])
|
| 85 |
+
if name == "page":
|
| 86 |
+
return _noise(0.18)
|
| 87 |
+
return _tone(440, 0.1)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def generate_sfx(out_dir: Path | None = None) -> list[Path]:
|
| 91 |
+
out = out_dir or SFX_DIR
|
| 92 |
+
from .manifest import SFX_EVENTS
|
| 93 |
+
|
| 94 |
+
paths: list[Path] = []
|
| 95 |
+
for event, filename in SFX_EVENTS.items():
|
| 96 |
+
path = out / filename
|
| 97 |
+
_write_wav(path, _sfx(event))
|
| 98 |
+
paths.append(path)
|
| 99 |
+
return paths
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# A slow lofi-noir jazz loop: i - iv - V7 - i in A minor, soft Rhodes-like pads,
|
| 103 |
+
# a gentle bass, brushed percussion, and faint vinyl crackle.
|
| 104 |
+
_CHORDS: tuple[tuple[float, tuple[float, ...]], ...] = (
|
| 105 |
+
(110.00, (261.63, 329.63, 392.00)), # Am7 (A bass; C E G)
|
| 106 |
+
(146.83, (349.23, 440.00, 523.25)), # Dm7 (D bass; F A C)
|
| 107 |
+
(164.81, (415.30, 493.88, 587.33)), # E7 (E bass; G# B D)
|
| 108 |
+
(110.00, (261.63, 329.63, 392.00)), # Am7
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _pad(freqs: tuple[float, ...], duration: float) -> np.ndarray:
|
| 113 |
+
t = _t(duration)
|
| 114 |
+
vibrato = 1.0 + 0.004 * np.sin(2 * np.pi * 5.0 * t)
|
| 115 |
+
tone = np.zeros(len(t))
|
| 116 |
+
for f in freqs:
|
| 117 |
+
tone += np.sin(2 * np.pi * f * t * vibrato) + 0.4 * np.sin(2 * np.pi * 2 * f * t)
|
| 118 |
+
env = np.ones(len(t))
|
| 119 |
+
a, r = int(SAMPLE_RATE * 0.25), int(SAMPLE_RATE * 0.6)
|
| 120 |
+
env[:a] = np.linspace(0, 1, a)
|
| 121 |
+
env[-r:] = np.linspace(1, 0.0, r)
|
| 122 |
+
return tone / len(freqs) * env * 0.16
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def generate_music(out_dir: Path | None = None, chord_seconds: float = 4.0) -> Path:
|
| 126 |
+
out = out_dir or MUSIC_DIR
|
| 127 |
+
duration = chord_seconds * len(_CHORDS)
|
| 128 |
+
n = int(SAMPLE_RATE * duration)
|
| 129 |
+
mix = np.zeros(n)
|
| 130 |
+
rng = np.random.default_rng(1989)
|
| 131 |
+
|
| 132 |
+
for i, (bass, voicing) in enumerate(_CHORDS):
|
| 133 |
+
start = int(i * chord_seconds * SAMPLE_RATE)
|
| 134 |
+
chord = _pad(voicing, chord_seconds)
|
| 135 |
+
bass_line = _tone(bass, chord_seconds, kind="sine", gain=0.18, attack=0.05,
|
| 136 |
+
release=chord_seconds * 0.4)
|
| 137 |
+
seg = chord[: len(bass_line)] + bass_line[: len(chord)]
|
| 138 |
+
end = min(n, start + len(seg))
|
| 139 |
+
mix[start:end] += seg[: end - start]
|
| 140 |
+
|
| 141 |
+
# Brushed percussion on a slow ~80 BPM pulse.
|
| 142 |
+
beat = 60.0 / 80.0
|
| 143 |
+
pos = 0.0
|
| 144 |
+
while pos < duration - 0.1:
|
| 145 |
+
tick = _noise(0.05, gain=0.04)
|
| 146 |
+
s = int(pos * SAMPLE_RATE)
|
| 147 |
+
e = min(n, s + len(tick))
|
| 148 |
+
mix[s:e] += tick[: e - s]
|
| 149 |
+
pos += beat
|
| 150 |
+
|
| 151 |
+
# Faint vinyl crackle.
|
| 152 |
+
crackle = rng.uniform(-1, 1, n)
|
| 153 |
+
crackle[np.abs(crackle) < 0.985] = 0.0
|
| 154 |
+
mix += crackle * 0.05
|
| 155 |
+
|
| 156 |
+
# Seamless loop fade.
|
| 157 |
+
fade = int(SAMPLE_RATE * 0.8)
|
| 158 |
+
mix[:fade] *= np.linspace(0, 1, fade)
|
| 159 |
+
mix[-fade:] *= np.linspace(1, 0, fade)
|
| 160 |
+
mix *= 0.85 / (np.max(np.abs(mix)) or 1.0)
|
| 161 |
+
|
| 162 |
+
path = out / "ambient_theme.wav"
|
| 163 |
+
_write_wav(path, mix)
|
| 164 |
+
return path
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def generate_placeholder_pack() -> list[Path]:
|
| 168 |
+
return [*generate_sfx(), generate_music()]
|