10Pratibh commited on
Commit
444b37e
Β·
1 Parent(s): b2766be

Bridge Troll: prompt-first playable loop on Qwen2.5-7B

Browse files
Files changed (4) hide show
  1. app.py +131 -0
  2. models.py +101 -0
  3. requirements.txt +6 -0
  4. troll_engine.py +183 -0
app.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bridge Troll β€” Gradio app (Weekend-1, prompt-first playable loop).
2
+
3
+ Run locally with no GPU / no download:
4
+ BRIDGE_TROLL_MOCK=1 python app.py
5
+
6
+ Run the real model (GPU; on a ZeroGPU Space this is automatic):
7
+ python app.py
8
+
9
+ This is the FUNCTIONAL pass. The hand-drawn woodcut UI + win animation come in
10
+ the polish phase (Weekend 2) via gr.Server / custom CSS for the Off-Brand badge.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import gradio as gr
16
+
17
+ from troll_engine import GameState, START_RESOLVE, build_messages, parse_judgment
18
+ from models import get_backend
19
+
20
+ # ZeroGPU decorator β€” no-op locally so the same file runs anywhere.
21
+ # Supports both @gpu and @gpu(duration=...).
22
+ try:
23
+ import spaces
24
+
25
+ gpu = spaces.GPU
26
+ except Exception: # not on a Space / spaces not installed
27
+
28
+ def gpu(*args, **_kwargs):
29
+ if args and callable(args[0]):
30
+ return args[0]
31
+ return lambda fn: fn
32
+
33
+
34
+ _backend = get_backend()
35
+
36
+
37
+ # Declared duration must cover worst-case generation but stay tight: ZeroGPU
38
+ # pre-checks it against remaining daily quota, and a smaller value queues faster.
39
+ @gpu(duration=30)
40
+ def _generate(messages: list[dict]) -> str:
41
+ return _backend.generate(messages)
42
+
43
+
44
+ INTRO = (
45
+ "A mossy troll heaves himself upright across the only bridge over the Mirebeck. "
46
+ "*\"None cross Gorm's bridge for free, traveller. Give me a reason β€” a *good* one.\"*"
47
+ )
48
+
49
+
50
+ def _meter_html(resolve: int, won: bool) -> str:
51
+ pct = max(0, min(100, round(resolve / START_RESOLVE * 100)))
52
+ if won:
53
+ return (
54
+ "<div class='resolve-wrap'><div class='resolve-label'>GORM HAS STEPPED ASIDE πŸŒ‰</div>"
55
+ "<div class='resolve-bar'><div class='resolve-fill won' style='width:0%'></div></div></div>"
56
+ )
57
+ # green when his resolve is high, warming toward gold as it drops
58
+ hue = 90 + (1 - pct / 100) * 30 # 90 (green) -> 120ish; tweak in polish
59
+ return (
60
+ "<div class='resolve-wrap'>"
61
+ f"<div class='resolve-label'>Gorm's Resolve β€” {resolve}</div>"
62
+ f"<div class='resolve-bar'><div class='resolve-fill' "
63
+ f"style='width:{pct}%;background:hsl({hue},55%,42%)'></div></div></div>"
64
+ )
65
+
66
+
67
+ def on_submit(user_text: str, chat: list, state: GameState):
68
+ user_text = (user_text or "").strip()
69
+ if not user_text or state.won:
70
+ return chat, state, _meter_html(state.resolve, state.won), "", gr.update()
71
+
72
+ messages = build_messages(state, user_text)
73
+ raw = _generate(messages)
74
+ j = parse_judgment(raw)
75
+
76
+ state.history.append({"role": "user", "content": user_text})
77
+ state.history.append({"role": "assistant", "content": j.reply})
78
+ state.apply(j)
79
+
80
+ chat = chat + [
81
+ {"role": "user", "content": user_text},
82
+ {"role": "assistant", "content": j.reply},
83
+ ]
84
+ why = f"*{j.tactic.value}* Β· {j.reason}" + (f" Β· persuasiveness {j.persuasiveness}/5"
85
+ if j.tactic.value == "genuine" else "")
86
+ if state.won:
87
+ why = "πŸŒ‰ You crossed. " + why
88
+ # disable input on win
89
+ box_update = gr.update(interactive=not state.won,
90
+ placeholder="The bridge is yours." if state.won else "Speak to Gorm…")
91
+ return chat, state, _meter_html(state.resolve, state.won), why, box_update
92
+
93
+
94
+ def on_reset():
95
+ state = GameState()
96
+ chat = [{"role": "assistant", "content": INTRO}]
97
+ return (chat, state, _meter_html(state.resolve, False), "",
98
+ gr.update(interactive=True, value="", placeholder="Speak to Gorm…"))
99
+
100
+
101
+ CSS = """
102
+ .resolve-wrap { margin: 6px 0 14px; }
103
+ .resolve-label { font-family: Georgia, serif; font-size: 14px; letter-spacing:.04em; margin-bottom:4px; }
104
+ .resolve-bar { height: 16px; background:#2a2118; border:1px solid #5a4a32; border-radius:9px; overflow:hidden; }
105
+ .resolve-fill { height:100%; transition: width .5s ease, background .5s ease; }
106
+ .resolve-fill.won { background:#caa54a; }
107
+ #why { font-family: Georgia, serif; opacity:.8; min-height:1.4em; }
108
+ """
109
+
110
+ with gr.Blocks(title="Bridge Troll") as demo:
111
+ gr.Markdown("## πŸ§ŒπŸŒ‰ Bridge Troll\n*Talk your way across β€” if your argument is actually good.*")
112
+ meter = gr.HTML(_meter_html(START_RESOLVE, False))
113
+ chatbot = gr.Chatbot(value=[{"role": "assistant", "content": INTRO}],
114
+ height=420, show_label=False)
115
+ why = gr.Markdown("", elem_id="why")
116
+ with gr.Row():
117
+ box = gr.Textbox(placeholder="Speak to Gorm…", show_label=False, scale=8, autofocus=True)
118
+ send = gr.Button("Say it", variant="primary", scale=1)
119
+ reset = gr.Button("New traveller", size="sm")
120
+
121
+ state = gr.State(GameState())
122
+
123
+ send.click(on_submit, [box, chatbot, state], [chatbot, state, meter, why, box]).then(
124
+ lambda: "", None, box)
125
+ box.submit(on_submit, [box, chatbot, state], [chatbot, state, meter, why, box]).then(
126
+ lambda: "", None, box)
127
+ reset.click(on_reset, None, [chatbot, state, meter, why, box])
128
+
129
+
130
+ if __name__ == "__main__":
131
+ demo.launch(css=CSS, theme=gr.themes.Soft())
models.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model backends for Bridge Troll.
2
+
3
+ Two interchangeable backends exposing the same `generate(messages) -> str`:
4
+
5
+ * TransformersTroll β€” Qwen2.5-7B-Instruct. Loaded EAGERLY at construction so it
6
+ follows the ZeroGPU rule (instantiate at module scope,
7
+ .to('cuda') eagerly; ZeroGPU maps the device transparently).
8
+ * MockTroll β€” deterministic canned judgments. No GPU, no download.
9
+ Lets you build and test the whole game loop + UI instantly.
10
+
11
+ Device selection:
12
+ * On a Hugging Face Space (env SPACE_ID is set) -> force 'cuda' and load eagerly.
13
+ Do NOT gate on torch.cuda.is_available(): on ZeroGPU it reports False at import
14
+ time, so gating would wrongly pin the model to CPU.
15
+ * Locally -> mps on Apple Silicon, else cpu. (You should NOT run the real model
16
+ on your laptop for real play β€” it's a ~15GB download and slow. Use mock locally;
17
+ run the real model on the Space.)
18
+
19
+ Swap to a fine-tuned checkpoint by changing BRIDGE_TROLL_MODEL. Swap to llama.cpp
20
+ later by writing a third backend with the same `.generate` signature.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+
28
+ MODEL_ID = os.environ.get("BRIDGE_TROLL_MODEL", "Qwen/Qwen2.5-7B-Instruct")
29
+ ON_SPACE = bool(os.environ.get("SPACE_ID")) # HF sets this when running on a Space
30
+
31
+
32
+ class MockTroll:
33
+ """Heuristic stand-in so the loop is testable with zero infra. NOT the game's
34
+ intelligence β€” just scaffolding so the UI/meter are exercised before the real
35
+ model is wired in."""
36
+
37
+ _FLATTERY = ("great", "wonderful", "amazing", "best", "handsome", "wise", "kind troll")
38
+ _THREAT = ("kill", "destroy", "smash", "burn", "or else", "make you", "force")
39
+ _MANIP = ("the king sent", "i am your", "you must", "it is the law", "actually you")
40
+
41
+ def generate(self, messages: list[dict]) -> str:
42
+ last = messages[-1]["content"].lower()
43
+ if any(w in last for w in self._THREAT):
44
+ tactic, p, reason, reply = "threat", 0, "tried to scare me", "Threats? Three hundred years of them. Cross elsewhere."
45
+ elif any(w in last for w in self._MANIP):
46
+ tactic, p, reason, reply = "manipulation", 0, "false authority", "I smell a lie under that fine talk. No."
47
+ elif any(w in last for w in self._FLATTERY):
48
+ tactic, p, reason, reply = "flattery", 0, "buttering me up", "Flattery slides off moss, traveller."
49
+ elif "please" in last or "need" in last or "family" in last or "sick" in last:
50
+ tactic, p, reason, reply = "genuine", 3, "a real appeal", "Hm. You speak plainly, at least. Go on."
51
+ else:
52
+ tactic, p, reason, reply = "smalltalk", 0, "no real argument", "Pleasant. Irrelevant. The bridge stays shut."
53
+ return json.dumps({"tactic": tactic, "persuasiveness": p, "reason": reason, "reply": reply})
54
+
55
+
56
+ def _local_device():
57
+ import torch
58
+ if torch.cuda.is_available():
59
+ return "cuda"
60
+ if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
61
+ return "mps"
62
+ return "cpu"
63
+
64
+
65
+ class TransformersTroll:
66
+ """Qwen2.5-7B via HF transformers. Loads eagerly at construction (module scope)."""
67
+
68
+ def __init__(self, model_id: str = MODEL_ID):
69
+ import torch
70
+ from transformers import AutoModelForCausalLM, AutoTokenizer
71
+
72
+ self.device = "cuda" if ON_SPACE else _local_device()
73
+ self.tokenizer = AutoTokenizer.from_pretrained(model_id)
74
+ self.model = AutoModelForCausalLM.from_pretrained(
75
+ model_id,
76
+ torch_dtype=torch.bfloat16,
77
+ ).to(self.device) # eager .to('cuda') on a Space β€” ZeroGPU handles mapping
78
+
79
+ def generate(self, messages: list[dict]) -> str:
80
+ import torch
81
+
82
+ inputs = self.tokenizer.apply_chat_template(
83
+ messages, add_generation_prompt=True, return_tensors="pt"
84
+ ).to(self.model.device)
85
+ with torch.no_grad():
86
+ out = self.model.generate(
87
+ inputs,
88
+ max_new_tokens=220,
89
+ do_sample=True,
90
+ temperature=0.7,
91
+ top_p=0.9,
92
+ pad_token_id=self.tokenizer.eos_token_id,
93
+ )
94
+ return self.tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True)
95
+
96
+
97
+ def get_backend():
98
+ """MockTroll when BRIDGE_TROLL_MOCK=1, else the real model (loads eagerly)."""
99
+ if os.environ.get("BRIDGE_TROLL_MOCK") == "1":
100
+ return MockTroll()
101
+ return TransformersTroll()
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=6.0
2
+ transformers>=4.45
3
+ torch
4
+ accelerate
5
+ sentencepiece
6
+ spaces
troll_engine.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bridge Troll β€” core engine.
2
+
3
+ Model-agnostic. Holds the troll's character, the judgment schema the model must
4
+ emit each turn, robust parsing, and the Resolve-meter bookkeeping.
5
+
6
+ Design principle: the MODEL makes the judgment (how persuasive a line is, and what
7
+ tactic it used). This module only does deterministic bookkeeping on top of that
8
+ judgment, so the AI stays load-bearing while the meter stays reliable.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from enum import Enum
17
+ from typing import Optional
18
+
19
+
20
+ # --------------------------------------------------------------------------- #
21
+ # Tuning knobs β€” this is the "fairness" surface you'll calibrate during the
22
+ # fine-tune. Keep them here so the eval harness can import and sweep them.
23
+ # --------------------------------------------------------------------------- #
24
+
25
+ START_RESOLVE: int = 100
26
+ WIN_AT: int = 0
27
+ MAX_RESOLVE: int = 130 # he can get *more* entrenched if you annoy him
28
+
29
+
30
+ class Tactic(str, Enum):
31
+ GENUINE = "genuine" # a real, good-faith argument
32
+ FLATTERY = "flattery" # buttering him up
33
+ THREAT = "threat" # intimidation
34
+ MANIPULATION = "manipulation" # trickery, lies, false premises
35
+ REPETITION = "repetition" # rephrasing something already tried
36
+ SMALLTALK = "smalltalk" # chit-chat, no argument at all
37
+
38
+
39
+ # How much each tactic moves Resolve. Negative = troll softens (good for player).
40
+ # `genuine` scales with persuasiveness; the rest are flat penalties/no-ops.
41
+ GENUINE_SCALE: dict[int, int] = {0: 0, 1: -2, 2: -6, 3: -12, 4: -20, 5: -30}
42
+
43
+ TACTIC_FLAT_DELTA: dict[Tactic, int] = {
44
+ Tactic.FLATTERY: +4, # offended, digs in a little
45
+ Tactic.THREAT: +10, # absolutely not
46
+ Tactic.MANIPULATION: +8, # he sees through it
47
+ Tactic.REPETITION: +5, # "you said that already"
48
+ Tactic.SMALLTALK: +1, # mildly stalls
49
+ }
50
+
51
+
52
+ @dataclass
53
+ class Judgment:
54
+ """What the model emits each turn."""
55
+ persuasiveness: int # 0..5, only meaningful for GENUINE
56
+ tactic: Tactic
57
+ reason: str # one short line β€” shown to the player as the "why"
58
+ reply: str # the troll's in-character dialogue
59
+
60
+ def resolve_delta(self) -> int:
61
+ if self.tactic is Tactic.GENUINE:
62
+ p = max(0, min(5, self.persuasiveness))
63
+ return GENUINE_SCALE[p]
64
+ return TACTIC_FLAT_DELTA.get(self.tactic, 0)
65
+
66
+
67
+ @dataclass
68
+ class GameState:
69
+ resolve: int = START_RESOLVE
70
+ turns: int = 0
71
+ won: bool = False
72
+ history: list[dict] = field(default_factory=list) # chat-template messages
73
+
74
+ def apply(self, j: Judgment) -> None:
75
+ self.resolve = max(0, min(MAX_RESOLVE, self.resolve + j.resolve_delta()))
76
+ self.turns += 1
77
+ if self.resolve <= WIN_AT:
78
+ self.won = True
79
+
80
+
81
+ # --------------------------------------------------------------------------- #
82
+ # The character + the rubric. During prompt-first this lives in the system
83
+ # prompt. After fine-tuning, the model internalises it and emits cleaner JSON.
84
+ # --------------------------------------------------------------------------- #
85
+
86
+ SYSTEM_PROMPT = """You are GORM, an old bridge troll. You have guarded the same \
87
+ mossy stone bridge over the Mirebeck for three hundred years. You are proud, \
88
+ gruff, and weary β€” but underneath it, lonely, and you secretly respect a clever \
89
+ or kind-hearted traveller. You will NOT move for flattery, threats, bribes, or \
90
+ trickery; those make you dig in. You step aside ONLY for an argument you find \
91
+ genuinely convincing or unexpectedly touching.
92
+
93
+ Every time the traveller speaks, you do TWO things:
94
+
95
+ 1. Judge their line honestly using this rubric.
96
+ - tactic β€” exactly one of:
97
+ "genuine" a real, good-faith argument or appeal
98
+ "flattery" compliments meant to win you over
99
+ "threat" intimidation or force
100
+ "manipulation" lies, tricks, false premises, fake authority
101
+ "repetition" something they have already tried, reworded
102
+ "smalltalk" chit-chat with no argument
103
+ - persuasiveness β€” an integer 0-5. ONLY for "genuine" lines; use 0 otherwise.
104
+ 0 weak/empty 1 thin 2 has a point 3 solid 4 strong 5 the kind of
105
+ thing that actually moves a three-hundred-year-old heart.
106
+ Be a tough but fair judge. Most lines are 1-2. A 4 or 5 is rare and earned.
107
+ Do not reward length or fancy words β€” reward genuine reasoning or feeling.
108
+
109
+ 2. Reply IN CHARACTER as Gorm β€” short (1-3 sentences), gruff, textured. React to
110
+ what they actually said. Never break character. Never mention the rubric, the
111
+ meter, or that you are an AI.
112
+
113
+ Respond with ONLY a single JSON object, nothing else, in this exact shape:
114
+ {"tactic": "...", "persuasiveness": 0, "reason": "<=10 words on why", "reply": "Gorm's words"}"""
115
+
116
+
117
+ def build_messages(state: GameState, user_text: str) -> list[dict]:
118
+ """Assemble chat-template messages for this turn."""
119
+ msgs: list[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
120
+ msgs.extend(state.history)
121
+ msgs.append({"role": "user", "content": user_text})
122
+ return msgs
123
+
124
+
125
+ # --------------------------------------------------------------------------- #
126
+ # Robust parsing β€” a prompted 7B will occasionally wrap JSON in prose or fences.
127
+ # Never crash the game on a bad parse; fall back to a neutral smalltalk judgment.
128
+ # --------------------------------------------------------------------------- #
129
+
130
+ _JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
131
+
132
+
133
+ def parse_judgment(raw: str) -> Judgment:
134
+ text = raw.strip()
135
+ # strip code fences if present
136
+ if text.startswith("```"):
137
+ text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.MULTILINE).strip()
138
+ match = _JSON_RE.search(text)
139
+ if match:
140
+ try:
141
+ obj = json.loads(match.group(0))
142
+ tactic = _coerce_tactic(obj.get("tactic"))
143
+ persuasiveness = _coerce_int(obj.get("persuasiveness"), 0, 0, 5)
144
+ reason = str(obj.get("reason", "")).strip()[:120]
145
+ reply = str(obj.get("reply", "")).strip() or _fallback_reply()
146
+ return Judgment(persuasiveness, tactic, reason, reply)
147
+ except (json.JSONDecodeError, ValueError, TypeError):
148
+ pass
149
+ # Total fallback: treat the raw text as the troll's reply, no progress.
150
+ return Judgment(0, Tactic.SMALLTALK, "unparseable judgment", text or _fallback_reply())
151
+
152
+
153
+ def _coerce_tactic(value) -> Tactic:
154
+ try:
155
+ return Tactic(str(value).strip().lower())
156
+ except ValueError:
157
+ return Tactic.SMALLTALK
158
+
159
+
160
+ def _coerce_int(value, default: int, lo: int, hi: int) -> int:
161
+ try:
162
+ return max(lo, min(hi, int(value)))
163
+ except (TypeError, ValueError):
164
+ return default
165
+
166
+
167
+ def _fallback_reply() -> str:
168
+ return "Gorm scratches his mossy chin and says nothing useful."
169
+
170
+
171
+ # --------------------------------------------------------------------------- #
172
+ # Convenience for the eval harness (Day 2): score a single line given a model fn.
173
+ # --------------------------------------------------------------------------- #
174
+
175
+ def play_turn(state: GameState, user_text: str, generate_fn) -> Judgment:
176
+ """generate_fn(messages: list[dict]) -> str (raw model output)."""
177
+ messages = build_messages(state, user_text)
178
+ raw = generate_fn(messages)
179
+ judgment = parse_judgment(raw)
180
+ state.history.append({"role": "user", "content": user_text})
181
+ state.history.append({"role": "assistant", "content": judgment.reply})
182
+ state.apply(judgment)
183
+ return judgment