Spaces:
Running
Running
Akshay Babbar commited on
Commit ·
2aae2f0
1
Parent(s): 640ccba
arch v2
Browse files- witgym/config.py +17 -3
- witgym/conversation.py +10 -6
- witgym/engine.py +12 -9
- witgym/extractor.py +5 -1
- witgym/generator.py +128 -24
- witgym/model.py +2 -0
- witgym/retriever.py +66 -27
- witgym/schemas.py +3 -0
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.
|
| 23 |
GENERATE_MIN_P = 0.06
|
| 24 |
-
GENERATE_MAX_NEW_TOKENS =
|
| 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 =
|
| 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 |
-
|
| 38 |
-
|
|
|
|
|
|
|
| 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}
|
| 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 —
|
| 70 |
-
#
|
| 71 |
-
#
|
|
|
|
| 72 |
context_str = self.conversation.get_context_string()
|
| 73 |
-
personas_to_run =
|
| 74 |
if metadata.twist_potential <= 6:
|
| 75 |
-
personas_to_run = ["cynic", "absurdist"]
|
| 76 |
-
logger.info(f"twist_potential={metadata.twist_potential} ≤ 6 —
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
candidates = generate_candidates(
|
| 79 |
user_input, metadata, scenes,
|
| 80 |
self.model, self.tokenizer,
|
| 81 |
-
context_str,
|
| 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,
|
| 13 |
)
|
| 14 |
|
| 15 |
PERSONA_INSTRUCTIONS = {
|
| 16 |
"cynic": (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
"You've seen this exact rationalization a hundred times. "
|
| 18 |
-
"You're not angry
|
| 19 |
-
"
|
| 20 |
-
"
|
| 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 |
-
"
|
| 34 |
-
"
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
),
|
| 37 |
}
|
| 38 |
|
| 39 |
GENERATION_PROMPT = """\
|
| 40 |
-
You
|
| 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.
|
| 65 |
-
9.
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 79 |
-
f"
|
| 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.
|
| 167 |
-
2.
|
| 168 |
-
3.
|
| 169 |
-
4.
|
|
|
|
|
|
|
| 170 |
|
| 171 |
-
A sharp 20-word line with a specific concrete
|
| 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 ANCHOR — Prefer 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. CONNECTOR — If 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. SHARPNESS — Does 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
|
| 8 |
from loguru import logger
|
| 9 |
import numpy as np
|
| 10 |
from witgym import config
|
| 11 |
-
from witgym.schemas import
|
| 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 |
-
#
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 87 |
-
scene
|
| 88 |
-
if scene.archetype in used_archetypes:
|
| 89 |
continue
|
| 90 |
-
|
| 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 |
-
|
| 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):
|