Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- scripts/compare_witgym_zero_shot.py +110 -0
- witgym/config.py +1 -1
- witgym/engine.py +2 -2
- witgym/extractor.py +9 -8
- witgym/router.py +15 -2
scripts/compare_witgym_zero_shot.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""WitGym vs zero-shot baseline β same model, same API backend."""
|
| 2 |
+
import json
|
| 3 |
+
import sys
|
| 4 |
+
import time
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
load_dotenv()
|
| 10 |
+
|
| 11 |
+
# 7 diverse scenarios (5 canonical + 2 extra)
|
| 12 |
+
TEST_INPUTS = [
|
| 13 |
+
"I just got promoted to manager and I have no idea what I'm doing.",
|
| 14 |
+
"My coworker keeps stealing my lunch from the fridge.",
|
| 15 |
+
"I've been cc'd on an email chain I definitely should not be reading.",
|
| 16 |
+
"I'm pretending to understand cryptocurrency at dinner parties.",
|
| 17 |
+
"My therapist fell asleep during our session.",
|
| 18 |
+
"My boss called a quick sync that's been going for two hours.",
|
| 19 |
+
"I waved back at someone who wasn't waving at me.",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
ZERO_SHOT_PROMPT = """You are a witty friend. The user shares an awkward or funny situation.
|
| 23 |
+
Reply with ONE sharp, funny line. Maximum 2 sentences. No preamble.
|
| 24 |
+
|
| 25 |
+
User: {user_input}
|
| 26 |
+
|
| 27 |
+
Funny reply:"""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def zero_shot_reply(user_input: str, model, tokenizer) -> str:
|
| 31 |
+
from witgym.model import generate_text
|
| 32 |
+
|
| 33 |
+
prompt = ZERO_SHOT_PROMPT.format(user_input=user_input)
|
| 34 |
+
raw = generate_text(prompt, model, tokenizer, config_type="generate")
|
| 35 |
+
return raw.strip().strip('"')
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def run_experiment(index_path: str = "data/index.npz") -> dict:
|
| 39 |
+
import os
|
| 40 |
+
|
| 41 |
+
os.environ.setdefault("LLM_BACKEND", "hf_api")
|
| 42 |
+
|
| 43 |
+
from witgym import config
|
| 44 |
+
from witgym.engine import WitGymEngine
|
| 45 |
+
from witgym.model import load_model
|
| 46 |
+
|
| 47 |
+
if not Path(index_path).exists():
|
| 48 |
+
print(f"[ERROR] Index missing: {index_path}")
|
| 49 |
+
sys.exit(1)
|
| 50 |
+
|
| 51 |
+
print(f"Model: {config.LLM_MODEL_ID} | Backend: {config.LLM_BACKEND}")
|
| 52 |
+
print(f"Providers: {config.HF_INFERENCE_PROVIDERS}\n")
|
| 53 |
+
|
| 54 |
+
# Shared embedder/index only β fresh engine per input (no conversation bleed)
|
| 55 |
+
shared = None
|
| 56 |
+
model, tokenizer = load_model()
|
| 57 |
+
|
| 58 |
+
rows = []
|
| 59 |
+
for i, user_input in enumerate(TEST_INPUTS, 1):
|
| 60 |
+
print(f"[{i}/{len(TEST_INPUTS)}] {user_input[:65]}...")
|
| 61 |
+
|
| 62 |
+
from witgym.engine import WitGymEngine, SharedResources
|
| 63 |
+
|
| 64 |
+
nonlocal_shared = shared
|
| 65 |
+
if nonlocal_shared is None:
|
| 66 |
+
nonlocal_shared = SharedResources(index_path=index_path)
|
| 67 |
+
shared = nonlocal_shared
|
| 68 |
+
engine = WitGymEngine(index_path=index_path, resources=nonlocal_shared)
|
| 69 |
+
|
| 70 |
+
t0 = time.time()
|
| 71 |
+
wg = engine.respond(user_input)
|
| 72 |
+
wg_elapsed = time.time() - t0
|
| 73 |
+
|
| 74 |
+
t0 = time.time()
|
| 75 |
+
zs = zero_shot_reply(user_input, model, tokenizer)
|
| 76 |
+
zs_elapsed = time.time() - t0
|
| 77 |
+
|
| 78 |
+
row = {
|
| 79 |
+
"input": user_input,
|
| 80 |
+
"witgym": {
|
| 81 |
+
"text": wg.selected,
|
| 82 |
+
"words": len(wg.selected.split()),
|
| 83 |
+
"latency_s": round(wg_elapsed, 1),
|
| 84 |
+
"archetype": wg.metadata.archetype.value,
|
| 85 |
+
"candidates": [c.text for c in wg.candidates],
|
| 86 |
+
},
|
| 87 |
+
"zero_shot": {
|
| 88 |
+
"text": zs,
|
| 89 |
+
"words": len(zs.split()),
|
| 90 |
+
"latency_s": round(zs_elapsed, 1),
|
| 91 |
+
},
|
| 92 |
+
}
|
| 93 |
+
rows.append(row)
|
| 94 |
+
print(f" WitGym ({row['witgym']['words']}w, {wg_elapsed:.1f}s): {wg.selected}")
|
| 95 |
+
print(f" Zero ({row['zero_shot']['words']}w, {zs_elapsed:.1f}s): {zs}\n")
|
| 96 |
+
|
| 97 |
+
out = {
|
| 98 |
+
"model": config.LLM_MODEL_ID,
|
| 99 |
+
"backend": config.LLM_BACKEND,
|
| 100 |
+
"zero_shot_prompt": ZERO_SHOT_PROMPT,
|
| 101 |
+
"results": rows,
|
| 102 |
+
}
|
| 103 |
+
out_path = Path("data/eval_witgym_vs_zero_shot.json")
|
| 104 |
+
out_path.write_text(json.dumps(out, indent=2), encoding="utf-8")
|
| 105 |
+
print(f"Saved β {out_path}")
|
| 106 |
+
return out
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
if __name__ == "__main__":
|
| 110 |
+
run_experiment()
|
witgym/config.py
CHANGED
|
@@ -23,7 +23,7 @@ EXTRACT_TEMP = 0.2
|
|
| 23 |
EXTRACT_DO_SAMPLE = False
|
| 24 |
GENERATE_TEMP = 1.0 # slightly lower to reduce arbitrary domain invention
|
| 25 |
GENERATE_MIN_P = 0.06
|
| 26 |
-
GENERATE_MAX_NEW_TOKENS =
|
| 27 |
RANK_TEMP = 0.1
|
| 28 |
EXTRACT_MAX_NEW_TOKENS = 220 # JSON never exceeds ~150 tokens; 220 = 47% safety margin
|
| 29 |
|
|
|
|
| 23 |
EXTRACT_DO_SAMPLE = False
|
| 24 |
GENERATE_TEMP = 1.0 # slightly lower to reduce arbitrary domain invention
|
| 25 |
GENERATE_MIN_P = 0.06
|
| 26 |
+
GENERATE_MAX_NEW_TOKENS = 72
|
| 27 |
RANK_TEMP = 0.1
|
| 28 |
EXTRACT_MAX_NEW_TOKENS = 220 # JSON never exceeds ~150 tokens; 220 = 47% safety margin
|
| 29 |
|
witgym/engine.py
CHANGED
|
@@ -314,8 +314,8 @@ class WitGymEngine:
|
|
| 314 |
context_str = f"[Previous line β do NOT repeat]: {exclude_joke}\n" + context_str
|
| 315 |
personas_to_run = ["cynic", "conviction", "absurdist"]
|
| 316 |
if metadata.twist_potential <= 6:
|
| 317 |
-
personas_to_run = ["cynic", "
|
| 318 |
-
logger.info(f"twist_potential={metadata.twist_potential} β€ 6 β cynic +
|
| 319 |
elif metadata.twist_potential > 8:
|
| 320 |
personas_to_run = ["cynic", "absurdist", "bisociate"]
|
| 321 |
logger.info(f"twist_potential={metadata.twist_potential} > 8 β bisociate replaces conviction")
|
|
|
|
| 314 |
context_str = f"[Previous line β do NOT repeat]: {exclude_joke}\n" + context_str
|
| 315 |
personas_to_run = ["cynic", "conviction", "absurdist"]
|
| 316 |
if metadata.twist_potential <= 6:
|
| 317 |
+
personas_to_run = ["cynic", "absurdist"]
|
| 318 |
+
logger.info(f"twist_potential={metadata.twist_potential} β€ 6 β cynic + absurdist")
|
| 319 |
elif metadata.twist_potential > 8:
|
| 320 |
personas_to_run = ["cynic", "absurdist", "bisociate"]
|
| 321 |
logger.info(f"twist_potential={metadata.twist_potential} > 8 β bisociate replaces conviction")
|
witgym/extractor.py
CHANGED
|
@@ -28,14 +28,15 @@ Return ONLY a JSON object with these exact fields (no explanation, no markdown,
|
|
| 28 |
}}
|
| 29 |
|
| 30 |
Think carefully about the ARCHETYPE β pick the one that most accurately describes the comedy mechanism hiding in this input.
|
| 31 |
-
Archetype selection guidance (avoid overusing
|
| 32 |
-
- status_assertion: claiming authority/status/rightness as if saying it makes it true
|
| 33 |
-
- misplaced_conf: confident competence claim immediately unsupported by reality
|
| 34 |
-
- anxiety_escalation: small trigger spun into catastrophe / inevitable doom logic
|
| 35 |
-
- social_fail: awkward performance, norm violation, cringe
|
| 36 |
-
- power_inversion: low-status person is the only honest/correct one, or
|
| 37 |
-
- self_delusion: specifically a self-image story ("I'm fine / I'm great / I'm the best") contradicted by behavior/evidence
|
| 38 |
-
|
|
|
|
| 39 |
For twist_potential: score high if the input has self-delusion, status gap, or absurd logic. Score low if it is a neutral factual statement with no tension.
|
| 40 |
If the input is ONLY a greeting, social opener, or typo-greeting with no described situation, set twist_potential to 1 and subtext to "greeting only β no situational content". Do not invent comedy tension.
|
| 41 |
For connector: look for a single word or short phrase that carries an expected meaning in context AND a second meaning that reframes the situation. Most inputs will have null. Return null unless a genuine dual-reading exists (e.g. "manage" can mean control people or barely cope; "balance" can mean financial or emotional equilibrium).
|
|
|
|
| 28 |
}}
|
| 29 |
|
| 30 |
Think carefully about the ARCHETYPE β pick the one that most accurately describes the comedy mechanism hiding in this input.
|
| 31 |
+
Archetype selection guidance (avoid overusing social_fail β use it only for literal norm violations):
|
| 32 |
+
- status_assertion: claiming authority/status/rightness as if saying it makes it true (e.g. "I just got promoted and have no idea what I'm doing" β claiming the title while admitting incompetence)
|
| 33 |
+
- misplaced_conf: confident competence claim immediately unsupported by reality (e.g. "my boss called a quick sync that's been going for two hours" β the word "quick" is the mismatch)
|
| 34 |
+
- anxiety_escalation: small trigger spun into catastrophe / inevitable doom logic (e.g. "my coworker keeps stealing my lunch" β escalating a minor loss into a power/control spiral)
|
| 35 |
+
- social_fail: awkward performance, norm violation, cringe β ONLY when the person did/said something embarrassing in public (e.g. "I waved back at someone who wasn't waving at me" β literal public norm violation)
|
| 36 |
+
- power_inversion: low-status person is the only honest/correct one, or the person with formal authority is visibly incompetent (e.g. "my therapist fell asleep during our session" β the helper needs help)
|
| 37 |
+
- self_delusion: specifically a self-image story ("I'm fine / I'm great / I'm the best") contradicted by behavior/evidence (e.g. "I'm pretending to understand cryptocurrency at dinner parties" β actively constructing a false expert identity)
|
| 38 |
+
CRITICAL: Do NOT use social_fail as a default. Most awkward situations are self_delusion, anxiety_escalation, power_inversion, or misplaced_conf. social_fail requires a public embarrassing action the person actually performed.
|
| 39 |
+
If unsure between self_delusion vs social_fail, ask: did the person DO something publicly awkward (social_fail) or are they MAINTAINING a false self-image (self_delusion)?
|
| 40 |
For twist_potential: score high if the input has self-delusion, status gap, or absurd logic. Score low if it is a neutral factual statement with no tension.
|
| 41 |
If the input is ONLY a greeting, social opener, or typo-greeting with no described situation, set twist_potential to 1 and subtext to "greeting only β no situational content". Do not invent comedy tension.
|
| 42 |
For connector: look for a single word or short phrase that carries an expected meaning in context AND a second meaning that reframes the situation. Most inputs will have null. Return null unless a genuine dual-reading exists (e.g. "manage" can mean control people or barely cope; "balance" can mean financial or emotional equilibrium).
|
witgym/router.py
CHANGED
|
@@ -83,14 +83,27 @@ def _parse_route(raw: str) -> Route | None:
|
|
| 83 |
|
| 84 |
|
| 85 |
def classify_intent(text: str, model, tokenizer) -> Route:
|
| 86 |
-
"""Route banter | quick_wit | coaching via LLM, with heuristic fallback.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
from witgym.model import generate_text
|
| 88 |
|
| 89 |
if not text.strip():
|
| 90 |
return "banter"
|
| 91 |
|
|
|
|
|
|
|
|
|
|
| 92 |
heuristic = _heuristic_route(text)
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
raw = generate_text(
|
| 95 |
ROUTER_PROMPT.format(user_input=text.strip()),
|
| 96 |
model,
|
|
@@ -104,5 +117,5 @@ def classify_intent(text: str, model, tokenizer) -> Route:
|
|
| 104 |
)
|
| 105 |
route = heuristic
|
| 106 |
else:
|
| 107 |
-
logger.info(f"Router: {route!r} for input {text[:60]!r}")
|
| 108 |
return route
|
|
|
|
| 83 |
|
| 84 |
|
| 85 |
def classify_intent(text: str, model, tokenizer) -> Route:
|
| 86 |
+
"""Route banter | quick_wit | coaching via LLM, with heuristic fallback.
|
| 87 |
+
|
| 88 |
+
Heuristic takes priority when there is no explicit coaching signal β avoids
|
| 89 |
+
LLM misclassifying confessional 'I just X' situations as coaching.
|
| 90 |
+
"""
|
| 91 |
from witgym.model import generate_text
|
| 92 |
|
| 93 |
if not text.strip():
|
| 94 |
return "banter"
|
| 95 |
|
| 96 |
+
normalized = text.strip().lower()
|
| 97 |
+
has_coaching_signal = any(sig in normalized for sig in _COACHING_SIGNALS)
|
| 98 |
+
|
| 99 |
heuristic = _heuristic_route(text)
|
| 100 |
|
| 101 |
+
# Skip LLM router when heuristic is confident and no explicit coaching signal:
|
| 102 |
+
# this prevents the LLM from misrouting confessional situation inputs to coaching.
|
| 103 |
+
if heuristic == "quick_wit" and not has_coaching_signal:
|
| 104 |
+
logger.info(f"Router: heuristic fast-path β 'quick_wit' for {text[:60]!r}")
|
| 105 |
+
return "quick_wit"
|
| 106 |
+
|
| 107 |
raw = generate_text(
|
| 108 |
ROUTER_PROMPT.format(user_input=text.strip()),
|
| 109 |
model,
|
|
|
|
| 117 |
)
|
| 118 |
route = heuristic
|
| 119 |
else:
|
| 120 |
+
logger.info(f"Router: LLM β {route!r} for input {text[:60]!r}")
|
| 121 |
return route
|