Akshay Babbar commited on
Commit
2aae2f0
·
1 Parent(s): 640ccba
witgym/config.py CHANGED
@@ -19,22 +19,36 @@ DTYPE = torch.bfloat16
19
  # Sampling
20
  EXTRACT_TEMP = 0.2
21
  EXTRACT_DO_SAMPLE = False
22
- GENERATE_TEMP = 1.3
23
  GENERATE_MIN_P = 0.06
24
- GENERATE_MAX_NEW_TOKENS = 55
25
  RANK_TEMP = 0.1
26
  EXTRACT_MAX_NEW_TOKENS = 220 # JSON never exceeds ~150 tokens; 220 = 47% safety margin
27
 
28
  # Context management
29
- CONTEXT_WINDOW = 32768 # Conservative for MPS memory
30
  COMPRESSION_THRESHOLD = 0.80 # Compress at 80% full
31
  KEEP_LAST_N_TURNS = 4
32
 
33
  # RAG
34
  TOP_K_SCENES = 2
 
 
 
 
35
  INDEX_PATH = "data/index.json"
36
  TRANSCRIPT_DIR = "data/transcripts"
37
 
 
 
 
 
 
 
 
 
 
 
38
  # Cliché penalty (soft steering, not hard suppression)
39
  CLICHE_LOGIT_PENALTY = -5.0
40
  CLICHE_PENALTY_TOKENS = 6 # Penalise first N tokens of the obvious response
 
19
  # Sampling
20
  EXTRACT_TEMP = 0.2
21
  EXTRACT_DO_SAMPLE = False
22
+ GENERATE_TEMP = 1.15 # lowered after anchor/retrieval fixes to reduce domain invention
23
  GENERATE_MIN_P = 0.06
24
+ GENERATE_MAX_NEW_TOKENS = 40
25
  RANK_TEMP = 0.1
26
  EXTRACT_MAX_NEW_TOKENS = 220 # JSON never exceeds ~150 tokens; 220 = 47% safety margin
27
 
28
  # Context management
29
+ CONTEXT_WINDOW = 4096 # MPS-friendly; triggers earlier compression if history grows
30
  COMPRESSION_THRESHOLD = 0.80 # Compress at 80% full
31
  KEEP_LAST_N_TURNS = 4
32
 
33
  # RAG
34
  TOP_K_SCENES = 2
35
+ RETRIEVE_POOL_SIZE = 12
36
+ ENABLE_CROSS_ENCODER_RERANK = True
37
+ RERANK_MODEL_ID = "cross-encoder/ettin-reranker-32m-v1"
38
+ RERANK_DEVICE = "cpu" # keep 9B on MPS; rerank on CPU to avoid unified-memory spikes
39
  INDEX_PATH = "data/index.json"
40
  TRANSCRIPT_DIR = "data/transcripts"
41
 
42
+ # Generation guards
43
+ ENABLE_BAD_WORD_GUARD = True
44
+ BAD_WORD_PHRASES = (
45
+ "PATH A",
46
+ "PATH B",
47
+ "PATHWAY SELECTION",
48
+ )
49
+ ENABLE_OVERLAP_GUARD = True
50
+ OVERLAP_NGRAM_SIZE = 6 # contiguous words shared with retrieved dialogue
51
+
52
  # Cliché penalty (soft steering, not hard suppression)
53
  CLICHE_LOGIT_PENALTY = -5.0
54
  CLICHE_PENALTY_TOKENS = 6 # Penalise first N tokens of the obvious response
witgym/conversation.py CHANGED
@@ -20,29 +20,33 @@ class ConversationManager:
20
  self.history: List[Tuple[str, str]] = [] # (user, assistant)
21
  self.used_archetypes: Set[ComedyArchetype] = set()
22
  self._summary: str = "" # Compressed summary of old turns
23
- self._mechanisms: List[Tuple[str, str, str]] = [] # (archetype, tension, distance)
24
 
25
  def add_turn(self, user_input: str, response: str, metadata: ComedyMetadata):
26
  self.history.append((user_input, response))
27
  self.used_archetypes.add(metadata.archetype)
28
  self._mechanisms.append((
 
 
29
  metadata.archetype.value,
30
  metadata.tension_type.value,
31
  metadata.violation_distance.value,
32
  ))
33
 
34
  def get_context_string(self) -> str:
35
- """Return the last N turns as mechanism-only context.
36
 
37
- Structural goal: preserve callback potential (what kind of jokes were made),
38
- while avoiding lexical bleed from raw user/assistant text across turns.
 
 
39
  """
40
  recent = self._mechanisms[-config.KEEP_LAST_N_TURNS:]
41
  lines = []
42
  if self._summary:
43
  lines.append(f"[Earlier conversation summary]: {self._summary}")
44
- for i, (arch, tension, dist) in enumerate(recent, 1):
45
- lines.append(f"Turn -{len(recent) - i + 1}: archetype={arch}, tension={tension}, distance={dist}")
46
  return "\n".join(lines)
47
 
48
  def needs_compression(self, tokenizer) -> bool:
 
20
  self.history: List[Tuple[str, str]] = [] # (user, assistant)
21
  self.used_archetypes: Set[ComedyArchetype] = set()
22
  self._summary: str = "" # Compressed summary of old turns
23
+ self._mechanisms: List[Tuple[str, str, str, str, str]] = [] # (user_input, subtext, archetype, tension, distance)
24
 
25
  def add_turn(self, user_input: str, response: str, metadata: ComedyMetadata):
26
  self.history.append((user_input, response))
27
  self.used_archetypes.add(metadata.archetype)
28
  self._mechanisms.append((
29
+ user_input,
30
+ metadata.subtext,
31
  metadata.archetype.value,
32
  metadata.tension_type.value,
33
  metadata.violation_distance.value,
34
  ))
35
 
36
  def get_context_string(self) -> str:
37
+ """Return the last N turns as mechanism-only context (archetype + tension).
38
 
39
+ Mechanism-only context for callbacks no prior topic text. user_input and subtext are
40
+ stored in _mechanisms but intentionally not exposed here topic contamination
41
+ makes turn-level evaluation unreliable and anchors the current joke to prior topics.
42
+ Re-enable richer output when multi-turn callback quality is validated.
43
  """
44
  recent = self._mechanisms[-config.KEEP_LAST_N_TURNS:]
45
  lines = []
46
  if self._summary:
47
  lines.append(f"[Earlier conversation summary]: {self._summary}")
48
+ for i, (user_in, subtext, arch, tension, dist) in enumerate(recent, 1):
49
+ lines.append(f"Turn -{len(recent) - i + 1}: archetype={arch}, tension={tension}")
50
  return "\n".join(lines)
51
 
52
  def needs_compression(self, tokenizer) -> bool:
witgym/engine.py CHANGED
@@ -62,28 +62,31 @@ class WitGymEngine:
62
  scenes = retrieve_scenes(
63
  self.index,
64
  metadata,
65
- self.conversation.used_archetypes,
66
  self.embed_model,
67
  )
68
 
69
- # PASS 2 — Generate persona candidates.
70
- # Medium inputs (4-6): run cynic + absurdist only.
71
- # Rich inputs (> 6): run all three including conviction.
 
72
  context_str = self.conversation.get_context_string()
73
- personas_to_run = None # None = all three
74
  if metadata.twist_potential <= 6:
75
- personas_to_run = ["cynic", "absurdist"] # Conviction needs richer tension to land cleanly
76
- logger.info(f"twist_potential={metadata.twist_potential} ≤ 6 — skipping conviction")
 
 
 
77
 
78
  candidates = generate_candidates(
79
  user_input, metadata, scenes,
80
  self.model, self.tokenizer,
81
- context_str, self.conversation.used_archetypes,
82
  personas_to_run=personas_to_run,
83
  )
84
 
85
  # RANK — Pick best candidate
86
- selected = rank_candidates(user_input, candidates, self.model, self.tokenizer)
87
 
88
  # COMPRESS — Swartzwelder pass: generate loose, cut ruthless (skips if ≤12 words)
89
  selected = compress_winner(selected, self.model, self.tokenizer)
 
62
  scenes = retrieve_scenes(
63
  self.index,
64
  metadata,
 
65
  self.embed_model,
66
  )
67
 
68
+ # PASS 2 — persona selection by twist_potential
69
+ # 6: light inputs — cynic + absurdist only (conviction needs richer tension)
70
+ # 7-8: full standard set cynic, conviction, absurdist
71
+ # >8: peak inputs — bisociate replaces conviction (domain-pivot vs. within-frame logic)
72
  context_str = self.conversation.get_context_string()
73
+ personas_to_run = ["cynic", "conviction", "absurdist"] # default: standard 3
74
  if metadata.twist_potential <= 6:
75
+ personas_to_run = ["cynic", "absurdist"]
76
+ logger.info(f"twist_potential={metadata.twist_potential} ≤ 6 — cynic + absurdist only")
77
+ elif metadata.twist_potential > 8:
78
+ personas_to_run = ["cynic", "absurdist", "bisociate"]
79
+ logger.info(f"twist_potential={metadata.twist_potential} > 8 — bisociate replaces conviction")
80
 
81
  candidates = generate_candidates(
82
  user_input, metadata, scenes,
83
  self.model, self.tokenizer,
84
+ context_str,
85
  personas_to_run=personas_to_run,
86
  )
87
 
88
  # RANK — Pick best candidate
89
+ selected = rank_candidates(user_input, metadata, candidates, self.model, self.tokenizer)
90
 
91
  # COMPRESS — Swartzwelder pass: generate loose, cut ruthless (skips if ≤12 words)
92
  selected = compress_winner(selected, self.model, self.tokenizer)
witgym/extractor.py CHANGED
@@ -15,11 +15,14 @@ Return ONLY a JSON object with these exact fields (no explanation, no markdown,
15
  "surface": "what was literally said in one sentence",
16
  "subtext": "what the speaker actually means or feels",
17
  "archetype": one of ["status_assertion", "self_delusion", "power_inversion", "anxiety_escalation", "social_fail", "misplaced_conf"],
 
18
  "tension_type": one of ["social_embarrass", "existential", "status_threat", "identity_expose", "logic_collapse"],
19
  "power_dynamic": "who has power and who doesn't, one sentence",
 
20
  "obvious_response": "the most boring, expected response to this input",
21
  "violation_distance": one of ["mild", "moderate", "sharp"],
22
- "twist_potential": an integer from 1 to 10 rating how much hidden comedy tension is in this input (1=completely flat, 10=extremely rich setup for wit)
 
23
  }}
24
 
25
  Think carefully about the ARCHETYPE — pick the one that most accurately describes the comedy mechanism hiding in this input.
@@ -32,6 +35,7 @@ Archetype selection guidance (avoid overusing self_delusion):
32
  - self_delusion: specifically a self-image story ("I'm fine / I'm great / I'm the best") contradicted by behavior/evidence in the same moment
33
  If unsure between self_delusion vs social_fail/anxiety_escalation, prefer the more specific one (social_fail/anxiety_escalation) unless there is an explicit self-image contradiction.
34
  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.
 
35
  Return ONLY the JSON. Nothing else."""
36
 
37
 
 
15
  "surface": "what was literally said in one sentence",
16
  "subtext": "what the speaker actually means or feels",
17
  "archetype": one of ["status_assertion", "self_delusion", "power_inversion", "anxiety_escalation", "social_fail", "misplaced_conf"],
18
+ "archetype_confidence": an integer from 1 to 10 (how confident you are in the archetype choice),
19
  "tension_type": one of ["social_embarrass", "existential", "status_threat", "identity_expose", "logic_collapse"],
20
  "power_dynamic": "who has power and who doesn't, one sentence",
21
+ "speaker_strategy": "one short phrase describing how the speaker is trying to be perceived (e.g. competent, unbothered, in-control), or null if unclear",
22
  "obvious_response": "the most boring, expected response to this input",
23
  "violation_distance": one of ["mild", "moderate", "sharp"],
24
+ "twist_potential": an integer from 1 to 10 rating how much hidden comedy tension is in this input (1=completely flat, 10=extremely rich setup for wit),
25
+ "connector": "the specific word or phrase in the input that could mean two different things simultaneously, or null if no such word exists"
26
  }}
27
 
28
  Think carefully about the ARCHETYPE — pick the one that most accurately describes the comedy mechanism hiding in this input.
 
35
  - self_delusion: specifically a self-image story ("I'm fine / I'm great / I'm the best") contradicted by behavior/evidence in the same moment
36
  If unsure between self_delusion vs social_fail/anxiety_escalation, prefer the more specific one (social_fail/anxiety_escalation) unless there is an explicit self-image contradiction.
37
  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.
38
+ 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).
39
  Return ONLY the JSON. Nothing else."""
40
 
41
 
witgym/generator.py CHANGED
@@ -6,19 +6,27 @@ from typing import List, Set
6
  from collections import Counter
7
  import re as _re
8
  from loguru import logger
9
- from transformers import LogitsProcessorList
10
  from witgym.model import generate_text, ClichePenaltyProcessor
 
11
  from witgym.schemas import (
12
- ComedyMetadata, TranscriptScene, CandidateResponse, ComedyArchetype
13
  )
14
 
15
  PERSONA_INSTRUCTIONS = {
16
  "cynic": (
 
 
 
 
 
 
 
 
17
  "You've seen this exact rationalization a hundred times. "
18
- "You're not angry about it — you're just tired. "
19
- "State what's actually happening with the weariness of someone who has been right about this for years. "
20
- "Open with the situation, the consequence, or the outcome as your subject — "
21
- "lead with something concrete and specific, not a generic observation about the person."
22
  ),
23
  "conviction": (
24
  "You have a firm, specific belief about how this situation works. "
@@ -28,24 +36,49 @@ PERSONA_INSTRUCTIONS = {
28
  "No irony. No wink. Absolute conviction."
29
  ),
30
  "absurdist": (
 
 
 
 
 
 
 
 
 
31
  "You're the only person in the room who sees where this logically ends. "
32
  "You're not being weird — you're just following the math. "
33
- "State the inevitable conclusion with the calm of someone reading from a manual. "
34
- "End with a single, specific concrete image that makes the conclusion visible "
35
- "not an abstraction, not a restatement. A physical object, a measurable action, a named institution."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  ),
37
  }
38
 
39
  GENERATION_PROMPT = """\
40
- You are a sharp, brief conversational wit engine.
41
- You are responding to: "{user_input}"
42
 
43
  SITUATION ANALYSIS:
44
  - What's happening: {surface}
45
  - What they really mean: {subtext}
46
  - The comedy mechanism: {archetype}
 
47
  - The tension: {tension_type}
48
  - Power dynamic: {power_dynamic}
 
 
49
 
50
  HUMAN COMEDY PRECEDENT (structurally similar situations — same violation type, NOT same words):
51
  {scenes_block}
@@ -61,8 +94,11 @@ CONSTRAINTS (ALL must be satisfied):
61
  5. Lead with the punchline. Do not build up to it.
62
  6. No preamble. No "Here's a response:". No hedging. Start with the wit directly.
63
  7. Stay benign. The violation must be recognizable, not offensive.
64
- 8. Do NOT use these violation types already used in this conversation: {used_archetypes_str}
65
- 9. Suppress this boring response style: "{obvious_response}"
 
 
 
66
 
67
  CONVERSATION CONTEXT (last turns, for callbacks):
68
  {context_str}
@@ -70,14 +106,46 @@ CONVERSATION CONTEXT (last turns, for callbacks):
70
  Respond now with ONE or TWO sentences. Nothing else."""
71
 
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
  def _build_scenes_block(scenes: List[TranscriptScene]) -> str:
 
75
  blocks = []
76
  for s in scenes:
77
  blocks.append(
78
- f" Show: {s.show} | Character: {s.character}\n"
79
- f" Situation: {s.setup}\n"
80
- f" What they said: {s.response}\n"
81
  f" Why it worked: {s.why_it_works}"
82
  )
83
  return "\n\n".join(blocks)
@@ -90,7 +158,6 @@ def generate_candidates(
90
  model,
91
  tokenizer,
92
  context_str: str,
93
- used_archetypes: set,
94
  personas_to_run: List[str] = None,
95
  ) -> List[CandidateResponse]:
96
  """Generate persona candidates (1-3) with ClichePenalty applied.
@@ -99,7 +166,6 @@ def generate_candidates(
99
  None = all three (default). Engine gates this based on twist_potential.
100
  """
101
  scenes_block = _build_scenes_block(scenes)
102
- used_str = ", ".join(a.value for a in used_archetypes) if used_archetypes else "none yet"
103
 
104
  active_personas = {
105
  name: instr for name, instr in PERSONA_INSTRUCTIONS.items()
@@ -108,25 +174,41 @@ def generate_candidates(
108
 
109
  cliche_processor = ClichePenaltyProcessor(metadata.obvious_response, tokenizer)
110
  processors = LogitsProcessorList([cliche_processor])
 
 
 
 
 
 
111
 
112
  candidates = []
 
 
 
113
  for persona_name, persona_instruction in active_personas.items():
114
  prompt = GENERATION_PROMPT.format(
115
  user_input=user_input,
116
  surface=metadata.surface,
117
  subtext=metadata.subtext,
118
  archetype=metadata.archetype.value,
 
119
  tension_type=metadata.tension_type.value,
120
  power_dynamic=metadata.power_dynamic,
 
 
121
  scenes_block=scenes_block,
122
  persona_name=persona_name.upper(),
123
  persona_instruction=persona_instruction,
124
- used_archetypes_str=used_str,
125
  obvious_response=metadata.obvious_response,
126
  context_str=context_str or "(no prior context)",
127
  )
128
 
129
  raw = generate_text(prompt, model, tokenizer, config_type="generate", logits_processors=processors)
 
 
 
 
 
130
  logger.info(f"[{persona_name}] → {raw[:80]}...")
131
 
132
  candidates.append(CandidateResponse(
@@ -153,22 +235,38 @@ def generate_candidates(
153
  extra_ids = tokenizer.encode(word, add_special_tokens=False)
154
  cliche_processor.penalty_ids.update(extra_ids[:2]) # first 2 tokens of each word
155
 
 
 
 
 
 
 
 
 
156
  return candidates
157
 
158
 
159
  RANK_PROMPT = """\
160
  You are judging {n} comedy responses to: "{user_input}"
 
 
 
 
 
 
161
 
162
  {candidates_block}
163
 
164
  Pick the funniest one using this exact priority order:
165
 
166
- 1. CONCRETE IMAGEThe best response ends with a single specific, unexpected image or action that makes the human truth visible. Responses using only abstract concepts or bureaucratic jargon with no specific image always lose. "The drill is merely a gentle hug" beats "the patient submitted a fitness statement."
167
- 2. SHARPNESSDoes the punchline land on first read without unpacking?
168
- 3. TRUTHDoes it name something recognizable that nobody said out loud?
169
- 4. BREVITYIf sharpness and image quality are equal, pick the shorter one.
 
 
170
 
171
- A sharp 20-word line with a specific concrete image beats a flat 10-word line of jargon.
172
  Responses that are purely bureaucratic or purely abstract always lose, regardless of length.
173
 
174
  Reply ONLY with a single digit ({valid_digits}). Nothing else."""
@@ -176,6 +274,7 @@ Reply ONLY with a single digit ({valid_digits}). Nothing else."""
176
 
177
  def rank_candidates(
178
  user_input: str,
 
179
  candidates: List[CandidateResponse],
180
  model,
181
  tokenizer,
@@ -228,6 +327,11 @@ def rank_candidates(
228
  prompt = RANK_PROMPT.format(
229
  n=len(shuffled),
230
  user_input=user_input,
 
 
 
 
 
231
  candidates_block="\n".join(lines),
232
  valid_digits="/".join(digits),
233
  )
 
6
  from collections import Counter
7
  import re as _re
8
  from loguru import logger
9
+ from transformers import LogitsProcessorList, NoBadWordsLogitsProcessor
10
  from witgym.model import generate_text, ClichePenaltyProcessor
11
+ from witgym import config
12
  from witgym.schemas import (
13
+ ComedyMetadata, TranscriptScene, CandidateResponse,
14
  )
15
 
16
  PERSONA_INSTRUCTIONS = {
17
  "cynic": (
18
+ "PATHWAY SELECTION — pick one silently before generating:\n"
19
+ " PATH A (subtext contains avoidance, spiral, or self-protection): "
20
+ "Name the specific function the behavior is performing. "
21
+ "What is it protecting? What does it cost? State it precisely.\n"
22
+ " PATH B (subtext contains status claim or competence assertion): "
23
+ "State the actual outcome as if it has already become concrete. Past tense. "
24
+ "One specific image or action anchored in the user's situation.\n"
25
+ "You pick the path silently. Output only the wit line — never the path label.\n\n"
26
  "You've seen this exact rationalization a hundred times. "
27
+ "You're not angry — you're just tired. "
28
+ "Lead with something concrete: the situation, the consequence, the outcome. "
29
+ "Not a generic observation about the person."
 
30
  ),
31
  "conviction": (
32
  "You have a firm, specific belief about how this situation works. "
 
36
  "No irony. No wink. Absolute conviction."
37
  ),
38
  "absurdist": (
39
+ "PATHWAY SELECTION — pick one silently before generating:\n"
40
+ " PATH A (subtext contains fear, avoidance, or escalation logic — anxiety_escalation or existential tension): "
41
+ "Follow the anxiety's own internal logic to its physical inevitable endpoint. "
42
+ "Name the specific form: a concrete image, measurable action, or physical object "
43
+ "from the user's situation.\n"
44
+ " PATH B (connector field is non-null OR input has subject\u2192action\u2192object structure): "
45
+ "Keep the verb/action. Find the domain where that exact action applied to a different object "
46
+ "reveals the identical human truth. Deliver from that domain without naming the parallel.\n"
47
+ "You pick the path silently. Output only the wit line — never the path label.\n\n"
48
  "You're the only person in the room who sees where this logically ends. "
49
  "You're not being weird — you're just following the math. "
50
+ "End with a single concrete image anchored in the user's situation. "
51
+ "Not an abstraction. A physical object or measurable action the input already implies."
52
+ ),
53
+ "bisociate": (
54
+ "PATHWAY SELECTION — pick one silently before generating:\n"
55
+ " PATH A (connector field is non-null): "
56
+ "Your punchline must land on the second meaning of the connector word. "
57
+ "The setup's expected reading of that word is the straight path. You take the other one.\n"
58
+ " PATH B (connector is null): "
59
+ "Apply the dominant verb from the subtext to the most structurally incongruous object from daily life. "
60
+ "Deliver from inside that object's world, as if you've been there all along. "
61
+ "Do not name the parallel. Do not explain the connection.\n"
62
+ "You pick the path silently. Output only the wit line — never the path label.\n\n"
63
+ "You notice that what they're describing is not unique to their situation. "
64
+ "The same need, avoidance, or craving exists somewhere completely different. "
65
+ "State the parallel as if it is obvious and everyone already knows this. "
66
+ "Do not reference any topic already mentioned in the conversation context."
67
  ),
68
  }
69
 
70
  GENERATION_PROMPT = """\
71
+ You're in the writer's room. Someone just said: "{user_input}" — what's the line?
 
72
 
73
  SITUATION ANALYSIS:
74
  - What's happening: {surface}
75
  - What they really mean: {subtext}
76
  - The comedy mechanism: {archetype}
77
+ - Archetype confidence: {archetype_confidence}/10
78
  - The tension: {tension_type}
79
  - Power dynamic: {power_dynamic}
80
+ - Speaker strategy: {speaker_strategy}
81
+ - Connector word (two readings, if present): {connector}
82
 
83
  HUMAN COMEDY PRECEDENT (structurally similar situations — same violation type, NOT same words):
84
  {scenes_block}
 
94
  5. Lead with the punchline. Do not build up to it.
95
  6. No preamble. No "Here's a response:". No hedging. Start with the wit directly.
96
  7. Stay benign. The violation must be recognizable, not offensive.
97
+ 8. Suppress this boring response style: "{obvious_response}"
98
+ 9. NEVER output "PATH A", "PATH B", "PATHWAY SELECTION", or any routing label. Output ONLY the comedy line.
99
+ 10. If any phrase from the precedent block appears in your line, rewrite it — use the mechanism only, never the precedent's wording.
100
+ 11. DOMAIN ANCHOR — Do not introduce institutions, departments, legal process, or workplace nouns unless already implied by surface, subtext, or power_dynamic. Prefer nouns from the user input.
101
+ 12. Treat unexpected domain shifts (HR, litigation, supply chain, middle-manager hierarchy) as defects unless the input already implies them.
102
 
103
  CONVERSATION CONTEXT (last turns, for callbacks):
104
  {context_str}
 
106
  Respond now with ONE or TWO sentences. Nothing else."""
107
 
108
 
109
+ def _normalize_for_overlap(text: str) -> str:
110
+ """Lowercase, strip punctuation, collapse whitespace for n-gram checks."""
111
+ text = text.lower()
112
+ text = _re.sub(r"[^\w\s]", " ", text)
113
+ return " ".join(text.split())
114
+
115
+
116
+ def _ngrams(words: List[str], n: int) -> Set[str]:
117
+ if len(words) < n:
118
+ return set()
119
+ return {" ".join(words[i : i + n]) for i in range(len(words) - n + 1)}
120
+
121
+
122
+ def _precedent_prompt_texts(scenes: List[TranscriptScene]) -> List[str]:
123
+ """Text actually injected into the generation prompt (overlap guard surface)."""
124
+ return [s.why_it_works for s in scenes]
125
+
126
+
127
+ def _copies_retrieved_phrase(candidate: str, scenes: List[TranscriptScene], n: int) -> bool:
128
+ """True if candidate shares an n-word contiguous phrase with precedent prompt text."""
129
+ cand_words = _normalize_for_overlap(candidate).split()
130
+ if len(cand_words) < n:
131
+ return False
132
+ cand_grams = _ngrams(cand_words, n)
133
+ if not cand_grams:
134
+ return False
135
+ for source in _precedent_prompt_texts(scenes):
136
+ src_grams = _ngrams(_normalize_for_overlap(source).split(), n)
137
+ if cand_grams & src_grams:
138
+ return True
139
+ return False
140
+
141
 
142
  def _build_scenes_block(scenes: List[TranscriptScene]) -> str:
143
+ """Mechanism-only precedent — tags + why_it_works, no raw setup or dialogue."""
144
  blocks = []
145
  for s in scenes:
146
  blocks.append(
147
+ f" Archetype: {s.archetype.value} | Tension: {s.tension_type.value} | "
148
+ f"Distance: {s.violation_distance.value}\n"
 
149
  f" Why it worked: {s.why_it_works}"
150
  )
151
  return "\n\n".join(blocks)
 
158
  model,
159
  tokenizer,
160
  context_str: str,
 
161
  personas_to_run: List[str] = None,
162
  ) -> List[CandidateResponse]:
163
  """Generate persona candidates (1-3) with ClichePenalty applied.
 
166
  None = all three (default). Engine gates this based on twist_potential.
167
  """
168
  scenes_block = _build_scenes_block(scenes)
 
169
 
170
  active_personas = {
171
  name: instr for name, instr in PERSONA_INSTRUCTIONS.items()
 
174
 
175
  cliche_processor = ClichePenaltyProcessor(metadata.obvious_response, tokenizer)
176
  processors = LogitsProcessorList([cliche_processor])
177
+ if config.ENABLE_BAD_WORD_GUARD:
178
+ bad_words_ids = [
179
+ tokenizer.encode(phrase, add_special_tokens=False)
180
+ for phrase in config.BAD_WORD_PHRASES
181
+ ]
182
+ processors.append(NoBadWordsLogitsProcessor(bad_words_ids, eos_token_id=tokenizer.eos_token_id))
183
 
184
  candidates = []
185
+ ngram_n = config.OVERLAP_NGRAM_SIZE
186
+ last_raw = None
187
+ last_persona = None
188
  for persona_name, persona_instruction in active_personas.items():
189
  prompt = GENERATION_PROMPT.format(
190
  user_input=user_input,
191
  surface=metadata.surface,
192
  subtext=metadata.subtext,
193
  archetype=metadata.archetype.value,
194
+ archetype_confidence=metadata.archetype_confidence,
195
  tension_type=metadata.tension_type.value,
196
  power_dynamic=metadata.power_dynamic,
197
+ speaker_strategy=metadata.speaker_strategy or "none",
198
+ connector=metadata.connector or "none",
199
  scenes_block=scenes_block,
200
  persona_name=persona_name.upper(),
201
  persona_instruction=persona_instruction,
 
202
  obvious_response=metadata.obvious_response,
203
  context_str=context_str or "(no prior context)",
204
  )
205
 
206
  raw = generate_text(prompt, model, tokenizer, config_type="generate", logits_processors=processors)
207
+ last_raw, last_persona = raw, persona_name
208
+ if config.ENABLE_OVERLAP_GUARD and _copies_retrieved_phrase(raw, scenes, ngram_n):
209
+ logger.warning(f"[{persona_name}] copies precedent phrasing — dropping candidate")
210
+ continue
211
+
212
  logger.info(f"[{persona_name}] → {raw[:80]}...")
213
 
214
  candidates.append(CandidateResponse(
 
235
  extra_ids = tokenizer.encode(word, add_special_tokens=False)
236
  cliche_processor.penalty_ids.update(extra_ids[:2]) # first 2 tokens of each word
237
 
238
+ if not candidates and last_raw:
239
+ logger.warning(f"All candidates dropped — keeping last line from {last_persona}")
240
+ candidates.append(CandidateResponse(
241
+ persona=last_persona,
242
+ text=last_raw.strip(),
243
+ violation_type=f"{metadata.archetype.value} via {last_persona} lens",
244
+ ))
245
+
246
  return candidates
247
 
248
 
249
  RANK_PROMPT = """\
250
  You are judging {n} comedy responses to: "{user_input}"
251
+ Connector word (has two simultaneous readings in the input): "{connector}"
252
+ Extracted context:
253
+ - subtext: "{subtext}"
254
+ - archetype: "{archetype}"
255
+ - tension_type: "{tension_type}"
256
+ - speaker_strategy: "{speaker_strategy}"
257
 
258
  {candidates_block}
259
 
260
  Pick the funniest one using this exact priority order:
261
 
262
+ 1. DOMAIN ANCHORPrefer lines that stay inside the user's world from surface/subtext. Down-rank candidates that import HR, legal process, supply chain, new departments, or institutional bureaucracy unless the user input already implies them. A grounded but slightly softer punchline beats a sharper line that drifts into an unsupported external domain.
263
+ 2. FINAL CLAUSE The punchline is always the last clause. Judge the quality of the ENDING, not the setup. A sharp ending on a flat setup beats a flat ending on a sharp setup. The best ending is a single specific, unexpected image or action that makes the human truth visible. Responses where the final clause is abstract, bureaucratic, or jargon always lose.
264
+ 3. CONNECTORIf a response lands on the second meaning of the connector word in its punchline, this is a strong quality signal. It means the wit is structurally grounded in the input, not floating free.
265
+ 4. SHARPNESSDoes the punchline land on first read without unpacking?
266
+ 5. TRUTH — Does it name something recognizable that nobody said out loud?
267
+ 6. BREVITY — If sharpness and image quality are equal, pick the shorter one.
268
 
269
+ A sharp 20-word line with a specific concrete ending beats a flat 10-word line of jargon.
270
  Responses that are purely bureaucratic or purely abstract always lose, regardless of length.
271
 
272
  Reply ONLY with a single digit ({valid_digits}). Nothing else."""
 
274
 
275
  def rank_candidates(
276
  user_input: str,
277
+ metadata: ComedyMetadata,
278
  candidates: List[CandidateResponse],
279
  model,
280
  tokenizer,
 
327
  prompt = RANK_PROMPT.format(
328
  n=len(shuffled),
329
  user_input=user_input,
330
+ connector=(metadata.connector or "none"),
331
+ subtext=metadata.subtext,
332
+ archetype=metadata.archetype.value,
333
+ tension_type=metadata.tension_type.value,
334
+ speaker_strategy=metadata.speaker_strategy or "none",
335
  candidates_block="\n".join(lines),
336
  valid_digits="/".join(digits),
337
  )
witgym/model.py CHANGED
@@ -1,6 +1,7 @@
1
  """Model loading and ClichePenaltyProcessor."""
2
  import re
3
  import torch
 
4
  from transformers import (
5
  AutoModelForCausalLM,
6
  AutoTokenizer,
@@ -153,6 +154,7 @@ def generate_text(
153
  # Free KV cache + intermediate buffers from unified memory after each call
154
  if config.DEVICE == "mps":
155
  torch.mps.empty_cache()
 
156
 
157
  new_tokens = output_ids[0][inputs["input_ids"].shape[-1]:]
158
  raw = tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
 
1
  """Model loading and ClichePenaltyProcessor."""
2
  import re
3
  import torch
4
+ import gc
5
  from transformers import (
6
  AutoModelForCausalLM,
7
  AutoTokenizer,
 
154
  # Free KV cache + intermediate buffers from unified memory after each call
155
  if config.DEVICE == "mps":
156
  torch.mps.empty_cache()
157
+ gc.collect()
158
 
159
  new_tokens = output_ids[0][inputs["input_ids"].shape[-1]:]
160
  raw = tokenizer.decode(new_tokens, skip_special_tokens=True).strip()
witgym/retriever.py CHANGED
@@ -4,13 +4,14 @@ Implements Principle 2: query is the abstract comedy metadata, not raw text.
4
  Returns analogous situations (same violation type), not similar words.
5
  """
6
  import json
7
- from typing import List, Set
8
  from loguru import logger
9
  import numpy as np
10
  from witgym import config
11
- from witgym.schemas import ComedyArchetype, TensionType, ViolationDistance, TranscriptScene, ComedyMetadata
12
 
13
  _index_cache = None # Loaded once, reused across calls
 
14
 
15
 
16
  def load_index(index_path: str = config.INDEX_PATH) -> dict:
@@ -31,18 +32,32 @@ def load_index(index_path: str = config.INDEX_PATH) -> dict:
31
  return _index_cache
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  def retrieve_scenes(
35
  index: dict,
36
  metadata: ComedyMetadata,
37
- used_archetypes: Set[ComedyArchetype],
38
  embed_model,
39
  top_k: int = config.TOP_K_SCENES,
40
  ) -> List[TranscriptScene]:
41
- """Principle 2: query on abstract comedy fields, return analogous situations.
42
-
43
- Over-fetches (top_k * 3) then filters out already-used archetypes to
44
- prevent repetition across the conversation.
45
- """
46
  # Mirror the indexed representation: keep the structural labels, then add
47
  # the extracted subtext so the query has semantic detail comparable to setup.
48
  query = (
@@ -66,36 +81,60 @@ def retrieve_scenes(
66
  # Rank all scenes by similarity
67
  ranked_indices = np.argsort(scores)[::-1]
68
 
69
- # Filter: exclude scenes whose archetype is already used in this session,
70
- # AND enforce intra-call diversity (no two retrieved scenes share the same archetype)
71
- selected = []
72
- selected_archetypes: Set[ComedyArchetype] = set()
73
- for idx in ranked_indices:
74
- scene = scenes[idx]
75
- if scene.archetype in used_archetypes:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  continue
77
- if scene.archetype in selected_archetypes:
78
- continue # Already have a scene of this archetype in this call
79
  selected.append(scene)
80
- selected_archetypes.add(scene.archetype)
81
  if len(selected) >= top_k:
82
  break
83
 
84
- # Fallback 1: relax intra-call diversity if not enough distinct archetypes found
85
  if len(selected) < top_k:
86
- for idx in ranked_indices:
87
- scene = scenes[idx]
88
- if scene.archetype in used_archetypes:
89
  continue
90
- if scene not in selected:
91
- selected.append(scene)
92
  if len(selected) >= top_k:
93
  break
94
 
95
- # Fallback 2: all session archetypes exhausted — return top matches without filter
96
  if not selected:
97
- logger.warning("All archetypes used — returning top scenes without archetype filter")
98
- selected = [scenes[i] for i in ranked_indices[:top_k]]
99
 
100
  logger.info(f"Retrieved {len(selected)} scenes: {[s.archetype.value for s in selected]}")
101
  return selected
 
4
  Returns analogous situations (same violation type), not similar words.
5
  """
6
  import json
7
+ from typing import List
8
  from loguru import logger
9
  import numpy as np
10
  from witgym import config
11
+ from witgym.schemas import TranscriptScene, ComedyMetadata
12
 
13
  _index_cache = None # Loaded once, reused across calls
14
+ _reranker_cache = None
15
 
16
 
17
  def load_index(index_path: str = config.INDEX_PATH) -> dict:
 
32
  return _index_cache
33
 
34
 
35
+ def _load_reranker():
36
+ global _reranker_cache
37
+ if not config.ENABLE_CROSS_ENCODER_RERANK:
38
+ return None
39
+ if _reranker_cache is not None:
40
+ return _reranker_cache
41
+
42
+ try:
43
+ from sentence_transformers import CrossEncoder
44
+
45
+ logger.info(f"Loading reranker: {config.RERANK_MODEL_ID} on {config.RERANK_DEVICE}")
46
+ _reranker_cache = CrossEncoder(config.RERANK_MODEL_ID, device=config.RERANK_DEVICE)
47
+ return _reranker_cache
48
+ except Exception as e:
49
+ logger.warning(f"Reranker unavailable; falling back to cosine only: {e}")
50
+ _reranker_cache = False
51
+ return None
52
+
53
+
54
  def retrieve_scenes(
55
  index: dict,
56
  metadata: ComedyMetadata,
 
57
  embed_model,
58
  top_k: int = config.TOP_K_SCENES,
59
  ) -> List[TranscriptScene]:
60
+ """Principle 2: query on abstract comedy fields, return analogous situations."""
 
 
 
 
61
  # Mirror the indexed representation: keep the structural labels, then add
62
  # the extracted subtext so the query has semantic detail comparable to setup.
63
  query = (
 
81
  # Rank all scenes by similarity
82
  ranked_indices = np.argsort(scores)[::-1]
83
 
84
+ # Stage 1: pool by cosine (wide) Stage 2: rerank (narrow)
85
+ pool_size = max(config.RETRIEVE_POOL_SIZE, top_k * 3)
86
+ pool_indices = ranked_indices[:pool_size]
87
+ pool_scenes = [scenes[i] for i in pool_indices]
88
+
89
+ reranker = _load_reranker()
90
+ if reranker is not None:
91
+ query_text = (
92
+ f"{metadata.subtext}\n"
93
+ f"archetype={metadata.archetype.value} "
94
+ f"tension={metadata.tension_type.value} "
95
+ f"distance={metadata.violation_distance.value}"
96
+ )
97
+ doc_texts = [
98
+ (
99
+ f"setup={s.setup}\n"
100
+ f"why_it_works={s.why_it_works}\n"
101
+ f"archetype={s.archetype.value} "
102
+ f"tension={s.tension_type.value} "
103
+ f"distance={s.violation_distance.value}"
104
+ )
105
+ for s in pool_scenes
106
+ ]
107
+ pairs = [[query_text, d] for d in doc_texts]
108
+ rerank_scores = np.asarray(reranker.predict(pairs), dtype=np.float32)
109
+ reranked_order = np.argsort(rerank_scores)[::-1]
110
+ ranked_scenes = [pool_scenes[i] for i in reranked_order]
111
+ logger.debug(
112
+ "Rerank top5 scores: "
113
+ + ", ".join(f"{rerank_scores[i]:.3f}" for i in reranked_order[:5])
114
+ )
115
+ else:
116
+ ranked_scenes = pool_scenes
117
+
118
+ # Same extracted archetype first; backfill from reranked pool only if thin
119
+ target = metadata.archetype
120
+ selected: List[TranscriptScene] = []
121
+ for scene in ranked_scenes:
122
+ if scene.archetype != target:
123
  continue
 
 
124
  selected.append(scene)
 
125
  if len(selected) >= top_k:
126
  break
127
 
 
128
  if len(selected) < top_k:
129
+ for scene in ranked_scenes:
130
+ if scene in selected:
 
131
  continue
132
+ selected.append(scene)
 
133
  if len(selected) >= top_k:
134
  break
135
 
 
136
  if not selected:
137
+ selected = ranked_scenes[:top_k]
 
138
 
139
  logger.info(f"Retrieved {len(selected)} scenes: {[s.archetype.value for s in selected]}")
140
  return selected
witgym/schemas.py CHANGED
@@ -31,11 +31,14 @@ class ComedyMetadata(BaseModel):
31
  surface: str
32
  subtext: str
33
  archetype: ComedyArchetype
 
34
  tension_type: TensionType
35
  power_dynamic: str
36
  obvious_response: str
37
  violation_distance: ViolationDistance
38
  twist_potential: int = 5 # 1-10: comedy richness of the input; drives pipeline gating
 
 
39
 
40
 
41
  class TranscriptScene(BaseModel):
 
31
  surface: str
32
  subtext: str
33
  archetype: ComedyArchetype
34
+ archetype_confidence: int = 7 # 1-10: confidence in archetype selection
35
  tension_type: TensionType
36
  power_dynamic: str
37
  obvious_response: str
38
  violation_distance: ViolationDistance
39
  twist_potential: int = 5 # 1-10: comedy richness of the input; drives pipeline gating
40
+ connector: Optional[str] = None # Word/phrase with two simultaneous readings; null if absent
41
+ speaker_strategy: Optional[str] = None # Brief guidance for how the speaker is trying to be perceived
42
 
43
 
44
  class TranscriptScene(BaseModel):