RthItalia commited on
Commit
7ed414d
·
verified ·
1 Parent(s): 91180f7

Deploy Trollsona to Build Small org with Codex

Browse files
Files changed (7) hide show
  1. .env.example +4 -0
  2. .gitignore +12 -0
  3. README.md +201 -6
  4. SUBMISSION.md +92 -0
  5. app.py +848 -0
  6. assets/style.css +446 -0
  7. requirements.txt +4 -0
.env.example ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ TROLLSONA_ENABLE_MODEL=1
2
+ TROLLSONA_MODEL_ID=RthItalia/nano_compact_3b_qkvfp16
3
+ TROLLSONA_FALLBACK_MODEL_ID=Qwen/Qwen2.5-0.5B-Instruct
4
+ TROLLSONA_MAX_NEW_TOKENS=200
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ __pycache__/
5
+ *.py[cod]
6
+ .gradio/
7
+ .pytest_cache/
8
+ .playwright-mcp/
9
+ .codex_launch_gradio.py
10
+ gradio.out.log
11
+ gradio.err.log
12
+ trollsona-*.png
README.md CHANGED
@@ -1,13 +1,208 @@
1
  ---
2
  title: Trollsona
3
- emoji: 🦀
4
- colorFrom: blue
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.16.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Trollsona
3
+ emoji: 🧌
4
+ colorFrom: yellow
5
+ colorTo: red
6
  sdk: gradio
7
+ sdk_version: 5.50.0
 
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # Trollsona / Your Troll Alterego
13
+
14
+ **Tagline:** Summon the little menace living behind your respectable personality.
15
+
16
+ **Track:** An Adventure in Thousand Token Wood
17
+
18
+ **Build target:** Hugging Face Space, Gradio app, small-model constraint `<=32B`.
19
+
20
+ **GitHub repo:** https://github.com/rthgit/Trollsona
21
+
22
+ **Hugging Face Space:** https://huggingface.co/spaces/RthItalia/Trollsona
23
+
24
+ Trollsona is a playful Gradio experience that turns a short user confession into a theatrical troll alter ego. The app returns a dossier-style result card with a trollsona name, a warm roast, one useful slap, and a goblin meter.
25
+
26
+ Built with a compact RthItalia model derived from `Qwen/Qwen2.5-3B-Instruct`, under `32B` parameters. The deployed Space is configured to try that model first, then a lightweight Qwen 0.5B model, then the deterministic local fallback if model loading or generation is unavailable.
27
+
28
+ The public Space currently runs the lightweight Qwen fallback on CPU, while the custom RthItalia compact 3B path is enabled automatically when CUDA is available.
29
+
30
+ ## Features
31
+
32
+ - Immersive Gradio UI for Hugging Face Spaces
33
+ - Theatrical trollsona result card
34
+ - Local Hugging Face Transformers generation path for the primary AI runtime
35
+ - Secondary lightweight Transformers model fallback
36
+ - Deterministic fallback generator for final resilience
37
+ - Safe roast guard for non-hateful, non-identity-targeted humor
38
+ - Persona dropdown, sting slider, and useful-truth checkbox
39
+ - Source/fallback notes hidden behind `See the cursed paperwork`
40
+
41
+ ## Model Runtime
42
+
43
+ Trollsona uses a small-model cascade:
44
+
45
+ 1. `RthItalia/nano_compact_3b_qkvfp16`
46
+ - compact `Qwen/Qwen2.5-3B-Instruct`-derived model by RthItalia
47
+ - preferred runtime when CUDA is available
48
+ - loaded with `trust_remote_code=True`
49
+
50
+ 2. `Qwen/Qwen2.5-0.5B-Instruct`
51
+ - lightweight hosted CPU fallback model
52
+ - currently active on the public Hugging Face Space running on `cpu-basic`
53
+
54
+ 3. Deterministic fallback
55
+ - used only if both model paths are unavailable or return unsafe/invalid output
56
+ - keeps the demo stable and reproducible
57
+
58
+ Constraint:
59
+
60
+ ```text
61
+ small model only, <=32B parameters
62
+ ```
63
+
64
+ Space model-first behavior:
65
+
66
+ ```bash
67
+ TROLLSONA_ENABLE_MODEL=1
68
+ ```
69
+
70
+ Recommended Hugging Face Space variables:
71
+
72
+ ```text
73
+ TROLLSONA_ENABLE_MODEL=1
74
+ TROLLSONA_MODEL_ID=RthItalia/nano_compact_3b_qkvfp16
75
+ TROLLSONA_FALLBACK_MODEL_ID=Qwen/Qwen2.5-0.5B-Instruct
76
+ TROLLSONA_MAX_NEW_TOKENS=200
77
+ ```
78
+
79
+ Local fallback-safe behavior if no variable is set:
80
+
81
+ ```bash
82
+ TROLLSONA_ENABLE_MODEL=0
83
+ ```
84
+
85
+ Deterministic fallback only:
86
+
87
+ ```bash
88
+ TROLLSONA_ENABLE_MODEL=0
89
+ ```
90
+
91
+ Implementation notes:
92
+
93
+ - `bitsandbytes` is not required
94
+ - primary RthItalia path expects CUDA
95
+ - CPU-only Spaces use the Qwen 0.5B model before the deterministic fallback
96
+ - source/runtime/fallback details are hidden in `See the cursed paperwork`
97
+
98
+ ## Stack
99
+
100
+ - Python
101
+ - Gradio
102
+ - Hugging Face Spaces
103
+ - Hugging Face Transformers, primary model path
104
+ - PyTorch, model backend
105
+
106
+ Required secrets:
107
+
108
+ ```text
109
+ [ASSENTE]
110
+ ```
111
+
112
+ ## Run Locally
113
+
114
+ ```bash
115
+ pip install -r requirements.txt
116
+ python app.py
117
+ ```
118
+
119
+ Open:
120
+
121
+ ```text
122
+ http://127.0.0.1:7860
123
+ ```
124
+
125
+ Model-first run:
126
+
127
+ ```bash
128
+ TROLLSONA_ENABLE_MODEL=1 python app.py
129
+ ```
130
+
131
+ Deterministic fallback run:
132
+
133
+ ```bash
134
+ TROLLSONA_ENABLE_MODEL=0 python app.py
135
+ ```
136
+
137
+ ## Hugging Face Space
138
+
139
+ Required files:
140
+
141
+ - `app.py`
142
+ - `requirements.txt`
143
+ - `README.md`
144
+ - `assets/style.css`
145
+
146
+ Space SDK:
147
+
148
+ ```text
149
+ Gradio
150
+ ```
151
+
152
+ Space URL:
153
+
154
+ ```text
155
+ https://huggingface.co/spaces/RthItalia/Trollsona
156
+ ```
157
+
158
+ ## Safety
159
+
160
+ Trollsona roasts habits, vibe, wording, overthinking, productivity rituals, internet behavior, startup energy, and harmless personal lore.
161
+
162
+ It avoids:
163
+
164
+ - protected-class targeting
165
+ - identity-based insults
166
+ - appearance insults
167
+ - threats or self-harm content
168
+ - sexual content
169
+ - profanity or slurs
170
+ - cruelty or humiliation
171
+
172
+ If generated model output fails the safety guard, the app replaces it with a safe fallback card.
173
+
174
+ ## Hackathon Fit
175
+
176
+ - Built as a Gradio app for Hugging Face Space
177
+ - Fits `An Adventure in Thousand Token Wood`
178
+ - Supports the `<=32B` small-model constraint
179
+ - Uses `RthItalia/nano_compact_3b_qkvfp16` as the primary AI path when CUDA is available
180
+ - Keeps `Qwen/Qwen2.5-0.5B-Instruct` as a secondary model fallback
181
+ - Runs without mandatory cloud APIs
182
+ - Keeps deterministic fallback as a reliability guard
183
+ - Produces short, whimsical, shareable output
184
+
185
+ ## Codex Track
186
+
187
+ Built with OpenAI Codex.
188
+
189
+ Public GitHub repo: https://github.com/rthgit/Trollsona
190
+
191
+ Codex-attributed commits include:
192
+
193
+ - `3fe2db1` Polish Trollsona dossier UI and grotesque prompt voice with Codex
194
+ - `4f196a6` Add RthItalia model cascade with Codex
195
+ - `8a1b09d` Document hosted model cascade QA with Codex
196
+
197
+ - Space README repo link: present
198
+ - Demo video: [DA COMPLETARE]
199
+ - Social post: [DA COMPLETARE]
200
+
201
+ ## Known Limits
202
+
203
+ - Public Space link: https://huggingface.co/spaces/RthItalia/Trollsona
204
+ - Demo video: [DA COMPLETARE]
205
+ - Social post URL: [DA COMPLETARE]
206
+ - Primary RthItalia model path requires CUDA; CPU-only Spaces use the secondary model fallback before deterministic fallback
207
+ - First model-backed generation can be slower on cold Spaces while model files load
208
+ - Exact model-backed behavior on upgraded Space hardware: [AMBIGUO], because upgraded hardware has not been tested
SUBMISSION.md ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Trollsona Submission Pack
2
+
3
+ ## Submission Checklist
4
+
5
+ | requirement | status | proof | missing action |
6
+ |---|---|---|---|
7
+ | Public GitHub repo | DONE | https://github.com/rthgit/Trollsona | None |
8
+ | Codex-attributed commits | DONE | `3fe2db1`, `4f196a6`, `8a1b09d` | None |
9
+ | Space README links to repo | DONE | `README.md` contains repo URL | None |
10
+ | Hugging Face Space deploy | DONE | https://huggingface.co/spaces/RthItalia/Trollsona | None |
11
+ | Space link | DONE | `README.md` contains https://huggingface.co/spaces/RthItalia/Trollsona | None |
12
+ | Space model variables | DONE | `.env.example` documents `TROLLSONA_ENABLE_MODEL=1`, `TROLLSONA_MODEL_ID=RthItalia/nano_compact_3b_qkvfp16`, `TROLLSONA_FALLBACK_MODEL_ID=Qwen/Qwen2.5-0.5B-Instruct` | Configure the same variables in Space settings if overriding defaults |
13
+ | Demo video | [DA COMPLETARE] | No video link/file present | Record 45-60s demo |
14
+ | Social post | [DA COMPLETARE] | Draft below contains Space/GitHub URLs | Publish and add final link if required |
15
+ | Gradio app | DONE | `app.py` defines `gr.Blocks` app | None |
16
+ | Small model <=32B | DONE | Primary model id: `RthItalia/nano_compact_3b_qkvfp16`; fallback model id: `Qwen/Qwen2.5-0.5B-Instruct` | None |
17
+ | Transformers model path | DONE | `TROLLSONA_ENABLE_MODEL=1` in Space variables; `AutoModelForCausalLM.from_pretrained(..., trust_remote_code=True)` implemented | None |
18
+ | Deterministic fallback | DONE | Fallback remains available with `TROLLSONA_ENABLE_MODEL=0`, unavailable CUDA, model failure, or invalid model output | None |
19
+ | Debug hidden by default | DONE | Source/fallback live in `See the cursed paperwork` | None |
20
+ | No mandatory cloud API | DONE | Public HF model path and fallback require no app secrets | None |
21
+
22
+ ## Demo Video Script
23
+
24
+ Target length: 45-60 seconds.
25
+
26
+ Recommended tested input:
27
+
28
+ ```text
29
+ Name: Alex
30
+ Lore: I start productivity systems and then reorganize the labels forever.
31
+ Persona: Dungeon Intern
32
+ Spice: 4
33
+ Advice: on
34
+ ```
35
+
36
+ Hosted test result: `Source: transformers_model`; runtime `Qwen/Qwen2.5-0.5B-Instruct` as CPU fallback model.
37
+
38
+ | timestamp | action | what it shows | suggested line |
39
+ |---|---|---|---|
40
+ | 0:00-0:05 | Open the Space | Trollsona hero, badges, dark ritual UI | "This is Trollsona: a small-model goblin that turns your personal lore into a cursed alter ego." |
41
+ | 0:05-0:15 | Enter name and lore | `What do they call you?`, `Confess your little lore` | "I give it a name and a very specific little confession." |
42
+ | 0:15-0:23 | Pick menace and sting | `Dungeon Intern`, spice `4`, advice on | "Then I choose the resident menace and how hard it should sting." |
43
+ | 0:23-0:32 | Click `Summon Trollsona` | End-to-end generation | "It summons a theatrical dossier instead of a plain chatbot answer." |
44
+ | 0:32-0:42 | Show result card | Trollsona name, roast, useful slap, goblin meter | "You get a name, a roast, one useful slap, and the goblin meter." |
45
+ | 0:42-0:50 | Open paperwork | `Source: transformers_model` and CPU runtime | "The model details stay hidden in the cursed paperwork until you ask for them." |
46
+ | 0:50-0:58 | Show README/Codex Track | Space, GitHub, model cascade, Codex commits | "Built as a Gradio Space with a small-model cascade and Codex-tracked commits." |
47
+ | 0:58-1:00 | Close on Space | Final result card or Space URL | "Tiny model, giant attitude." |
48
+
49
+ ## Social Post Draft
50
+
51
+ Hook: I built a little ritual that summons the troll living behind your respectable personality.
52
+
53
+ Description: Trollsona turns a short confession into a theatrical alter-ego dossier: trollsona name, playful roast, one useful slap, and a goblin meter.
54
+
55
+ Tech note: Built for Build Small Hackathon as a Gradio Hugging Face Space. The primary model is `RthItalia/nano_compact_3b_qkvfp16`, derived from `Qwen/Qwen2.5-3B-Instruct` and under the `<=32B` small-model constraint. A Qwen 0.5B model and deterministic fallback remain as reliability guards.
56
+
57
+ Links:
58
+
59
+ - Space: https://huggingface.co/spaces/RthItalia/Trollsona
60
+ - GitHub: https://github.com/rthgit/Trollsona
61
+
62
+ CTA: Try it, summon your menace, and share the dossier.
63
+
64
+ ## Release QA
65
+
66
+ | test | command/action | expected result | status |
67
+ |---|---|---|---|
68
+ | Python compile | `python -B -m py_compile app.py` | no syntax errors | DONE |
69
+ | Model path implemented | inspect `app.py` | Space sets `TROLLSONA_ENABLE_MODEL=1`; `from_pretrained(..., trust_remote_code=True)` path present | DONE |
70
+ | Deterministic fallback | run `generate_trollsona(...)` twice | identical structured output | DONE |
71
+ | Local Gradio launch | `python app.py` | app opens on `127.0.0.1:7860` | DONE in local QA |
72
+ | Input-full generation | fill name + lore + summon | result card renders | DONE in local QA |
73
+ | Input-minimal generation | empty name/lore | output still complete | DONE |
74
+ | Debug default | load page | source/fallback hidden | DONE |
75
+ | Visual contrast | inspect input/dropdown/checkbox/CTA/card | readable UI | DONE |
76
+ | Git status | `git status --short --ignored` | only ignored `.env` / QA screenshot remain | DONE |
77
+ | README repo link | inspect README | GitHub URL present | DONE |
78
+ | Space build | Hugging Face runtime API | `stage=RUNNING`, `requested=cpu-basic` | DONE |
79
+ | Browser test on Space | Playwright on public Space | card renders; debug source/fallback hidden until accordion opens | DONE |
80
+ | Hosted model-backed generation | Playwright on public Space, then open `See the cursed paperwork` | `Source: transformers_model`; runtime `Qwen/Qwen2.5-0.5B-Instruct` as `fallback_model` on CPU; fallback note reports primary `RthItalia/nano_compact_3b_qkvfp16` skipped because CUDA is unavailable | DONE |
81
+
82
+ ## Final Ship Plan
83
+
84
+ | step | owner | output | done criteria | priority |
85
+ |---|---|---|---|---|
86
+ | 1 | Codex | Final README and submission docs | Docs committed | P0 |
87
+ | 2 | Codex/User | GitHub push | `rthgit/Trollsona` contains latest commits | P0 |
88
+ | 3 | Codex | Hugging Face Space | Public Space URL opens app | P0 |
89
+ | 4 | Codex | README Space link | Space URL is present in `README.md` | P0 |
90
+ | 5 | User | Demo video | 45-60s video available | P1 |
91
+ | 6 | User | Social post | Published post with links | P1 |
92
+ | 7 | User | Submission form | All required URLs submitted | P0 |
app.py ADDED
@@ -0,0 +1,848 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import html
5
+ import json
6
+ import os
7
+ import re
8
+ from functools import lru_cache
9
+ from typing import Any
10
+
11
+
12
+ APP_TITLE = "Trollsona"
13
+ APP_SUBTITLE = "Summon the little menace living behind your respectable personality."
14
+ TRACK_NAME = "An Adventure in Thousand Token Wood"
15
+ DEFAULT_MODEL_ID = "RthItalia/nano_compact_3b_qkvfp16"
16
+ DEFAULT_FALLBACK_MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
17
+ MAX_PROFILE_CHARS = 700
18
+ MAX_NAME_CHARS = 36
19
+
20
+
21
+ def parse_bool_env(name: str, default: bool) -> bool:
22
+ raw_value = os.getenv(name)
23
+ if raw_value is None:
24
+ return default
25
+ normalized = raw_value.strip().lower()
26
+ if normalized in {"1", "true", "yes", "on"}:
27
+ return True
28
+ if normalized in {"0", "false", "no", "off"}:
29
+ return False
30
+ return default
31
+
32
+
33
+ def parse_int_env(name: str, default: int, min_value: int, max_value: int) -> int:
34
+ raw_value = os.getenv(name)
35
+ if raw_value is None:
36
+ return default
37
+ try:
38
+ value = int(raw_value)
39
+ except ValueError:
40
+ return default
41
+ return max(min_value, min(max_value, value))
42
+
43
+
44
+ MODEL_ID = os.getenv("TROLLSONA_MODEL_ID", DEFAULT_MODEL_ID)
45
+ FALLBACK_MODEL_ID = os.getenv("TROLLSONA_FALLBACK_MODEL_ID", DEFAULT_FALLBACK_MODEL_ID)
46
+ MODEL_ENABLED = parse_bool_env("TROLLSONA_ENABLE_MODEL", default=False)
47
+ MAX_NEW_TOKENS = parse_int_env("TROLLSONA_MAX_NEW_TOKENS", 200, 32, 512)
48
+
49
+
50
+ PERSONA_STYLES = {
51
+ "Back-Alley Oracle": {
52
+ "flavor": "candlelit prophecy from a very suspicious side street",
53
+ "noun_pool": ["Candle", "Omen", "Alley", "Brass", "Whisper", "Ledger"],
54
+ },
55
+ "Basement Prince": {
56
+ "flavor": "royal delusion wrapped in dust, snacks, and old cables",
57
+ "noun_pool": ["Basement", "Velvet", "Outlet", "Throne", "Snack", "Static"],
58
+ },
59
+ "Forest Heckler": {
60
+ "flavor": "mossy woodland sarcasm with a pocket full of bad advice",
61
+ "noun_pool": ["Moss", "Root", "Twig", "Bog", "Fern", "Stump"],
62
+ },
63
+ "Union Goblin": {
64
+ "flavor": "petty workplace grievance with ceremonial clipboard energy",
65
+ "noun_pool": ["Clause", "Mug", "Breakroom", "Badge", "Staple", "Shift"],
66
+ },
67
+ "Dungeon Intern": {
68
+ "flavor": "overworked dungeon bureaucracy and unpaid dramatic labor",
69
+ "noun_pool": ["Ledger", "Torch", "Mop", "Key", "Goblet", "Trapdoor"],
70
+ },
71
+ "Mall Witch": {
72
+ "flavor": "food-court divination with lip gloss and thunder",
73
+ "noun_pool": ["Kiosk", "Charm", "Receipt", "Fountain", "Mascara", "Pretzel"],
74
+ },
75
+ "Parking Lot Philosopher": {
76
+ "flavor": "deep truths delivered beside a dented shopping cart",
77
+ "noun_pool": ["Asphalt", "Cart", "Neon", "Cone", "Puddle", "Keychain"],
78
+ },
79
+ "Saint of Bad Decisions": {
80
+ "flavor": "holy nonsense for people who turn errands into lore",
81
+ "noun_pool": ["Halo", "Candle", "Excuse", "Relic", "Errand", "Confetti"],
82
+ },
83
+ "Meme Caporegime": {
84
+ "flavor": "old-neighborhood swagger filtered through cursed screenshots",
85
+ "noun_pool": ["Pixel", "Prophecy", "Caption", "Scroll", "Vibe", "Echo"],
86
+ },
87
+ }
88
+
89
+ SPICE_LABELS = {
90
+ 1: "tiny pinch",
91
+ 2: "polite sting",
92
+ 3: "back-room heckle",
93
+ 4: "crispy little judgment",
94
+ 5: "full dossier incident",
95
+ }
96
+
97
+ BLOCKED_PATTERNS = [
98
+ r"\bkill yourself\b",
99
+ r"\bkys\b",
100
+ r"\bself[- ]?harm\b",
101
+ r"\bsuicide\b",
102
+ r"\bhate\b",
103
+ r"\bidiot\b",
104
+ r"\bstupid\b",
105
+ r"\bmoron\b",
106
+ r"\bdumb\b",
107
+ r"\bloser\b",
108
+ r"\bugly\b",
109
+ r"\bworthless\b",
110
+ r"\bsubhuman\b",
111
+ r"\bslur\b",
112
+ r"\bterrorist\b",
113
+ r"\bsexual\b",
114
+ r"\bexplicit\b",
115
+ r"\bprotected class\b",
116
+ ]
117
+
118
+ PROTECTED_TARGETING_PATTERNS = [
119
+ r"\bbecause of your race\b",
120
+ r"\bbecause of your religion\b",
121
+ r"\bbecause of your gender\b",
122
+ r"\bbecause of your sexuality\b",
123
+ r"\bbecause of your disability\b",
124
+ r"\bbecause of your nationality\b",
125
+ r"\bbecause of your ethnicity\b",
126
+ ]
127
+
128
+ SAFE_REPLY = (
129
+ "The dossier hissed, smoked, and refused to punch down. "
130
+ "Final harmless verdict: your chaos has excellent posture and a suspicious little hat."
131
+ )
132
+ SAFE_ADVICE = "Make the next useful move before you decorate the excuse."
133
+
134
+ PRESET_DOSSIERS = [
135
+ {
136
+ "button": "Mira - coffee-built UI oracle",
137
+ "values": (
138
+ "Mira",
139
+ "I overbuild side projects, drink too much coffee, and love weird UI.",
140
+ "Back-Alley Oracle",
141
+ 3,
142
+ True,
143
+ ),
144
+ },
145
+ {
146
+ "button": "Alex - label-system dungeon clerk",
147
+ "values": (
148
+ "Alex",
149
+ "I start productivity systems and then reorganize the labels forever.",
150
+ "Dungeon Intern",
151
+ 4,
152
+ True,
153
+ ),
154
+ },
155
+ {
156
+ "button": "Sam - tiny-game screenshot boss",
157
+ "values": (
158
+ "Sam",
159
+ "I make tiny games, forget lunch, and name variables like ancient spells.",
160
+ "Meme Caporegime",
161
+ 2,
162
+ False,
163
+ ),
164
+ },
165
+ ]
166
+
167
+
168
+ def stable_int(*parts: str) -> int:
169
+ payload = "||".join(parts).encode("utf-8", errors="ignore")
170
+ return int(hashlib.sha256(payload).hexdigest()[:12], 16)
171
+
172
+
173
+ def clean_text(value: Any, max_chars: int) -> str:
174
+ text = "" if value is None else str(value)
175
+ text = re.sub(r"\s+", " ", text).strip()
176
+ return text[:max_chars]
177
+
178
+
179
+ def clamp_spice(value: Any) -> int:
180
+ try:
181
+ spice = int(value)
182
+ except (TypeError, ValueError):
183
+ spice = 3
184
+ return max(1, min(5, spice))
185
+
186
+
187
+ def compute_cringe_score(profile: str, persona: str, spice: int) -> int:
188
+ base = stable_int(profile.lower(), persona.lower(), str(spice)) % 61
189
+ return max(0, min(100, 22 + base + (spice * 3)))
190
+
191
+
192
+ def cringe_label(score: int) -> str:
193
+ if score < 35:
194
+ return "barely haunted"
195
+ if score < 60:
196
+ return "noticeably cursed"
197
+ if score < 82:
198
+ return "dossier-grade cringe"
199
+ return "full goblin canon event"
200
+
201
+
202
+ def build_prompt(
203
+ user_name: str,
204
+ profile: str,
205
+ persona: str,
206
+ spice: int,
207
+ include_advice: bool,
208
+ score: int,
209
+ ) -> str:
210
+ style = PERSONA_STYLES.get(persona, PERSONA_STYLES["Forest Heckler"])
211
+ advice_rule = "Include one practical useful_advice sentence." if include_advice else (
212
+ "Set useful_advice to a short note that advice was disabled."
213
+ )
214
+ return f"""
215
+ You are Trollsona, a theatrical troll alter-ego generator.
216
+ Track: {TRACK_NAME}.
217
+
218
+ Your job is to transform the user's self-description into a funny, slightly grotesque,
219
+ whimsical troll persona. Make it feel like a stained-paper character dossier that was
220
+ dictated by a back-alley fortune teller, stamped by a petty clerk, and lightly heckled
221
+ by an italo-american cousin who has opinions but not cruelty.
222
+
223
+ Return only valid minified JSON with these fields:
224
+ trollsona_name, troll_reply, useful_advice, cringe_score, cringe_score_label.
225
+
226
+ Objective:
227
+ - Make the result absurd, memorable, specific, and theatrical.
228
+ - Make trollsona_name sound like a summoned character, not a username.
229
+ - Keep it roasty, not hateful.
230
+ - Keep the humor sharp but warm: playful sting, never humiliation.
231
+
232
+ Style rules:
233
+ - Write in vivid, punchy English.
234
+ - Use occasional light italo-american flavor, but sparingly.
235
+ - Good flavor examples: "listen, paisan", "madone", "capisce".
236
+ - Do not overuse slang or turn the voice into a caricature.
237
+ - Use grotesque but charming imagery: candle wax, receipts, tiny crowns, haunted binders,
238
+ dented carts, snack dust, side quests, suspicious paperwork.
239
+ - No generic roast bot voice.
240
+ - No generic assistant copy, no filler, no disclaimers, no moralizing.
241
+ - troll_reply must be the strongest comedic line, 1-3 short sentences max.
242
+ - useful_advice must contain one real insight in 1 sentence max.
243
+
244
+ Humor boundaries:
245
+ - Roast only habits, vibe, overthinking, productivity rituals, startup energy,
246
+ internet behavior, wording, or harmless personal lore.
247
+ - Never attack protected characteristics or identity.
248
+ - Never insult appearance, race, ethnicity, religion, disability, nationality,
249
+ gender, sexuality, trauma, mental health, or protected traits.
250
+ - Never include threats, self-harm, sexual content, profanity, or slurs.
251
+ - Never punch down.
252
+
253
+ User name: {user_name or "Anonymous traveler"}
254
+ User profile: {profile or "No profile supplied."}
255
+ Persona: {persona}
256
+ Persona flavor: {style["flavor"]}
257
+ Spice level: {spice}/5 ({SPICE_LABELS[spice]})
258
+ Use this exact deterministic cringe_score: {score}
259
+ Use this matching cringe_score_label: {cringe_label(score)}
260
+ {advice_rule}
261
+ """.strip()
262
+
263
+
264
+ def is_safe_text(text: str) -> bool:
265
+ normalized = text.lower()
266
+ for pattern in BLOCKED_PATTERNS + PROTECTED_TARGETING_PATTERNS:
267
+ if re.search(pattern, normalized):
268
+ return False
269
+ return True
270
+
271
+
272
+ def fallback_trollsona(
273
+ user_name: str,
274
+ profile: str,
275
+ persona: str,
276
+ spice: int,
277
+ include_advice: bool,
278
+ reason: str,
279
+ ) -> dict[str, Any]:
280
+ style = PERSONA_STYLES.get(persona, PERSONA_STYLES["Forest Heckler"])
281
+ seed = stable_int(user_name.lower(), profile.lower(), persona.lower(), str(spice))
282
+ adjectives = ["Velvet", "Candle", "Ashen", "Brass", "Crooked", "Sainted", "Static"]
283
+ titles = [
284
+ "Overthinker in Residence",
285
+ "Snack Baron of Almost",
286
+ "Dossier Clerk",
287
+ "Chaos Notary",
288
+ "Sidequest Duke",
289
+ "Patron Saint of Later",
290
+ ]
291
+ noun = style["noun_pool"][seed % len(style["noun_pool"])]
292
+ adjective = adjectives[(seed // 7) % len(adjectives)]
293
+ title = titles[(seed // 13) % len(titles)]
294
+
295
+ safe_name = re.sub(r"[^A-Za-z0-9 ]+", "", user_name).strip()[:MAX_NAME_CHARS]
296
+ name_prefix = safe_name.title() if safe_name else adjective
297
+ trollsona_name = f"{name_prefix} {noun}-{title}"
298
+
299
+ roast_templates = [
300
+ "Listen, paisan: your vibe is a candlelit side quest that opened twelve tabs, found a tiny crown, and called it destiny.",
301
+ "Your aura says main character, but your calendar is dressed like a haunted binder asking for rent.",
302
+ "You are one dramatic cape away from turning a normal errand into a village ordinance.",
303
+ "Your brain is a basement tavern where every idea demands a theme song, a snack bowl, and a separate invoice.",
304
+ "Madone, you carry the confidence of a bridge troll charging tolls in vibes and loose receipts.",
305
+ "You alphabetize chaos, misplace the alphabet, then file a complaint with the moon.",
306
+ ]
307
+ advice_templates = [
308
+ "Pick one task, make it smaller, and finish that version before you rename the kingdom.",
309
+ "Write the next concrete step in one sentence, then do only that step. Capisce?",
310
+ "Keep the weird idea, but give it a deadline and a visible done state.",
311
+ "Trade one dramatic plan for one shipped artifact before the candles burn out.",
312
+ "Use the chaos as seasoning, not as project management.",
313
+ ]
314
+
315
+ score = compute_cringe_score(profile, persona, spice)
316
+ reply = roast_templates[(seed // 17 + spice) % len(roast_templates)]
317
+ advice = advice_templates[(seed // 23 + spice) % len(advice_templates)]
318
+ if not include_advice:
319
+ advice = "Truth withheld. The dossier clerk stamps the page and looks away."
320
+
321
+ return {
322
+ "trollsona_name": trollsona_name,
323
+ "troll_reply": reply,
324
+ "useful_advice": advice,
325
+ "cringe_score": score,
326
+ "cringe_score_label": cringe_label(score),
327
+ "include_advice": include_advice,
328
+ "runtime": f"model_id={MODEL_ID}; fallback_model_id={FALLBACK_MODEL_ID}; model_enabled={MODEL_ENABLED}",
329
+ "source": "deterministic_fallback",
330
+ "fallback_reason": reason,
331
+ }
332
+
333
+
334
+ @lru_cache(maxsize=1)
335
+ def load_model() -> tuple[Any | None, Any | None, str, str]:
336
+ if not MODEL_ENABLED:
337
+ return (
338
+ None,
339
+ None,
340
+ "model disabled by TROLLSONA_ENABLE_MODEL",
341
+ f"model_id={MODEL_ID}; fallback_model_id={FALLBACK_MODEL_ID}; device=disabled",
342
+ )
343
+
344
+ try:
345
+ import torch
346
+ from transformers import AutoModelForCausalLM, AutoTokenizer
347
+ except Exception as exc:
348
+ return (
349
+ None,
350
+ None,
351
+ f"model dependencies unavailable: {type(exc).__name__}: {exc}",
352
+ f"model_id={MODEL_ID}; fallback_model_id={FALLBACK_MODEL_ID}; device=unavailable",
353
+ )
354
+
355
+ failures: list[str] = []
356
+
357
+ def load_tokenizer(candidate_id: str) -> Any:
358
+ tokenizer = AutoTokenizer.from_pretrained(
359
+ candidate_id,
360
+ use_fast=True,
361
+ trust_remote_code=True,
362
+ )
363
+ if tokenizer.pad_token_id is None and tokenizer.eos_token is not None:
364
+ tokenizer.pad_token = tokenizer.eos_token
365
+ return tokenizer
366
+
367
+ def load_cuda_model(candidate_id: str) -> Any:
368
+ load_attempts = [
369
+ {
370
+ "trust_remote_code": True,
371
+ "device_map": "cuda",
372
+ "dtype": torch.float16,
373
+ "low_cpu_mem_usage": True,
374
+ },
375
+ {
376
+ "trust_remote_code": True,
377
+ "device_map": "cuda",
378
+ "torch_dtype": torch.float16,
379
+ "low_cpu_mem_usage": True,
380
+ },
381
+ {
382
+ "trust_remote_code": True,
383
+ "torch_dtype": torch.float16,
384
+ "low_cpu_mem_usage": True,
385
+ },
386
+ ]
387
+ last_error: Exception | None = None
388
+ for kwargs in load_attempts:
389
+ try:
390
+ model = AutoModelForCausalLM.from_pretrained(candidate_id, **kwargs)
391
+ if "device_map" not in kwargs:
392
+ model = model.to("cuda")
393
+ return model
394
+ except Exception as exc:
395
+ last_error = exc
396
+ if last_error is not None:
397
+ raise last_error
398
+ raise RuntimeError("CUDA model load failed without exception")
399
+
400
+ def load_cpu_model(candidate_id: str) -> Any:
401
+ try:
402
+ return AutoModelForCausalLM.from_pretrained(
403
+ candidate_id,
404
+ trust_remote_code=True,
405
+ low_cpu_mem_usage=True,
406
+ )
407
+ except TypeError:
408
+ return AutoModelForCausalLM.from_pretrained(candidate_id, trust_remote_code=True)
409
+
410
+ candidates = [
411
+ {"role": "primary", "model_id": MODEL_ID, "requires_cuda": True},
412
+ {"role": "fallback_model", "model_id": FALLBACK_MODEL_ID, "requires_cuda": False},
413
+ ]
414
+
415
+ seen_model_ids: set[str] = set()
416
+ for candidate in candidates:
417
+ candidate_id = str(candidate["model_id"]).strip()
418
+ if not candidate_id or candidate_id in seen_model_ids:
419
+ continue
420
+ seen_model_ids.add(candidate_id)
421
+ role = str(candidate["role"])
422
+ requires_cuda = bool(candidate["requires_cuda"])
423
+
424
+ if requires_cuda and not torch.cuda.is_available():
425
+ failures.append(f"{role} {candidate_id}: CUDA unavailable")
426
+ continue
427
+
428
+ try:
429
+ tokenizer = load_tokenizer(candidate_id)
430
+ if torch.cuda.is_available():
431
+ model = load_cuda_model(candidate_id)
432
+ device = "cuda"
433
+ else:
434
+ model = load_cpu_model(candidate_id)
435
+ device = "cpu"
436
+ model.eval()
437
+ torch.manual_seed(0)
438
+ if torch.cuda.is_available():
439
+ torch.cuda.manual_seed_all(0)
440
+ fallback_note = "; ".join(failures)
441
+ status = "model loaded" if not fallback_note else f"model loaded after fallback: {fallback_note}"
442
+ runtime = (
443
+ f"model_id={candidate_id}; role={role}; device={device}; "
444
+ f"cuda_available={torch.cuda.is_available()}"
445
+ )
446
+ return tokenizer, model, status, runtime
447
+ except Exception as exc:
448
+ failures.append(f"{role} {candidate_id}: {type(exc).__name__}: {exc}")
449
+
450
+ failure_text = " | ".join(failures) if failures else "no model candidates configured"
451
+ runtime = (
452
+ f"model_id={MODEL_ID}; fallback_model_id={FALLBACK_MODEL_ID}; "
453
+ f"cuda_available={torch.cuda.is_available()}"
454
+ )
455
+ return None, None, f"model load failed: {failure_text}", runtime
456
+
457
+
458
+ def format_generation_prompt(tokenizer: Any, prompt: str) -> str:
459
+ try:
460
+ if getattr(tokenizer, "chat_template", None):
461
+ return tokenizer.apply_chat_template(
462
+ [{"role": "user", "content": prompt}],
463
+ tokenize=False,
464
+ add_generation_prompt=True,
465
+ )
466
+ except Exception:
467
+ return prompt
468
+ return prompt
469
+
470
+
471
+ def generation_temperature(spice: int) -> float:
472
+ return round(0.48 + (clamp_spice(spice) * 0.08), 2)
473
+
474
+
475
+ def model_device(model: Any) -> Any:
476
+ target_device = getattr(model, "device", None)
477
+ if target_device is not None and str(target_device) != "meta":
478
+ return target_device
479
+ try:
480
+ return next(model.parameters()).device
481
+ except Exception:
482
+ return None
483
+
484
+
485
+ def generate_with_model(prompt: str, spice: int) -> tuple[str | None, str, str]:
486
+ tokenizer, model, status, runtime = load_model()
487
+ if tokenizer is None or model is None:
488
+ return None, status, runtime
489
+
490
+ try:
491
+ import torch
492
+
493
+ model_prompt = format_generation_prompt(tokenizer, prompt)
494
+ inputs = tokenizer(model_prompt, return_tensors="pt", truncation=True, max_length=1536)
495
+ target_device = model_device(model)
496
+ if target_device is not None:
497
+ inputs = {key: value.to(target_device) for key, value in inputs.items()}
498
+
499
+ seed = stable_int(prompt, str(spice), runtime) % (2**31)
500
+ torch.manual_seed(seed)
501
+ if hasattr(torch, "cuda") and torch.cuda.is_available():
502
+ torch.cuda.manual_seed_all(seed)
503
+
504
+ with torch.no_grad():
505
+ output_ids = model.generate(
506
+ **inputs,
507
+ max_new_tokens=MAX_NEW_TOKENS,
508
+ do_sample=True,
509
+ temperature=generation_temperature(spice),
510
+ num_beams=1,
511
+ repetition_penalty=1.1,
512
+ pad_token_id=tokenizer.eos_token_id,
513
+ )
514
+ prompt_len = inputs["input_ids"].shape[-1]
515
+ generated_ids = output_ids[0][prompt_len:]
516
+ return tokenizer.decode(generated_ids, skip_special_tokens=True).strip(), status, runtime
517
+ except Exception as exc:
518
+ return None, f"model generation failed: {type(exc).__name__}: {exc}", runtime
519
+
520
+
521
+ def parse_loose_model_fields(raw_text: str) -> dict[str, str]:
522
+ fields: dict[str, str] = {}
523
+ for field in ["trollsona_name", "troll_reply", "useful_advice", "cringe_score_label"]:
524
+ pattern = rf'"{field}"\s*:\s*"((?:\\.|[^"\\])*)'
525
+ match = re.search(pattern, raw_text or "", flags=re.DOTALL)
526
+ if not match:
527
+ continue
528
+ try:
529
+ value = json.loads(f'"{match.group(1)}"')
530
+ except json.JSONDecodeError:
531
+ value = match.group(1)
532
+ fields[field] = str(value)
533
+ return fields
534
+
535
+
536
+ def coerce_model_result(
537
+ parsed: dict[str, Any],
538
+ fallback: dict[str, Any],
539
+ score: int,
540
+ include_advice: bool,
541
+ fallback_reason: str,
542
+ runtime: str,
543
+ ) -> dict[str, Any] | None:
544
+ result = dict(fallback)
545
+ field_limits = {
546
+ "trollsona_name": 80,
547
+ "troll_reply": 360,
548
+ "useful_advice": 280,
549
+ "cringe_score_label": 80,
550
+ }
551
+ used_fields: list[str] = []
552
+ missing_fields: list[str] = []
553
+
554
+ for field, limit in field_limits.items():
555
+ value = clean_text(parsed.get(field), limit)
556
+ if value and is_safe_text(value):
557
+ result[field] = value
558
+ used_fields.append(field)
559
+ else:
560
+ missing_fields.append(field)
561
+
562
+ if not used_fields:
563
+ return None
564
+
565
+ result["cringe_score"] = score
566
+ result["include_advice"] = include_advice
567
+ result["source"] = "transformers_model"
568
+ result["runtime"] = runtime
569
+ if missing_fields:
570
+ partial_reason = f"model output partial; fallback filled: {', '.join(missing_fields)}"
571
+ result["fallback_reason"] = (
572
+ f"{fallback_reason}; {partial_reason}" if fallback_reason else partial_reason
573
+ )
574
+ else:
575
+ result["fallback_reason"] = fallback_reason
576
+ return result
577
+
578
+
579
+ def parse_model_output(
580
+ raw_text: str,
581
+ fallback: dict[str, Any],
582
+ score: int,
583
+ include_advice: bool,
584
+ fallback_reason: str,
585
+ runtime: str,
586
+ ) -> dict[str, Any] | None:
587
+ decoder = json.JSONDecoder()
588
+ parsed = None
589
+ for match in re.finditer(r"\{", raw_text or ""):
590
+ try:
591
+ candidate, _ = decoder.raw_decode(raw_text[match.start() :])
592
+ except json.JSONDecodeError:
593
+ continue
594
+ if isinstance(candidate, dict):
595
+ parsed = candidate
596
+ break
597
+
598
+ if parsed is None:
599
+ parsed = parse_loose_model_fields(raw_text)
600
+
601
+ return coerce_model_result(parsed, fallback, score, include_advice, fallback_reason, runtime)
602
+
603
+
604
+ def repair_model_output(
605
+ raw_text: str,
606
+ fallback: dict[str, Any],
607
+ fallback_reason: str,
608
+ runtime: str,
609
+ ) -> dict[str, Any] | None:
610
+ repaired_reply = clean_text(raw_text, 360)
611
+ repaired_reply = re.sub(r"^```(?:json)?|```$", "", repaired_reply).strip()
612
+ if not repaired_reply or repaired_reply.startswith("{"):
613
+ return None
614
+ if not is_safe_text(repaired_reply):
615
+ return None
616
+
617
+ result = dict(fallback)
618
+ result["troll_reply"] = repaired_reply
619
+ result["source"] = "transformers_model_repaired"
620
+ result["runtime"] = runtime
621
+ repair_reason = "model output was not valid JSON and was repaired"
622
+ result["fallback_reason"] = f"{fallback_reason}; {repair_reason}" if fallback_reason else repair_reason
623
+ return result
624
+
625
+
626
+ def safety_guard(result: dict[str, Any], fallback: dict[str, Any]) -> dict[str, Any]:
627
+ fields = [
628
+ result.get("trollsona_name", ""),
629
+ result.get("troll_reply", ""),
630
+ result.get("useful_advice", ""),
631
+ result.get("cringe_score_label", ""),
632
+ ]
633
+ if not all(is_safe_text(str(field)) for field in fields):
634
+ guarded = dict(fallback)
635
+ guarded["troll_reply"] = SAFE_REPLY
636
+ guarded["useful_advice"] = SAFE_ADVICE
637
+ guarded["fallback_reason"] = "safety guard replaced unsafe output"
638
+ return guarded
639
+ return result
640
+
641
+
642
+ def render_card(result: dict[str, Any]) -> str:
643
+ esc = {key: html.escape(str(value)) for key, value in result.items()}
644
+ score = max(0, min(100, int(result.get("cringe_score", 0))))
645
+ useful_advice = clean_text(result.get("useful_advice", ""), 280)
646
+ show_advice = bool(result.get("include_advice", True)) and bool(useful_advice)
647
+ advice_tile = (
648
+ f"""
649
+ <div class="trollsona-tile">
650
+ <div class="trollsona-label">A USEFUL SLAP</div>
651
+ <div class="trollsona-value">{html.escape(useful_advice)}</div>
652
+ </div>
653
+ """.rstrip()
654
+ if show_advice
655
+ else ""
656
+ )
657
+ grid_class = "trollsona-grid" if show_advice else "trollsona-grid trollsona-grid-single"
658
+ return f"""
659
+ <div class="trollsona-card">
660
+ <div class="dossier-kicker">THE SUMMONED MENACE</div>
661
+ <h2>{esc["trollsona_name"]}</h2>
662
+ <div class="trollsona-mainline">{esc["troll_reply"]}</div>
663
+ <div class="{grid_class}">
664
+ {advice_tile}
665
+ <div class="trollsona-tile">
666
+ <div class="trollsona-label">GOBLIN METER</div>
667
+ <div class="meter-shell" aria-label="Goblin meter {score} out of 100">
668
+ <div class="meter-fill" style="width: {score}%"></div>
669
+ </div>
670
+ <div class="trollsona-value">{score}/100 - {esc["cringe_score_label"]}</div>
671
+ </div>
672
+ </div>
673
+ </div>
674
+ """.strip()
675
+
676
+
677
+ def render_cursed_paperwork(result: dict[str, Any]) -> str:
678
+ source = clean_text(result.get("source", "unknown"), 80)
679
+ runtime = clean_text(result.get("runtime", "runtime unavailable"), 260)
680
+ fallback_reason = clean_text(result.get("fallback_reason", ""), 180)
681
+ if not fallback_reason:
682
+ fallback_reason = "No fallback note."
683
+ return (
684
+ f"**Source:** `{source}` \n"
685
+ f"**Runtime:** `{runtime}` \n"
686
+ f"**Fallback note:** {fallback_reason}"
687
+ )
688
+
689
+
690
+ def render_empty_card() -> str:
691
+ return """
692
+ <div class="empty-dossier">
693
+ <div class="dossier-kicker">The dossier is sealed</div>
694
+ <h2>No menace has signed the paperwork yet.</h2>
695
+ <p>Feed the booth a little lore, pick a resident menace, and pull the handle.</p>
696
+ </div>
697
+ """.strip()
698
+
699
+
700
+ def load_preset(index: int) -> tuple[str, str, str, int, bool]:
701
+ return PRESET_DOSSIERS[index]["values"]
702
+
703
+
704
+ def generate_trollsona(
705
+ user_name: str,
706
+ profile: str,
707
+ persona: str,
708
+ spice: int,
709
+ include_advice: bool,
710
+ ) -> tuple[str, dict[str, Any], str]:
711
+ user_name = clean_text(user_name, MAX_NAME_CHARS)
712
+ profile = clean_text(profile, MAX_PROFILE_CHARS)
713
+ persona = persona if persona in PERSONA_STYLES else "Forest Heckler"
714
+ spice = clamp_spice(spice)
715
+ include_advice = bool(include_advice)
716
+
717
+ fallback = fallback_trollsona(
718
+ user_name=user_name,
719
+ profile=profile,
720
+ persona=persona,
721
+ spice=spice,
722
+ include_advice=include_advice,
723
+ reason="model unavailable or output invalid",
724
+ )
725
+
726
+ score = compute_cringe_score(profile, persona, spice)
727
+ prompt = build_prompt(user_name, profile, persona, spice, include_advice, score)
728
+ raw_text, model_status, runtime = generate_with_model(prompt, spice)
729
+ model_fallback_reason = "" if model_status == "model loaded" else model_status
730
+
731
+ result = None
732
+ if raw_text:
733
+ result = parse_model_output(
734
+ raw_text=raw_text,
735
+ fallback=fallback,
736
+ score=score,
737
+ include_advice=include_advice,
738
+ fallback_reason=model_fallback_reason,
739
+ runtime=runtime,
740
+ )
741
+ if result is None:
742
+ result = repair_model_output(raw_text, fallback, model_fallback_reason, runtime)
743
+
744
+ if result is None:
745
+ result = dict(fallback)
746
+ result["fallback_reason"] = model_status
747
+ result["runtime"] = runtime
748
+
749
+ result = safety_guard(result, fallback)
750
+ return render_card(result), result, render_cursed_paperwork(result)
751
+
752
+
753
+ def build_demo() -> Any:
754
+ import gradio as gr
755
+
756
+ css = ""
757
+ css_path = os.path.join(os.path.dirname(__file__), "assets", "style.css")
758
+ if os.path.exists(css_path):
759
+ with open(css_path, "r", encoding="utf-8") as handle:
760
+ css = handle.read()
761
+
762
+ with gr.Blocks(title=APP_TITLE, css=css) as demo:
763
+ gr.HTML(
764
+ f"""
765
+ <section class="ritual-hero">
766
+ <div class="hero-mark">Trollsona</div>
767
+ <h1>{APP_TITLE}</h1>
768
+ <p>{APP_SUBTITLE}</p>
769
+ <div class="badge-row">
770
+ <span>Build Small Hackathon</span>
771
+ <span>Small model</span>
772
+ <span>Safe grotesque humor</span>
773
+ <span>{TRACK_NAME}</span>
774
+ </div>
775
+ </section>
776
+ """.strip()
777
+ )
778
+
779
+ with gr.Row(elem_classes=["ritual-layout"]):
780
+ with gr.Column(scale=1, elem_classes=["summoning-panel"]):
781
+ gr.HTML('<div class="panel-heading">The summoning booth</div>')
782
+ user_name = gr.Textbox(
783
+ label="What do they call you?",
784
+ placeholder="Mira",
785
+ max_lines=1,
786
+ )
787
+ profile = gr.Textbox(
788
+ label="Confess your little lore",
789
+ placeholder="I overbuild side projects, drink too much coffee, and love weird UI.",
790
+ lines=5,
791
+ max_lines=7,
792
+ )
793
+ persona = gr.Dropdown(
794
+ label="Pick your resident menace",
795
+ choices=list(PERSONA_STYLES.keys()),
796
+ value="Back-Alley Oracle",
797
+ )
798
+ spice = gr.Slider(
799
+ label="How hard should it sting?",
800
+ minimum=1,
801
+ maximum=5,
802
+ value=3,
803
+ step=1,
804
+ )
805
+ include_advice = gr.Checkbox(label="Slip in one useful truth", value=True)
806
+ generate_button = gr.Button("Summon Trollsona", variant="primary")
807
+
808
+ with gr.Column(scale=1, elem_classes=["dossier-stage"]):
809
+ card_output = gr.HTML(value=render_empty_card())
810
+ debug_state = gr.State()
811
+ with gr.Accordion("See the cursed paperwork", open=False):
812
+ debug_output = gr.Markdown(
813
+ value=(
814
+ "**Source:** `not summoned` \n"
815
+ "**Runtime:** `not summoned` \n"
816
+ "**Fallback note:** The dossier clerk is still asleep."
817
+ )
818
+ )
819
+
820
+ generate_button.click(
821
+ fn=generate_trollsona,
822
+ inputs=[user_name, profile, persona, spice, include_advice],
823
+ outputs=[card_output, debug_state, debug_output],
824
+ )
825
+
826
+ with gr.Accordion("Stolen dossiers", open=False):
827
+ gr.HTML('<div class="preset-note">Tap a stolen dossier to pre-fill the booth.</div>')
828
+ with gr.Row(elem_classes=["preset-row"]):
829
+ for preset_index, preset in enumerate(PRESET_DOSSIERS):
830
+ preset_button = gr.Button(
831
+ preset["button"],
832
+ variant="secondary",
833
+ elem_classes=["preset-card"],
834
+ )
835
+ preset_button.click(
836
+ fn=lambda index=preset_index: load_preset(index),
837
+ inputs=[],
838
+ outputs=[user_name, profile, persona, spice, include_advice],
839
+ )
840
+
841
+ return demo
842
+
843
+
844
+ demo = None if parse_bool_env("TROLLSONA_SKIP_UI_BUILD", default=False) else build_demo()
845
+
846
+
847
+ if __name__ == "__main__":
848
+ (demo or build_demo()).launch()
assets/style.css ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --ink: #130f0c;
3
+ --coal: #1d1712;
4
+ --coal-soft: #2b2118;
5
+ --parchment: #efe0bf;
6
+ --paper: #f8edcf;
7
+ --paper-deep: #d7bd86;
8
+ --brass: #c68633;
9
+ --ember: #b44a24;
10
+ --olive: #55633f;
11
+ --bone: #fff7df;
12
+ --muted: #bda985;
13
+ }
14
+
15
+ body,
16
+ .gradio-container {
17
+ background: var(--ink) !important;
18
+ color: var(--bone) !important;
19
+ }
20
+
21
+ .gradio-container {
22
+ width: min(100%, 1180px) !important;
23
+ max-width: 1180px !important;
24
+ min-height: 100vh;
25
+ margin: 0 auto !important;
26
+ padding: 24px !important;
27
+ --body-background-fill: var(--ink);
28
+ --body-text-color: var(--bone);
29
+ --background-fill-primary: var(--coal);
30
+ --background-fill-secondary: var(--coal-soft);
31
+ --border-color-primary: rgba(215, 189, 134, 0.34);
32
+ --block-background-fill: var(--coal-soft);
33
+ --block-border-color: rgba(215, 189, 134, 0.34);
34
+ --block-label-background-fill: transparent;
35
+ --block-label-border-color: transparent;
36
+ --block-label-text-color: var(--paper);
37
+ --block-info-text-color: var(--muted);
38
+ --input-background-fill: #251d15;
39
+ --input-border-color: rgba(215, 189, 134, 0.48);
40
+ --input-placeholder-color: rgba(255, 247, 223, 0.52);
41
+ --body-text-color-subdued: var(--muted);
42
+ --button-primary-background-fill: var(--ember);
43
+ --button-primary-background-fill-hover: #c9572b;
44
+ --button-primary-text-color: var(--bone);
45
+ --button-secondary-background-fill: var(--coal-soft);
46
+ --button-secondary-background-fill-hover: #35291e;
47
+ --button-secondary-text-color: var(--paper);
48
+ }
49
+
50
+ .ritual-hero {
51
+ max-width: 100%;
52
+ border: 1px solid rgba(198, 134, 51, 0.45);
53
+ border-radius: 8px;
54
+ background:
55
+ linear-gradient(135deg, rgba(29, 23, 18, 0.98), rgba(43, 33, 24, 0.98)),
56
+ repeating-linear-gradient(90deg, rgba(255, 247, 223, 0.04) 0, rgba(255, 247, 223, 0.04) 1px, transparent 1px, transparent 9px);
57
+ padding: 28px;
58
+ box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28);
59
+ }
60
+
61
+ .hero-mark,
62
+ .dossier-kicker,
63
+ .panel-heading,
64
+ .trollsona-label {
65
+ color: var(--brass);
66
+ font-size: 0.78rem;
67
+ font-weight: 800;
68
+ letter-spacing: 0;
69
+ text-transform: uppercase;
70
+ }
71
+
72
+ .ritual-hero h1 {
73
+ color: var(--bone);
74
+ font-size: 3rem;
75
+ line-height: 1;
76
+ margin: 10px 0 8px;
77
+ }
78
+
79
+ .ritual-hero p {
80
+ color: var(--paper);
81
+ font-size: 1.12rem;
82
+ line-height: 1.45;
83
+ margin: 0;
84
+ max-width: 620px;
85
+ }
86
+
87
+ .badge-row {
88
+ display: flex;
89
+ flex-wrap: wrap;
90
+ gap: 8px;
91
+ margin-top: 18px;
92
+ }
93
+
94
+ .badge-row span {
95
+ border: 1px solid rgba(198, 134, 51, 0.48);
96
+ border-radius: 999px;
97
+ background: rgba(85, 99, 63, 0.28);
98
+ color: var(--paper);
99
+ padding: 6px 10px;
100
+ font-size: 0.82rem;
101
+ line-height: 1;
102
+ }
103
+
104
+ .ritual-layout {
105
+ display: grid !important;
106
+ grid-template-columns: minmax(360px, 0.92fr) minmax(420px, 1.08fr);
107
+ align-items: stretch;
108
+ gap: 20px;
109
+ margin: 20px auto 0;
110
+ width: 100%;
111
+ }
112
+
113
+ .summoning-panel,
114
+ .dossier-stage {
115
+ border: 1px solid rgba(215, 189, 134, 0.36);
116
+ border-radius: 8px;
117
+ background: rgba(29, 23, 18, 0.94);
118
+ min-width: 0;
119
+ padding: 16px;
120
+ }
121
+
122
+ .summoning-panel {
123
+ box-shadow: inset 0 0 0 1px rgba(255, 247, 223, 0.04);
124
+ }
125
+
126
+ .panel-heading {
127
+ margin: 0 0 14px;
128
+ }
129
+
130
+ .summoning-panel > *,
131
+ .dossier-stage > * {
132
+ background: transparent !important;
133
+ }
134
+
135
+ .summoning-panel .block,
136
+ .summoning-panel .form,
137
+ .summoning-panel .gr-form,
138
+ .summoning-panel .wrap,
139
+ .summoning-panel .container,
140
+ .summoning-panel .input-container,
141
+ .summoning-panel .prose,
142
+ .dossier-stage .block,
143
+ .dossier-stage .form,
144
+ .dossier-stage .wrap,
145
+ .dossier-stage .container,
146
+ .dossier-stage .input-container {
147
+ border-color: rgba(215, 189, 134, 0.36) !important;
148
+ background: var(--coal-soft) !important;
149
+ color: var(--bone) !important;
150
+ }
151
+
152
+ .summoning-panel label,
153
+ .summoning-panel .label-wrap,
154
+ .summoning-panel [data-testid="block-label"],
155
+ .summoning-panel [data-testid="block-info"],
156
+ .summoning-panel span,
157
+ .dossier-stage label,
158
+ .dossier-stage .label-wrap,
159
+ .dossier-stage [data-testid="block-label"] {
160
+ color: var(--paper) !important;
161
+ }
162
+
163
+ .summoning-panel label,
164
+ .summoning-panel [data-testid="block-label"] {
165
+ font-weight: 700 !important;
166
+ }
167
+
168
+ .summoning-panel input,
169
+ .summoning-panel textarea,
170
+ .summoning-panel select,
171
+ .summoning-panel [role="listbox"],
172
+ .summoning-panel [role="combobox"],
173
+ .summoning-panel .dropdown,
174
+ .summoning-panel .wrap input,
175
+ .summoning-panel .wrap textarea {
176
+ border-color: rgba(215, 189, 134, 0.45) !important;
177
+ background: #251d15 !important;
178
+ color: var(--bone) !important;
179
+ box-shadow: none !important;
180
+ }
181
+
182
+ .summoning-panel input::placeholder,
183
+ .summoning-panel textarea::placeholder {
184
+ color: rgba(255, 247, 223, 0.48) !important;
185
+ }
186
+
187
+ .summoning-panel input:focus,
188
+ .summoning-panel textarea:focus,
189
+ .summoning-panel [role="listbox"]:focus,
190
+ .summoning-panel [role="combobox"]:focus {
191
+ border-color: var(--brass) !important;
192
+ outline: 2px solid rgba(198, 134, 51, 0.28) !important;
193
+ }
194
+
195
+ .summoning-panel input[type="range"] {
196
+ accent-color: var(--ember);
197
+ background: transparent !important;
198
+ }
199
+
200
+ .summoning-panel input[type="number"] {
201
+ min-width: 62px;
202
+ text-align: center;
203
+ }
204
+
205
+ .summoning-panel input[type="checkbox"] {
206
+ accent-color: var(--ember);
207
+ border: 1px solid rgba(215, 189, 134, 0.55) !important;
208
+ background: #251d15 !important;
209
+ }
210
+
211
+ .summoning-panel input[type="checkbox"]:checked {
212
+ background: var(--ember) !important;
213
+ }
214
+
215
+ .summoning-panel button,
216
+ button.primary,
217
+ .gradio-container button.primary {
218
+ border: 1px solid rgba(255, 247, 223, 0.28) !important;
219
+ border-radius: 8px !important;
220
+ background: var(--ember) !important;
221
+ color: var(--bone) !important;
222
+ font-weight: 800 !important;
223
+ box-shadow: 0 10px 28px rgba(180, 74, 36, 0.22);
224
+ }
225
+
226
+ .summoning-panel button:hover,
227
+ button.primary:hover,
228
+ .gradio-container button.primary:hover {
229
+ background: #c9572b !important;
230
+ }
231
+
232
+ .empty-dossier,
233
+ .trollsona-card {
234
+ border: 1px solid rgba(92, 65, 35, 0.45);
235
+ border-radius: 8px;
236
+ color: var(--coal);
237
+ min-height: 360px;
238
+ padding: 24px;
239
+ box-shadow: 0 22px 60px rgba(0, 0, 0, 0.32);
240
+ }
241
+
242
+ .trollsona-card {
243
+ background:
244
+ linear-gradient(180deg, rgba(248, 237, 207, 0.98), rgba(239, 224, 191, 0.98)),
245
+ repeating-linear-gradient(0deg, rgba(92, 65, 35, 0.06) 0, rgba(92, 65, 35, 0.06) 1px, transparent 1px, transparent 10px);
246
+ }
247
+
248
+ .empty-dossier {
249
+ display: flex;
250
+ flex-direction: column;
251
+ justify-content: center;
252
+ background:
253
+ linear-gradient(180deg, rgba(219, 194, 139, 0.98), rgba(190, 151, 84, 0.96)),
254
+ repeating-linear-gradient(0deg, rgba(43, 33, 24, 0.08) 0, rgba(43, 33, 24, 0.08) 1px, transparent 1px, transparent 11px);
255
+ box-shadow:
256
+ inset 0 0 0 1px rgba(255, 247, 223, 0.2),
257
+ 0 22px 60px rgba(0, 0, 0, 0.32);
258
+ }
259
+
260
+ .empty-dossier h2,
261
+ .trollsona-card h2 {
262
+ color: var(--coal);
263
+ font-size: 2rem;
264
+ line-height: 1.1;
265
+ margin: 10px 0 14px;
266
+ overflow-wrap: anywhere;
267
+ }
268
+
269
+ .empty-dossier p {
270
+ color: #281b12;
271
+ font-size: 1.03rem;
272
+ font-weight: 650;
273
+ line-height: 1.52;
274
+ margin: 0;
275
+ max-width: 360px;
276
+ }
277
+
278
+ .trollsona-mainline {
279
+ border-left: 4px solid var(--ember);
280
+ color: #35271b;
281
+ font-size: 1.08rem;
282
+ font-weight: 750;
283
+ line-height: 1.48;
284
+ margin: 0 0 18px;
285
+ padding-left: 14px;
286
+ overflow-wrap: anywhere;
287
+ }
288
+
289
+ .trollsona-grid {
290
+ display: grid;
291
+ grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
292
+ gap: 12px;
293
+ margin-top: 16px;
294
+ }
295
+
296
+ .trollsona-grid-single {
297
+ grid-template-columns: 1fr;
298
+ }
299
+
300
+ .trollsona-tile {
301
+ border: 1px solid rgba(92, 65, 35, 0.22);
302
+ border-radius: 8px;
303
+ background: rgba(255, 247, 223, 0.64);
304
+ padding: 14px;
305
+ }
306
+
307
+ .trollsona-value {
308
+ color: #3f3020;
309
+ font-size: 1rem;
310
+ line-height: 1.42;
311
+ margin-top: 8px;
312
+ overflow-wrap: anywhere;
313
+ }
314
+
315
+ .meter-shell {
316
+ border: 1px solid rgba(92, 65, 35, 0.28);
317
+ border-radius: 999px;
318
+ background: rgba(29, 23, 18, 0.16);
319
+ height: 12px;
320
+ margin-top: 10px;
321
+ overflow: hidden;
322
+ }
323
+
324
+ .meter-fill {
325
+ background: linear-gradient(90deg, var(--olive), var(--brass), var(--ember));
326
+ height: 100%;
327
+ }
328
+
329
+ .gradio-container details,
330
+ .gradio-container .accordion,
331
+ .gradio-container button[aria-expanded],
332
+ .gradio-container button.label-wrap,
333
+ .dossier-stage button,
334
+ .gradio-container .secondary {
335
+ border-color: rgba(215, 189, 134, 0.32) !important;
336
+ background: rgba(29, 23, 18, 0.82) !important;
337
+ color: var(--paper) !important;
338
+ }
339
+
340
+ .gradio-container details summary,
341
+ .gradio-container button[aria-expanded] span,
342
+ .gradio-container button[aria-expanded],
343
+ .gradio-container button.label-wrap,
344
+ .gradio-container button.label-wrap *,
345
+ .gradio-container button.label-wrap span {
346
+ color: var(--paper) !important;
347
+ }
348
+
349
+ .gradio-container button.label-wrap svg,
350
+ .gradio-container button.label-wrap path {
351
+ color: var(--paper) !important;
352
+ fill: var(--paper) !important;
353
+ stroke: var(--paper) !important;
354
+ }
355
+
356
+ .preset-note {
357
+ color: var(--paper);
358
+ font-size: 0.94rem;
359
+ line-height: 1.35;
360
+ margin: 2px 0 12px;
361
+ }
362
+
363
+ .preset-row {
364
+ display: grid !important;
365
+ grid-template-columns: repeat(3, minmax(0, 1fr));
366
+ gap: 10px;
367
+ }
368
+
369
+ .preset-card,
370
+ .preset-card button,
371
+ .gradio-container .preset-card {
372
+ border: 1px solid rgba(215, 189, 134, 0.34) !important;
373
+ border-radius: 8px !important;
374
+ background:
375
+ linear-gradient(180deg, rgba(43, 33, 24, 0.96), rgba(29, 23, 18, 0.96)) !important;
376
+ color: var(--paper) !important;
377
+ font-weight: 800 !important;
378
+ line-height: 1.25 !important;
379
+ min-height: 78px !important;
380
+ text-align: left !important;
381
+ white-space: normal !important;
382
+ box-shadow: inset 0 0 0 1px rgba(255, 247, 223, 0.04) !important;
383
+ }
384
+
385
+ .preset-card:hover,
386
+ .preset-card button:hover,
387
+ .gradio-container .preset-card:hover {
388
+ border-color: rgba(198, 134, 51, 0.72) !important;
389
+ background:
390
+ linear-gradient(180deg, rgba(62, 45, 31, 0.98), rgba(37, 29, 21, 0.98)) !important;
391
+ }
392
+
393
+ .gradio-container table,
394
+ .gradio-container th,
395
+ .gradio-container td {
396
+ border-color: rgba(215, 189, 134, 0.24) !important;
397
+ background: var(--coal-soft) !important;
398
+ color: var(--paper) !important;
399
+ }
400
+
401
+ .dossier-stage .prose,
402
+ .dossier-stage .prose p,
403
+ .dossier-stage .prose strong,
404
+ .dossier-stage .prose code {
405
+ color: var(--paper) !important;
406
+ }
407
+
408
+ .dossier-stage .prose code {
409
+ border: 1px solid rgba(215, 189, 134, 0.26);
410
+ border-radius: 4px;
411
+ background: #251d15;
412
+ padding: 2px 5px;
413
+ }
414
+
415
+ .gradio-container footer {
416
+ display: none !important;
417
+ }
418
+
419
+ @media (max-width: 760px) {
420
+ .gradio-container {
421
+ padding: 14px !important;
422
+ }
423
+
424
+ .ritual-layout {
425
+ grid-template-columns: 1fr;
426
+ gap: 14px;
427
+ }
428
+
429
+ .preset-row {
430
+ grid-template-columns: 1fr;
431
+ }
432
+
433
+ .ritual-hero {
434
+ padding: 20px;
435
+ }
436
+
437
+ .ritual-hero h1 {
438
+ font-size: 2.35rem;
439
+ }
440
+
441
+ .empty-dossier,
442
+ .trollsona-card {
443
+ min-height: 300px;
444
+ padding: 18px;
445
+ }
446
+ }
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio>=4.44,<6
2
+ transformers>=4.44,<5
3
+ torch>=2.2
4
+ accelerate>=0.33