akshay4 commited on
Commit
ced0ccd
·
verified ·
1 Parent(s): 7bae238

Upload folder using huggingface_hub

Browse files
.gitignore CHANGED
@@ -1,5 +1,6 @@
1
  # Local index is generated; large transcripts live on HF dataset repo
2
  data/*
 
3
  !data/transcripts/
4
  !data/transcripts/**
5
  data/transcripts/office_generated.txt
@@ -45,6 +46,7 @@ coverage.xml
45
  # IDE and editors
46
  .idea/
47
  .vscode/
 
48
  *.swp
49
  *.swo
50
  *~
 
1
  # Local index is generated; large transcripts live on HF dataset repo
2
  data/*
3
+ !data/public_traces.jsonl
4
  !data/transcripts/
5
  !data/transcripts/**
6
  data/transcripts/office_generated.txt
 
46
  # IDE and editors
47
  .idea/
48
  .vscode/
49
+ docs/private-demo-pitch.md
50
  *.swp
51
  *.swo
52
  *~
README.md CHANGED
@@ -11,288 +11,63 @@ pinned: false
11
  license: apache-2.0
12
  short_description: Paste awkward. Get one sharp wit line + coach drills.
13
  tags:
 
 
 
14
  - comedy
15
  - rag
16
  - case-based-reasoning
17
  - qwen
18
- - thousand-token-wood
19
  - achievement:offbrand
20
- - achievement:best-agent
21
- - achievement:best-demo
22
- - achievement:bonus-quest-champion
23
  - sponsor:openai
24
  ---
25
 
26
  # 🎭 WitGym
27
 
28
- > *A comedy coaching engine grounded in human precedent not vibes.*
29
 
30
- Paste an awkward real-life situation (or tap a starter). WitGym dissects the **structural comedy of the moment**, retrieves analogous scenes from The Office, drafts 2–3 persona candidates (cynic, conviction, absurdist twist-gated), ranks them, and returns one compressed sharp line. After a reply, use coach drills to sharpen it, try a different angle, or explain why it lands.
31
 
32
- **[→ Try it live on Hugging Face Spaces](https://huggingface.co/spaces/build-small-hackathon/WitGym)**
33
-
34
- ---
35
 
36
- ## How to use
37
-
38
- 1. **Start** — click *Start Training* on the landing screen.
39
- 2. **Describe the moment** — paste your own awkward situation, or tap a starter chip in the sidebar (*Status*, *Social*, *Delusion*, etc.).
40
- 3. **Get a line** — WitGym returns one sharp wit line grounded in Office precedent (not an in-character Michael/Dwight impersonation).
41
- 4. **Coach drills** — after a reply, use *sharpen it*, *different angle*, or *explain the joke* to iterate on the same situation.
42
- 5. **Character panel** — the Office cast cards on the landing page are reference bios only; you do **not** pick a character to role-play.
43
-
44
- ---
45
-
46
- ## Why comedy is hard for AI
47
-
48
- Every comedy coaching app just asks a model to "be funny." That's like asking someone to "be good at chess" without ever showing them a game.
49
-
50
- Wit has **structure**. It emerges from the gap between what's socially expected and what gets said. The Office didn't write jokes — it wrote *situations*, then let characters navigate them in character-consistent ways. That's the thing to learn from.
51
-
52
- WitGym treats comedy the way researchers do: as a system of **social violations, status games, and tension types** — then grounds responses in scenes that worked for the same reason.
53
-
54
- ---
55
 
56
- ## Architecture
 
 
 
57
 
58
- ```mermaid
59
- flowchart TD
60
- A[User: paste awkward situation] --> B{Small talk?}
61
- B -- yes --> C[Identity reply no pipeline]
62
- B -- no --> D
63
-
64
- D[Pass 1 — Neurology of Comedy\nQwen3.5-27B extracts 10 structural fields]
65
- D --> E[Archetype + Tension + Distance\nComedyMetadata schema]
66
- E --> F[BGE-small embedder\n384-dim semantic vector]
67
- F --> G[Cosine retrieval + rerank\n4021 Office scene index]
68
- G --> H[Top-2 precedent scenes\nwith why_it_works annotations]
69
- H --> I[Pass 2 — Persona Generation\n2–3 candidates: Cynic · Conviction · Absurdist]
70
- I --> J[Pass 3 — Tournament Ranking\nranked by structural fit to the metadata]
71
- J --> K[Pass 4 — Compression\nreduce to one sharp line]
72
- K --> L[Coaching\noptional: what made this work?]
73
- L --> M[Response + full debug trace]
74
-
75
- style D fill:#1a1a2e,color:#ffd700,stroke:#ffd700
76
- style G fill:#16213e,color:#4fc3f7,stroke:#4fc3f7
77
- style I fill:#0f3460,color:#f8f8f8,stroke:#e94560
78
- style M fill:#1a1a2e,color:#b0f4b0,stroke:#4caf50
79
- ```
80
-
81
- ---
82
-
83
- ## The Comedy Science
84
-
85
- WitGym models comedy as three interacting structural properties:
86
-
87
- | Property | Enum | What it captures |
88
- |---|---|---|
89
- | **Archetype** | `ComedyArchetype` | *Why* this moment is funny in principle — e.g. `STATUS_ASSERTION`, `SELF_DELUSION_EXPOSED`, `POWER_INVERSION` |
90
- | **Tension type** | `TensionType` | *What* is at stake — e.g. `SOCIAL_EMBARRASSMENT`, `STATUS_THREAT`, `IDENTITY_EXPOSURE` |
91
- | **Violation distance** | `ViolationDistance` | *How far* to push — `mild`, `moderate`, `sharp` |
92
-
93
- These aren't vibes. They're used to:
94
- 1. Select the right **precedent scenes** from the index (cosine similarity on archetype + semantic embedding)
95
- 2. Constrain **persona generation** (each persona must violate in a structurally consistent way)
96
- 3. **Rank** candidates (the winner resolves the tension most cleanly)
97
-
98
- The pipeline produces a `ComedyMetadata` object with 10 fields — including `connector` (the double-meaning word that makes a line land), `subtext` (what's actually being communicated), and `twist_potential` (comedy richness score 1–10 used to gate the full pipeline vs. quick response).
99
-
100
- ---
101
 
102
- ## The Retrieval System
 
 
 
 
 
103
 
104
- 4,021 indexed scenes from The Office, each annotated with:
105
- - `archetype`, `tension_type`, `violation_distance` structural labels
106
- - `why_it_works` — a one-sentence explanation of the comedy mechanism
107
- - `setup` + `response` the actual scene
108
-
109
- At query time, the user's situation is embedded with **BGE-small** (33M params) and retrieved against the index via cosine similarity. The retrieval finds scenes with the **same comedy structure** — not the same topic.
110
-
111
- ```mermaid
112
- flowchart LR
113
- A[User situation] --> B[BGE-small encoder\n33M params]
114
- B --> C[384-dim vector]
115
- C --> D[NumPy cosine index\n4021 scenes × 384 dims]
116
- D --> E[Top-2 scenes\nthen cross-encoder rerank]
117
- E --> F[Injected as few-shot\nexamples into Pass 2 prompt]
118
- style B fill:#1a3a5c,color:#4fc3f7
119
- style D fill:#1a3a5c,color:#4fc3f7
120
- ```
121
-
122
- ---
123
-
124
- ## Pipeline flow
125
-
126
- ```mermaid
127
- sequenceDiagram
128
- participant U as User
129
- participant A as app.py
130
- participant E as WitGymEngine
131
- participant L as LLM (Qwen3.5-27B)
132
- participant I as BGE Index
133
-
134
- U->>A: Paste situation
135
- A->>E: respond(user_input)
136
-
137
- Note over E,L: Pass 1 — Metadata extraction
138
- E->>L: Extract ComedyMetadata (10 fields)
139
- L-->>E: archetype + tension + distance + subtext...
140
-
141
- Note over E,I: RAG retrieval
142
- E->>I: embed(surface) → cosine search
143
- I-->>E: 2 precedent scenes
144
-
145
- Note over E,L: Pass 2 — Candidate generation
146
- E->>L: Generate 2–3 personas (cynic, conviction, absurdist — twist-gated)
147
- L-->>E: 3 candidate responses (streaming)
148
-
149
- Note over E,L: Pass 3 — Tournament ranking
150
- E->>L: Rank candidates by structural fit
151
- L-->>E: winner + explanation
152
-
153
- Note over E,L: Pass 4 — Compression
154
- E->>L: Compress winner to one sharp line
155
- L-->>E: final response
156
-
157
- E-->>A: PipelineEvent stream
158
- A-->>U: Streaming response + debug trace
159
- ```
160
-
161
- ---
162
-
163
- ## UI: Progressive Disclosure
164
-
165
- The practice screen is situation-first: you paste or pick a starter, then receive a streaming coach reply. The landing **coaching panel** shows Office character bios (tap for a popup) — reference flavor, not a character picker.
166
-
167
- The trace uses **progressive disclosure** — the reply stays front-and-center, while the expandable rail reveals the underlying structured pipeline as JSON:
168
-
169
- - **Trace** — expandable JSON payload with metadata, retrieved scenes, candidates, and selected output
170
- - **Chips / capsules** — used only where the product needs human-facing explanation, not as a substitute for the execution trace
171
-
172
- New elements animate in with a shimmer sweep + border glow system that stops on first interaction. This mirrors how Notion and Apple iOS handle progressive discovery — purposeful discoverability signaling, not decoration.
173
-
174
- ---
175
 
176
- ## Tech Stack
177
 
178
- | Layer | Choice | Why |
179
- |---|---|---|
180
- | **LLM** | Qwen3.5-27B via HF Inference Providers | ≤32B constraint; best instruction-following at this size |
181
- | **Embedder** | BGE-small (33M params) | Fast, accurate, runs on CPU in < 50ms |
182
- | **Index** | NumPy cosine retrieval + optional 32M cross-encoder rerank, 4021 scenes × 384 dims | No server needed; loaded at startup from Hub dataset |
183
- | **UI** | Gradio 6.x on HF Spaces | Streaming SSE, custom CSS theming |
184
- | **Validation** | Pydantic v2 | Schema-enforced extraction; fallback on parse failure |
185
- | **Retry** | Exponential backoff on all LLM calls | Handles upstream provider flakiness gracefully |
186
-
187
- ---
188
-
189
- ## Build Small compliance
190
-
191
- - **Model size**: Qwen3.5-27B ≤ 32B ✓
192
- - **Embedder**: BGE-small 33M — runs on CPU, no GPU needed for retrieval ✓
193
- - **Deployed on HF Spaces**: Gradio app, streams via SSE ✓
194
- - **Open source**: Apache 2.0 licensed ✓
195
- - **Use case**: whimsical entertainment / comedy coaching grounded in structured precedent ✓
196
- - **Interaction design**: custom Gradio interface with progressive disclosure and streaming feedback ✓
197
- - **Agentic flow**: route -> extract -> retrieve -> generate -> rank -> compress ✓
198
- - No fine-tuning required — all comedy structure is in the retrieval index and prompts
199
-
200
- ---
201
-
202
- ## How to cite WitGym
203
-
204
- If you use or build on this project, please cite:
205
-
206
- ```bibtex
207
- @software{babbar2026witgym,
208
- author = {Babbar, Akshay},
209
- title = {WitGym: CBR-RAG Comedy Coaching Engine},
210
- year = {2026},
211
- url = {https://github.com/akshay-babbar/witgym},
212
- note = {Hugging Face Build Small Hackathon 2026 submission}
213
- }
214
- ```
215
-
216
- See also [`CITATION.cff`](CITATION.cff) for machine-readable metadata.
217
-
218
- ---
219
-
220
- ## What makes this different
221
-
222
- | Approach | Problem |
223
- |---|---|
224
- | "Respond wittily to: [situation]" | No structural understanding; generic, contextless outputs |
225
- | RAG on jokes | Jokes don't transfer — the *situation structure* transfers |
226
- | **WitGym** | Extracts comedy structure → retrieves same-structure precedents → generates constrained by that structure |
227
-
228
- The insight: The Office didn't write great jokes. It wrote great **situations**, then populated them with characters who respond in structurally consistent ways. WitGym learns from the situations, not the punchlines.
229
-
230
- ---
231
-
232
- ## Run locally
233
 
234
  ```bash
235
- # Install
236
- uv sync # or: pip install -e .
237
-
238
- # Build index (The Office transcripts)
239
  witgym-index
240
-
241
- # Run with HF Inference API (recommended — no local weights needed)
242
- export HF_TOKEN=hf_...
243
  export LLM_BACKEND=hf_api
244
- python app.py
245
-
246
- # Run with local weights (MPS/CUDA)
247
- python app.py
248
- ```
249
-
250
- ---
251
-
252
- ## HF Spaces configuration
253
-
254
- Set these in Space settings — **not** in GitHub. CI only syncs code; runtime auth is separate.
255
-
256
- | Secret | Value |
257
- |---|---|
258
- | `HF_TOKEN` | Write token with read access to the data dataset |
259
- | `LLM_BACKEND` | `hf_api` |
260
- | `WITGYM_DATA_REPO` | `build-small-hackathon/witgym-data` (default) |
261
-
262
- Optional: `HF_INFERENCE_PROVIDER` (defaults to `together`), `WITGYM_INDEX_PATH`.
263
-
264
- Do **not** set `WITGYM_SKIP_HUB` on the Space.
265
-
266
- ---
267
-
268
- ## Large data on HF Hub
269
-
270
- Files over 1 MB (`office_generated.txt`, `index.npz`) live in a private org dataset, not in git. At startup the app fetches `index.npz` from the Hub; if auth fails it falls back to rebuilding from bundled transcripts.
271
-
272
- ```bash
273
- hf upload build-small-hackathon/witgym-data \
274
- data/index.npz index.npz --repo-type dataset
275
-
276
- hf upload build-small-hackathon/witgym-data \
277
- data/transcripts/office_generated.txt office_generated.txt \
278
- --repo-type dataset --private
279
  ```
280
 
281
- When transcripts change: re-run `witgym-index`, then re-upload `index.npz`. Offline dev: set `WITGYM_SKIP_HUB=1`.
282
-
283
- ---
284
-
285
- ## CI/CD
286
-
287
- Pushes to `main` sync code to the Space via [`.github/workflows/sync-to-hub.yml`](.github/workflows/sync-to-hub.yml).
288
-
289
- | Where | Secret | Purpose |
290
- |---|---|---|
291
- | GitHub repo secrets | `HF_TOKEN` | CI push to Space only |
292
- | Space secrets | `HF_TOKEN`, `WITGYM_DATA_REPO`, `LLM_BACKEND` | Runtime Hub API + dataset + inference |
293
-
294
- These are **two different** `HF_TOKEN` placements. Configuring GitHub does not configure the Space runtime.
295
-
296
- ---
297
-
298
- Built for the [Hugging Face Build Small Hackathon 2026](https://huggingface.co/build-small-hackathon).
 
11
  license: apache-2.0
12
  short_description: Paste awkward. Get one sharp wit line + coach drills.
13
  tags:
14
+ - build-small-hackathon
15
+ - track:wood
16
+ - thousand-token-wood
17
  - comedy
18
  - rag
19
  - case-based-reasoning
20
  - qwen
 
21
  - achievement:offbrand
22
+ - achievement:sharing
23
+ - achievement:fieldnotes
 
24
  - sponsor:openai
25
  ---
26
 
27
  # 🎭 WitGym
28
 
29
+ **One sharp line, grounded in human precedent. Then drills to sharpen it.**
30
 
31
+ WitGym is a comedy coaching engine for awkward real-life moments. It extracts the **comedy structure**, retrieves analogous precedent from *The Office*, drafts constrained persona candidates, runs a **tournament ranker**, and returns one crisp line with optional coaching.
32
 
33
+ **Live Space**: [build-small-hackathon/WitGym](https://huggingface.co/spaces/build-small-hackathon/WitGym)
 
 
34
 
35
+ ### Why I built this
36
+ Comedy has always been a personal interest — not just watching it, but understanding how a line lands. I wanted a **humor coach** for real awkward moments: paste what happened, get one sharp line, then drill on it. *The Office* felt like the right precedent library (I'm a longtime fan) — not to impersonate characters, but to learn from situations that already work. WitGym is my attempt to make that coach real under the ≤32B constraint.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
+ ### 30‑second demo
39
+ - Paste: “My boss says he trusts me, but he rewrites every message I send.”
40
+ - Watch the phases: extract → retrieve → draft → rank → polish (streaming)
41
+ - Tap drills: **sharpen it**, **different angle**, **explain the joke**
42
 
43
+ ### What makes it different
44
+ - **CBR‑RAG on comedy mechanics**: retrieve by archetype, tension, and violation distance — not topic keywords
45
+ - **Behavioral observation as the generative seed**: name the human move, then write from that
46
+ - **Tournament ranking for landing**: truth precision, final-clause quality, domain anchoring
47
+ - **Inspectable traces**: progressive disclosure in the UI; public JSONL export for Sharing is Caring
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
+ ### How it works
50
+ 1. **Extract** — `Qwen/Qwen3.5-27B` → `ComedyMetadata` (12 fields)
51
+ 2. **Retrieve** — `BAAI/bge-small-en-v1.5` cosine pool + optional `cross-encoder/ettin-reranker-32m-v1`
52
+ 3. **Generate** — 2–3 twist-gated persona candidates
53
+ 4. **Rank** — fixed rubric selects the winner
54
+ 5. **Compress** — optional polish to one sharp line
55
 
56
+ ### Evidence / badges
57
+ - **Sharing is Caring** (`achievement:sharing`): [public pipeline traces](data/public_traces.jsonl) — sanitized JSONL (metadata, scene IDs, candidate stats, execution log; no Office dialogue text). Regenerate: `uv run python scripts/export_public_traces.py`
58
+ - **Field Notes** (`achievement:fieldnotes`): [docs/field-notes.md](docs/field-notes.md)
59
+ - **Off‑Brand UI** (`achievement:offbrand`): custom Gradio UI + streaming trace disclosure
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
+ > **Validator**: add demo video + social post links here before submission. [Validate README](https://build-small-hackathon-field-guide.hf.space/submit)
62
 
63
+ ### Run locally
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  ```bash
66
+ uv sync
 
 
 
67
  witgym-index
 
 
 
68
  export LLM_BACKEND=hf_api
69
+ export HF_TOKEN=hf_...
70
+ uv run python app.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  ```
72
 
73
+ Built for the [Build Small Hackathon 2026](https://huggingface.co/build-small-hackathon) Thousand Token Wood.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
data/public_traces.jsonl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {"kind": "single_turn", "input": "I just got promoted to manager and I have no idea what I'm doing.", "route": "quick_wit", "model_id": "Qwen/Qwen3.5-27B", "llm_backend": "hf_api", "latency_s": 24.0, "metadata": {"surface": "I just got promoted to manager and I have no idea what I'm doing.", "subtext": "The speaker is overwhelmed by impostor syndrome and fears their new authority will expose their incompetence.", "behavioral_observation": null, "archetype": "status_assertion", "archetype_confidence": 9, "tension_type": "identity_expose", "power_dynamic": "The speaker holds formal power as a manager but lacks the actual competence to wield it effectively.", "speaker_strategy": "vulnerable honesty", "obvious_response": "Don't worry, everyone feels that way at first.", "violation_distance": "mild", "twist_potential": 7, "connector": "manager"}, "retrieved_scenes": [{"scene_id": "08ca192f647e", "show": "The Office", "character": "Michael Scott", "archetype": "status_assertion", "tension_type": "identity_expose", "violation_distance": "sharp"}, {"scene_id": "17182d6cf3eb", "show": "The Office", "character": "Robert", "archetype": "status_assertion", "tension_type": "status_threat", "violation_distance": "sharp"}], "candidates": [{"persona": "cynic", "word_count": 30}, {"persona": "conviction", "word_count": 18}, {"persona": "absurdist", "word_count": 23}], "winning_persona": "cynic", "selected": "Congratulations on the promotion; you now have authority to tell everyone what you're doing while they pretend not to notice you're making it up as you go.", "logs": [{"step": "metadata", "status": "ok", "detail": "twist=7 archetype=status_assertion"}, {"step": "retrieval", "status": "ok", "detail": "Michael Scott:status_assertion, Robert:status_assertion"}, {"step": "candidate_generation", "status": "ok", "detail": "cynic:30w, conviction:18w, absurdist:23w"}, {"step": "ranking", "status": "ok", "detail": "cynic"}, {"step": "compression", "status": "ok", "detail": "selected line finalized"}]}
2
+ {"kind": "single_turn", "input": "My coworker keeps stealing my lunch from the fridge.", "route": "quick_wit", "model_id": "Qwen/Qwen3.5-27B", "llm_backend": "hf_api", "latency_s": 11.47, "metadata": {"surface": "My coworker keeps stealing my lunch from the fridge.", "subtext": "I feel violated and powerless against a recurring theft that threatens my basic needs and workplace safety.", "behavioral_observation": null, "archetype": "anxiety_escalation", "archetype_confidence": 8, "tension_type": "status_threat", "power_dynamic": "The coworker holds power through repeated successful transgression while the speaker remains a passive victim unable to stop it.", "speaker_strategy": "seeking validation for victimhood", "obvious_response": "That is really annoying, you should probably report it to HR or put your food in a locked container.", "violation_distance": "moderate", "twist_potential": 6, "connector": null}, "retrieved_scenes": [{"scene_id": "0039dc5d8663", "show": "The Office", "character": "Kevin", "archetype": "anxiety_escalation", "tension_type": "status_threat", "violation_distance": "moderate"}, {"scene_id": "29f42f59ca32", "show": "The Office", "character": "Michael", "archetype": "anxiety_escalation", "tension_type": "status_threat", "violation_distance": "moderate"}], "candidates": [{"persona": "cynic", "word_count": 18}, {"persona": "absurdist", "word_count": 25}], "winning_persona": "absurdist", "selected": "My lunchbox is a vending machine where I'm the currency, and he just inserted a quarter made of my dignity.", "logs": [{"step": "metadata", "status": "ok", "detail": "twist=6 archetype=anxiety_escalation"}, {"step": "retrieval", "status": "ok", "detail": "Kevin:anxiety_escalation, Michael:anxiety_escalation"}, {"step": "candidate_generation", "status": "ok", "detail": "cynic:18w, absurdist:25w"}, {"step": "ranking", "status": "ok", "detail": "absurdist"}, {"step": "compression", "status": "ok", "detail": "selected line finalized"}]}
3
+ {"kind": "single_turn", "input": "My boss says he trusts me, but he rewrites every message I send.", "route": "quick_wit", "model_id": "Qwen/Qwen3.5-27B", "llm_backend": "hf_api", "latency_s": 16.01, "metadata": {"surface": "My boss says he trusts me, but he rewrites every message I send.", "subtext": "The speaker feels their competence is undermined and that the boss's claim of trust is a hollow lie contradicted by controlling behavior.", "behavioral_observation": null, "archetype": "power_inversion", "archetype_confidence": 9, "tension_type": "status_threat", "power_dynamic": "The boss holds formal authority but reveals insecurity through micromanagement, while the employee holds the moral high ground of being the actual competent worker.", "speaker_strategy": "exposing hypocrisy", "obvious_response": "That sounds frustrating.", "violation_distance": "moderate", "twist_potential": 8, "connector": null}, "retrieved_scenes": [{"scene_id": "44eea9bd8663", "show": "The Office", "character": "Toby Flenderson", "archetype": "power_inversion", "tension_type": "status_threat", "violation_distance": "sharp"}, {"scene_id": "d7bfe3ce399e", "show": "The Office", "character": "Michael", "archetype": "power_inversion", "tension_type": "status_threat", "violation_distance": "sharp"}], "candidates": [{"persona": "cynic", "word_count": 20}, {"persona": "conviction", "word_count": 19}, {"persona": "absurdist", "word_count": 28}], "winning_persona": "absurdist", "selected": "If he trusts me so much, let's just stop hitting \"send\" entirely and hand him the keyboard while I watch him type my own thoughts back to himself.", "logs": [{"step": "metadata", "status": "ok", "detail": "twist=8 archetype=power_inversion"}, {"step": "retrieval", "status": "ok", "detail": "Toby Flenderson:power_inversion, Michael:power_inversion"}, {"step": "candidate_generation", "status": "ok", "detail": "cynic:20w, conviction:19w, absurdist:28w"}, {"step": "ranking", "status": "ok", "detail": "absurdist"}, {"step": "compression", "status": "ok", "detail": "selected line finalized"}]}
docs/field-notes.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # WitGym — Field Notes (Build Small 2026)
2
+
3
+ ## What I built
4
+ WitGym is a comedy coaching engine for real-life awkward moments. You paste a situation, it produces **one sharp line**, then lets you iterate with drills (sharpen / different angle / explain).
5
+
6
+ The core bet: **comedy transfers by structure, not by topic**. Instead of “RAG on jokes”, WitGym does **CBR-RAG on comedy mechanics** and uses precedent from *The Office* to ground the response.
7
+
8
+ ## The small-model constraint (≤32B) changed the design
9
+ Under the Build Small constraint, the goal wasn’t “generate funnier text by scaling”, it was “get reliable *wit* by adding structure”:
10
+
11
+ - **Pass 1 (extraction)**: extract a compact schema (`ComedyMetadata`) describing the moment: archetype, tension, violation distance, subtext, behavioral observation, etc.\n
12
+ - **Retrieval (CBR-RAG)**: retrieve *structurally similar* precedent scenes from a prebuilt index.\n
13
+ - **Pass 2 (generation)**: draft 2–3 persona candidates with strict constraints.\n
14
+ - **Pass 3 (ranking)**: pick a winner with an explicit judging rubric (truth precision + strong ending + domain anchoring).\n
15
+ - **Pass 4 (compression)**: optionally tighten the winner to one crisp line.\n
16
+
17
+ ## What was unexpectedly important
18
+ - **Behavioral observation > feelings**: naming the *move* (“renamed procrastination as ‘keeping options open’”) is a better generative seed than therapy-language subtext.\n
19
+ - **Ranking beats clever prompting**: the biggest quality jumps came from forcing a tournament-style selection rubric, especially “truth precision” and “final clause” quality.\n
20
+ - **Progressive disclosure UX matters**: streaming phase updates and an expandable trace makes judges trust the system (it’s not “vibes”; you can see what it did).\n
21
+
22
+ ## Models used
23
+ - **LLM**: `Qwen/Qwen3.5-27B` (≤32B) via Hugging Face Inference Providers (recommended runtime path).\n
24
+ - **Embedder**: `BAAI/bge-small-en-v1.5` (33M) for retrieval.\n
25
+ - **Reranker (optional)**: `cross-encoder/ettin-reranker-32m-v1` (CPU) for pool reranking.\n
26
+
27
+ ## What I’d improve next (post-hackathon)
28
+ - Make “coach mode” differentiate *response style*, not just “add an explanation panel”.\n
29
+ - Add a public, privacy-safe trace export that covers **both** evaluation runs and real usage patterns (with de-identification).\n
30
+ - Tighten the “small talk” and “low twist” path so it’s still delightful without running the full pipeline.\n
31
+
docs/linkedin-post.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LinkedIn post (copy-paste)
2
+
3
+ Happy to share a project I've been building for the Hugging Face Build Small Hackathon.
4
+
5
+ Comedy has always been a personal interest for me — not just watching it, but understanding how a line actually lands. I wanted something closer to a **humor coach** for awkward real-life moments: you paste what happened, get one sharp line back, then iterate on it.
6
+
7
+ That's **WitGym**. It extracts the comedy structure of the moment, finds analogous precedent from *The Office* (a show I've loved for years — it felt like the right sandbox), drafts a few constrained candidates, ranks them, and returns one line you can use. Then you can sharpen it, try a different angle, or unpack why it works.
8
+
9
+ More detail in the README and live demo on Hugging Face Spaces:
10
+ https://huggingface.co/spaces/build-small-hackathon/WitGym
11
+
12
+ Built for Build Small 2026 — Thousand Token Wood track.
scripts/eval_behavioral_ab.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A/B eval: baseline (main/deployed) vs behavioral_observation branch changes.
2
+
3
+ Uses LLM_BACKEND=hf_api only. Does not touch corpus/index.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from dotenv import load_dotenv
16
+
17
+ load_dotenv()
18
+ os.environ.setdefault("LLM_BACKEND", "hf_api")
19
+
20
+ ROOT = Path(__file__).resolve().parents[1]
21
+ CHANGED = [
22
+ "witgym/schemas.py",
23
+ "witgym/extractor.py",
24
+ "witgym/prompts.py",
25
+ "witgym/generator.py",
26
+ ]
27
+ BACKUP_DIR = ROOT / "data" / ".eval_behavioral_backup"
28
+
29
+ SINGLE_TURN = [
30
+ "I just got promoted to manager and I have no idea what I'm doing.",
31
+ "My coworker keeps stealing my lunch from the fridge.",
32
+ "I've been cc'd on an email chain I definitely should not be reading.",
33
+ "I'm pretending to understand cryptocurrency at dinner parties.",
34
+ "My therapist fell asleep during our session.",
35
+ "I still haven't replied to that email from three weeks ago.",
36
+ "My boss says he trusts me, but he rewrites every message I send.",
37
+ "I told everyone the meeting would be quick and it is now ruining lives.",
38
+ "I keep calling it networking when really I'm just begging professionally.",
39
+ 'I said "circle back" because I ran out of courage.',
40
+ "I keep acting like I'm choosing not to date when the market has actually made that decision for me.",
41
+ "I described panic as being detail-oriented and now people believe me.",
42
+ ]
43
+
44
+ COACHING_FLOWS = [
45
+ (
46
+ "Help me come up with something funny to say to my micromanaging boss.",
47
+ "He keeps interrupting me in meetings and then repeating my point like he invented it.",
48
+ ),
49
+ (
50
+ "Coach me on a funny response for a social situation.",
51
+ 'A friend asked if I was free this weekend and I panicked and said I was "booked" when I meant emotionally.',
52
+ ),
53
+ (
54
+ "Help me respond better in awkward situations.",
55
+ "I told my date I love routines and then described anxiety like it was a hobby.",
56
+ ),
57
+ ]
58
+
59
+
60
+ def _backup_modified() -> None:
61
+ if BACKUP_DIR.exists():
62
+ shutil.rmtree(BACKUP_DIR)
63
+ BACKUP_DIR.mkdir(parents=True)
64
+ for rel in CHANGED:
65
+ src = ROOT / rel
66
+ if src.exists():
67
+ shutil.copy2(src, BACKUP_DIR / Path(rel).name)
68
+
69
+
70
+ def _restore_modified() -> None:
71
+ for rel in CHANGED:
72
+ name = Path(rel).name
73
+ src = BACKUP_DIR / name
74
+ if src.exists():
75
+ shutil.copy2(src, ROOT / rel)
76
+
77
+
78
+ def _reset_to_baseline() -> None:
79
+ subprocess.run(
80
+ ["git", "checkout", "--", *CHANGED],
81
+ cwd=ROOT,
82
+ check=True,
83
+ )
84
+
85
+
86
+ def _run_one(engine, user_input: str, *, coaching_turn2: str | None = None) -> dict:
87
+ from witgym.engine import WitGymEngine
88
+
89
+ eng = WitGymEngine(index_path="data/index.npz", resources=engine)
90
+ t0 = time.time()
91
+ if coaching_turn2 is None:
92
+ r = eng.respond(user_input)
93
+ meta = r.metadata
94
+ return {
95
+ "input": user_input,
96
+ "kind": "single",
97
+ "selected": r.selected,
98
+ "word_count": len(r.selected.split()),
99
+ "behavioral_observation": getattr(meta, "behavioral_observation", None),
100
+ "subtext": meta.subtext,
101
+ "archetype": meta.archetype.value,
102
+ "latency_s": round(time.time() - t0, 1),
103
+ "candidates": [c.text for c in r.candidates],
104
+ }
105
+
106
+ turn1 = eng.respond(user_input)
107
+ eng2 = WitGymEngine(
108
+ index_path="data/index.npz",
109
+ resources=engine,
110
+ conversation=eng.conversation,
111
+ )
112
+ t1 = time.time()
113
+ r2 = eng2.respond(coaching_turn2)
114
+ meta = r2.metadata
115
+ return {
116
+ "input": f"{user_input} → {coaching_turn2}",
117
+ "kind": "coaching_turn2",
118
+ "coaching_question": turn1.coaching_question or turn1.selected,
119
+ "selected": r2.selected,
120
+ "word_count": len(r2.selected.split()),
121
+ "behavioral_observation": getattr(meta, "behavioral_observation", None),
122
+ "subtext": meta.subtext,
123
+ "archetype": meta.archetype.value,
124
+ "latency_s": round(time.time() - t1, 1),
125
+ "candidates": [c.text for c in r2.candidates],
126
+ }
127
+
128
+
129
+ def _run_variant_subprocess(tag: str) -> None:
130
+ """Run one variant in a fresh Python process so code changes take effect."""
131
+ env = os.environ.copy()
132
+ env.setdefault("LLM_BACKEND", "hf_api")
133
+ cmd = [
134
+ sys.executable,
135
+ str(ROOT / "scripts" / "eval_behavioral_ab.py"),
136
+ "--worker",
137
+ tag,
138
+ ]
139
+ subprocess.run(cmd, cwd=ROOT, env=env, check=True)
140
+
141
+
142
+ def run_variant(tag: str) -> list[dict]:
143
+ from witgym.engine import SharedResources
144
+
145
+ if not Path("data/index.npz").exists():
146
+ print("[ERROR] data/index.npz missing")
147
+ sys.exit(1)
148
+
149
+ shared = SharedResources(index_path="data/index.npz")
150
+ results: list[dict] = []
151
+ total = len(SINGLE_TURN) + len(COACHING_FLOWS)
152
+ n = 0
153
+
154
+ for prompt in SINGLE_TURN:
155
+ n += 1
156
+ print(f"\n[{tag}] [{n}/{total}] {prompt[:70]}...")
157
+ row = _run_one(shared, prompt)
158
+ print(f" → {row['selected']}")
159
+ if row.get("behavioral_observation"):
160
+ print(f" obs: {row['behavioral_observation'][:100]}")
161
+ results.append(row)
162
+
163
+ for turn1, turn2 in COACHING_FLOWS:
164
+ n += 1
165
+ print(f"\n[{tag}] [{n}/{total}] coaching: {turn1[:50]}...")
166
+ row = _run_one(shared, turn1, coaching_turn2=turn2)
167
+ print(f" → {row['selected']}")
168
+ if row.get("behavioral_observation"):
169
+ print(f" obs: {row['behavioral_observation'][:100]}")
170
+ results.append(row)
171
+
172
+ out = ROOT / "data" / f"eval_behavioral_{tag}.json"
173
+ payload = {"tag": tag, "results": results}
174
+ out.write_text(json.dumps(payload, indent=2), encoding="utf-8")
175
+ print(f"\n✓ Saved {out}")
176
+ return results
177
+
178
+
179
+ def compare() -> None:
180
+ b_path = ROOT / "data" / "eval_behavioral_baseline.json"
181
+ m_path = ROOT / "data" / "eval_behavioral_modified.json"
182
+ if not b_path.exists() or not m_path.exists():
183
+ print("[ERROR] Run full eval first")
184
+ sys.exit(1)
185
+
186
+ baseline = {r["input"]: r for r in json.loads(b_path.read_text())["results"]}
187
+ modified = {r["input"]: r for r in json.loads(m_path.read_text())["results"]}
188
+
189
+ print("\n" + "=" * 100)
190
+ print("BEHAVIORAL_OBSERVATION A/B — baseline (main/HF) vs modified")
191
+ print("=" * 100)
192
+
193
+ rows = []
194
+ for key in baseline:
195
+ b, m = baseline[key], modified.get(key)
196
+ if not m:
197
+ continue
198
+ print(f"\n📝 {key[:90]}")
199
+ print(f" BASE [{b['word_count']}w]: {b['selected']}")
200
+ print(f" MOD [{m['word_count']}w]: {m['selected']}")
201
+ if m.get("behavioral_observation") and not b.get("behavioral_observation"):
202
+ print(f" OBS (modified only): {m['behavioral_observation']}")
203
+ elif b.get("behavioral_observation") or m.get("behavioral_observation"):
204
+ print(f" OBS baseline: {b.get('behavioral_observation')}")
205
+ print(f" OBS modified: {m.get('behavioral_observation')}")
206
+ rows.append({"input": key, "baseline": b["selected"], "modified": m["selected"]})
207
+
208
+ sheet = ROOT / "data" / "eval_behavioral_sheet.json"
209
+ sheet.write_text(json.dumps(rows, indent=2), encoding="utf-8")
210
+ print(f"\n✓ Comparison sheet → {sheet}")
211
+
212
+
213
+ def main() -> None:
214
+ import argparse
215
+
216
+ p = argparse.ArgumentParser()
217
+ p.add_argument("--compare-only", action="store_true")
218
+ p.add_argument("--worker", choices=["baseline", "modified"])
219
+ args = p.parse_args()
220
+
221
+ if args.worker:
222
+ run_variant(args.worker)
223
+ return
224
+
225
+ if args.compare_only:
226
+ compare()
227
+ return
228
+
229
+ _backup_modified()
230
+ try:
231
+ print("=== BASELINE (main / deployed HF) ===")
232
+ _reset_to_baseline()
233
+ _run_variant_subprocess("baseline")
234
+ print("\n=== MODIFIED (behavioral_observation) ===")
235
+ _restore_modified()
236
+ _run_variant_subprocess("modified")
237
+ compare()
238
+ finally:
239
+ _restore_modified()
240
+
241
+
242
+ if __name__ == "__main__":
243
+ main()
scripts/export_public_traces.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export a small, public-safe set of WitGym traces (Sharing is Caring).
2
+
3
+ This writes deterministic-ish JSONL traces for a fixed set of canonical prompts.
4
+ It intentionally omits any raw Office transcript text from retrieved scenes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import time
12
+ import hashlib
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ CANONICAL_SINGLE_TURN = [
18
+ "I just got promoted to manager and I have no idea what I'm doing.",
19
+ "My coworker keeps stealing my lunch from the fridge.",
20
+ "My boss says he trusts me, but he rewrites every message I send.",
21
+ ]
22
+
23
+
24
+ def _scene_id(setup: str, response: str) -> str:
25
+ h = hashlib.sha256((setup + "\n" + response).encode("utf-8")).hexdigest()
26
+ return h[:12]
27
+
28
+
29
+ def _safe_scene(scene) -> dict:
30
+ # Do not export setup/response verbatim; this keeps traces inspectable without
31
+ # publishing transcript text.
32
+ return {
33
+ "scene_id": _scene_id(scene.setup, scene.response),
34
+ "show": scene.show,
35
+ "character": scene.character,
36
+ "archetype": scene.archetype.value,
37
+ "tension_type": scene.tension_type.value,
38
+ "violation_distance": scene.violation_distance.value,
39
+ }
40
+
41
+
42
+ def _pipeline_logs(result) -> list[dict]:
43
+ meta = result.metadata
44
+ scenes = result.retrieved_scenes
45
+ candidates = result.candidates
46
+ return [
47
+ {
48
+ "step": "metadata",
49
+ "status": "ok",
50
+ "detail": f"twist={meta.twist_potential} archetype={meta.archetype.value}",
51
+ },
52
+ {
53
+ "step": "retrieval",
54
+ "status": "ok",
55
+ "detail": ", ".join(f"{s.character}:{s.archetype.value}" for s in scenes) or "no precedent scenes",
56
+ },
57
+ {
58
+ "step": "candidate_generation",
59
+ "status": "ok",
60
+ "detail": ", ".join(f"{c.persona}:{len(c.text.split())}w" for c in candidates) or "no candidates",
61
+ },
62
+ {"step": "ranking", "status": "ok", "detail": result.winning_persona or "none"},
63
+ {"step": "compression", "status": "ok", "detail": "selected line finalized"},
64
+ ]
65
+
66
+
67
+ def _run_single(engine, user_input: str) -> dict:
68
+ t0 = time.time()
69
+ result = engine.respond(user_input)
70
+ dt = time.time() - t0
71
+
72
+ meta = result.metadata
73
+ return {
74
+ "kind": "single_turn",
75
+ "input": user_input,
76
+ "route": result.route,
77
+ "model_id": os.getenv("LLM_MODEL_ID", "Qwen/Qwen3.5-27B"),
78
+ "llm_backend": os.getenv("LLM_BACKEND", "hf_api"),
79
+ "latency_s": round(dt, 2),
80
+ "metadata": {
81
+ "surface": meta.surface,
82
+ "subtext": meta.subtext,
83
+ "behavioral_observation": getattr(meta, "behavioral_observation", None),
84
+ "archetype": meta.archetype.value,
85
+ "archetype_confidence": meta.archetype_confidence,
86
+ "tension_type": meta.tension_type.value,
87
+ "power_dynamic": meta.power_dynamic,
88
+ "speaker_strategy": meta.speaker_strategy,
89
+ "obvious_response": meta.obvious_response,
90
+ "violation_distance": meta.violation_distance.value,
91
+ "twist_potential": meta.twist_potential,
92
+ "connector": meta.connector,
93
+ },
94
+ "retrieved_scenes": [_safe_scene(s) for s in result.retrieved_scenes],
95
+ "candidates": [
96
+ {"persona": c.persona, "word_count": len(c.text.split())}
97
+ for c in result.candidates
98
+ ],
99
+ "winning_persona": result.winning_persona,
100
+ "selected": result.selected,
101
+ "logs": _pipeline_logs(result),
102
+ }
103
+
104
+
105
+ def main() -> int:
106
+ # Prefer HF API for reproducible “no local weights” runs.
107
+ os.environ.setdefault("LLM_BACKEND", "hf_api")
108
+
109
+ # Allow running without installing the package (pip/uv editable install).
110
+ repo_root = Path(__file__).resolve().parents[1]
111
+ if str(repo_root) not in sys.path:
112
+ sys.path.insert(0, str(repo_root))
113
+
114
+ from witgym.engine import WitGymEngine
115
+
116
+ out_path = Path("data/public_traces.jsonl")
117
+ out_path.parent.mkdir(parents=True, exist_ok=True)
118
+
119
+ engine = WitGymEngine(index_path="data/index.npz")
120
+
121
+ rows: list[dict] = []
122
+ for prompt in CANONICAL_SINGLE_TURN:
123
+ rows.append(_run_single(engine, prompt))
124
+
125
+ with out_path.open("w", encoding="utf-8") as f:
126
+ for row in rows:
127
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
128
+
129
+ print(f"✓ Wrote {len(rows)} trace rows → {out_path}")
130
+ return 0
131
+
132
+
133
+ if __name__ == "__main__":
134
+ raise SystemExit(main())
135
+