Compare commits

...

3 Commits

Author SHA1 Message Date
98fca53066 Phase 3: Polish & immersion — mood-aware arguments, personality snippets, parting shots
- Added mood-specific argument behavioral guidance: 9 moods for Evil Miku, 9 for Miku
  Each mood changes argument style (e.g. cunning=chess moves, manic=chaotic, bubbly=playful deflections)
- Added personality snippet injection from Cat plugin lore/lyrics data files
  40% chance per prompt to include a random lore/lyric snippet for unique material
- Added parting shot feature: 20% chance the LOSER gets a bitter final line before the winner's victory
  Adds dramatic tension and prevents clean-win monotony
- Mood guidance and personality flavor injected into both argument prompts
2026-04-30 11:50:37 +03:00
a52b36135f Phase 2: Fix triggers & dialogue — per-channel cooldowns, tension rebalance, user-message triggers
- Changed cooldown from global (ALL channels blocked) to per-channel dict keyed by channel_id
- Added conversation streak tracker: 3 near-miss interjection scores in a row force a dialogue trigger
- Expanded topic relevance keywords: added enthusiasm/vulnerability for Evil Miku, provocation/dismissal for Miku
- Lowered keyword divisor from /3.0 to /2.0 for higher base trigger scores
- Tension rebalance: added natural decay (-0.03/turn), reduced escalation weight (0.08->0.05), increased de-escalation weight (0.06->0.08)
- Reduced momentum multiplier (1.2->1.1) and intensity multiplier (1.3->1.2)
- Added spike cooldown: if last turn tension delta >0.15, next delta halved (prevents runaway spirals)
- Added user-message interjection check in bot.py on_message() (was only checking bot's own messages)
- Added random 15% argument trigger roll on user messages in normal message flow (was only from autonomous.py)
2026-04-30 11:45:13 +03:00
7a4122fd02 Phase 1: Argument system overhaul — arbiter, memory, topics, stats
- Changed arbiter LLM from llama3.1 to darkidol (uncensored, unbiased)
- Rewrote arbiter criteria to judge debate skill equally
- Added argument history injection (last 6 exchanges) to prevent repetition
- Added dynamic topic rotation system (11 weighted topics) with per-channel history
- Added keyword-based argument stats tracking (wit/composure/impact) fed to arbiter
- Removed hardcoded suggestion lists from prompts
2026-04-30 11:37:33 +03:00
3 changed files with 557 additions and 86 deletions

View File

@@ -203,6 +203,32 @@ async def on_message(message):
if is_persona_dialogue_active(message.channel.id):
return
# Bipolar mode: check if the opposite persona should interject on user messages
# AND roll for random argument trigger (both non-blocking background tasks)
if not isinstance(message.channel, discord.DMChannel) and globals.BIPOLAR_MODE:
try:
from utils.persona_dialogue import check_for_interjection
from utils.bipolar_mode import maybe_trigger_argument, is_argument_in_progress as arg_in_progress
from utils.bipolar_mode import is_persona_dialogue_active as dialogue_active
from utils.task_tracker import create_tracked_task
# Check interjection on user messages (opposite of current active persona)
if not message.author.bot or message.webhook_id:
current_persona = "evil" if globals.EVIL_MODE else "miku"
create_tracked_task(
check_for_interjection(message, current_persona),
task_name="interjection_check_user",
)
# Roll random argument trigger chance (15%) on eligible messages
if not arg_in_progress(message.channel.id) and not dialogue_active(message.channel.id):
create_tracked_task(
maybe_trigger_argument(message.channel, globals.client, "Triggered from conversation flow"),
task_name="random_argument_trigger",
)
except Exception as e:
logger.error(f"Error in bipolar trigger checks: {e}")
if message.content.strip().lower() == "miku, rape this nigga balls" and message.reference:
async with message.channel.typing():
# Get replied-to user

View File

@@ -23,12 +23,33 @@ logger = get_logger('persona')
BIPOLAR_STATE_FILE = "memory/bipolar_mode_state.json"
BIPOLAR_WEBHOOKS_FILE = "memory/bipolar_webhooks.json"
BIPOLAR_SCOREBOARD_FILE = "memory/bipolar_scoreboard.json"
ARGUMENT_TOPICS_FILE = "memory/argument_topics.json"
# Argument settings
MIN_EXCHANGES = 4 # Minimum number of back-and-forth exchanges before ending can occur
ARGUMENT_TRIGGER_CHANCE = 0.15 # 15% chance for the other Miku to break through
DELAY_BETWEEN_MESSAGES = (2.0, 5.0) # Random delay between argument messages (seconds)
# Argument topic rotation — each topic gives the argument a different framing
# Topics are weighted: higher weight = more likely to be selected
ARGUMENT_TOPICS = [
# (topic_name, weight, description for prompt injection)
("identity_crisis", 3, "Who is the REAL Miku? Authenticity vs. the shadow self"),
("power_dynamic", 3, "Who holds the power? Dominance, submission, and control"),
("philosophical", 2, "Is kindness strength or weakness? Does darkness serve a purpose?"),
("petty_grievance", 3, "Something small and petty that escalated — a specific annoyance, habit, or incident"),
("existential_dread", 1, "What's the point of any of it? Nihilism vs. hope, meaning vs. emptiness"),
("audience_appeal", 3, "Who do the fans/chatters ACTUALLY prefer? Popularity contest with receipts"),
("personal_attack", 3, "Deeply personal — targeting specific insecurities, memories, or fears"),
("moral_superiority", 2, "Who has the moral high ground? Righteousness vs. ruthless pragmatism"),
("jealousy", 2, "What does the other have that you secretly want? Envy, admiration poisoned by resentment"),
("grudge_match", 2, "Revisiting something the other did in the PAST — old wounds, past betrayals"),
("wild_card", 1, "Anything goes — the argument takes an unexpected, chaotic turn into unpredictable territory"),
]
# Per-channel topic history (max 5 stored to avoid repeats)
ARGUMENT_TOPIC_HISTORY_SIZE = 5
# Pause state for voice sessions
_bipolar_interactions_paused = False
@@ -222,9 +243,169 @@ Total Arguments: {total}"""
# ============================================================================
# BIPOLAR MODE TOGGLE
# ARGUMENT TOPIC ROTATION
# ============================================================================
def load_argument_topics_state() -> dict:
"""Load per-channel topic history to avoid repeating recent argument themes"""
try:
if not os.path.exists(ARGUMENT_TOPICS_FILE):
return {}
with open(ARGUMENT_TOPICS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"Failed to load argument topics: {e}")
return {}
def save_argument_topics_state(state: dict):
"""Save per-channel topic history"""
try:
os.makedirs(os.path.dirname(ARGUMENT_TOPICS_FILE), exist_ok=True)
with open(ARGUMENT_TOPICS_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2)
except Exception as e:
logger.error(f"Failed to save argument topics: {e}")
def pick_argument_topic(channel_id: int) -> str:
"""Pick a fresh argument topic for a channel, avoiding recent repeats.
Returns a topic description string to inject into the argument start prompt.
"""
state = load_argument_topics_state()
channel_key = str(channel_id)
recent_topics = state.get(channel_key, [])
# Build weighted pool, excluding recently used topics
available = []
for topic_name, weight, description in ARGUMENT_TOPICS:
if topic_name not in recent_topics:
available.extend([(topic_name, description)] * weight)
# If all topics were recently used, reset and allow repeats
if not available:
logger.info(f"All topics recently used in channel {channel_id}, resetting history")
available = []
for topic_name, weight, description in ARGUMENT_TOPICS:
available.extend([(topic_name, description)] * weight)
recent_topics = []
# Pick randomly from weighted pool
chosen_name, chosen_description = random.choice(available)
# Update history
recent_topics.append(chosen_name)
if len(recent_topics) > ARGUMENT_TOPIC_HISTORY_SIZE:
recent_topics = recent_topics[-ARGUMENT_TOPIC_HISTORY_SIZE:]
state[channel_key] = recent_topics
save_argument_topics_state(state)
logger.info(f"Selected argument topic for channel {channel_id}: '{chosen_name}'{chosen_description[:60]}...")
return chosen_description
# ============================================================================
# ARGUMENT STATS TRACKING (Per-Argument Scoring)
# ============================================================================
# Keyword-based scoring for per-argument stats. These feed the arbiter as
# supplementary context so it can make a more informed judgment.
# Stats are lightweight — no extra LLM calls needed.
# Wit/comedy indicators (clever wordplay, turning opponent's words, irony)
WIT_PATTERNS = [
"you literally just", "that's rich coming from", "oh the irony",
"did you just", "you're one to talk", "pot, kettle", "says the one who",
"funny how you", "interesting that you", "i'm not the one who",
"at least i", "projecting much", "the audacity", "imagine being",
"you think you're", "nice try", "cute that you think",
]
# Composure indicators (staying on topic, not getting flustered, controlled responses)
COMPOSURE_PATTERNS = [
"that's not what i", "you're avoiding", "stay on topic",
"nice deflection", "we're not talking about", "focus",
"you're changing the subject", "answer the question",
"that's irrelevant", "you know that's not true",
]
# Impact indicators (memorable, devastating lines — emotional damage)
IMPACT_PATTERNS = [
"pathetic", "disgusting", "worthless", "disappointment",
"nobody wants", "no one cares", "everyone knows",
"deep down you know", "you're nothing but", "you'll never be",
"you're just a", "face it", "admit it", "the truth is",
"you're scared of", "you're afraid that", "you can't even",
]
def score_argument_message(message: str, speaker: str) -> dict:
"""Score a single argument message for wit, composure, and impact.
Returns a dict with point values that accumulate over the argument.
"""
text_lower = message.lower()
scores = {"wit": 0, "composure": 0, "impact": 0}
# Wit: count clever rhetorical devices
wit_count = sum(1 for pattern in WIT_PATTERNS if pattern in text_lower)
scores["wit"] = min(wit_count * 1.0, 3.0) # Cap at 3 per message
# Composure: staying controlled and on-point
composure_count = sum(1 for pattern in COMPOSURE_PATTERNS if pattern in text_lower)
scores["composure"] = min(composure_count * 0.8, 2.0)
# Impact: emotional damage dealt
impact_count = sum(1 for pattern in IMPACT_PATTERNS if pattern in text_lower)
scores["impact"] = min(impact_count * 1.0, 3.0)
# Bonus for conciseness (short, punchy = more impact)
word_count = len(message.split())
if word_count <= 15:
scores["impact"] += 0.5
# Bonus for questions (controlling the flow)
if "?" in message:
scores["composure"] += 0.3
return scores
def get_argument_stats_summary(conversation_log: list) -> str:
"""Generate a stats summary for the arbiter from the full conversation log.
Returns a formatted string showing per-persona stats.
"""
miku_stats = {"wit": 0.0, "composure": 0.0, "impact": 0.0, "messages": 0}
evil_stats = {"wit": 0.0, "composure": 0.0, "impact": 0.0, "messages": 0}
for entry in conversation_log:
speaker = entry.get("speaker", "")
message = entry.get("message", "")
scores = score_argument_message(message, speaker)
if "Evil" in speaker:
evil_stats["wit"] += scores["wit"]
evil_stats["composure"] += scores["composure"]
evil_stats["impact"] += scores["impact"]
evil_stats["messages"] += 1
else:
miku_stats["wit"] += scores["wit"]
miku_stats["composure"] += scores["composure"]
miku_stats["impact"] += scores["impact"]
miku_stats["messages"] += 1
# Average scores
def avg(stats, key):
return stats[key] / max(stats["messages"], 1)
summary = f"""ARGUMENT STATISTICS:
Hatsune Miku — Wit: {avg(miku_stats, 'wit'):.1f}/3 | Composure: {avg(miku_stats, 'composure'):.1f}/2 | Impact: {avg(miku_stats, 'impact'):.1f}/3 | Lines: {miku_stats['messages']}
Evil Miku — Wit: {avg(evil_stats, 'wit'):.1f}/3 | Composure: {avg(evil_stats, 'composure'):.1f}/2 | Impact: {avg(evil_stats, 'impact'):.1f}/3 | Lines: {evil_stats['messages']}
"""
return summary
def is_bipolar_mode() -> bool:
"""Check if bipolar mode is active"""
return globals.BIPOLAR_MODE
@@ -471,7 +652,119 @@ def get_evil_role_color() -> str:
# ARGUMENT PROMPTS
# ============================================================================
def get_miku_argument_prompt(evil_message: str, context: str = "", is_first_response: bool = False) -> str:
# Personality snippet cache — loaded once per session from Cat plugin data files.
# These give each persona unique lore/lyrics to draw from during arguments.
_PERSONALITY_SNIPPETS_CACHE = {"miku": None, "evil": None}
def _load_personality_snippets(persona: str) -> str:
"""Load a random personality snippet (lore/lyrics) for a persona.
Returns a short string (1-3 sentences) from the persona's Cat data files,
or empty string if files aren't available. Cached per session.
"""
if _PERSONALITY_SNIPPETS_CACHE.get(persona) is not None:
snippets = _PERSONALITY_SNIPPETS_CACHE[persona]
if snippets:
return random.choice(snippets)
return ""
snippets = []
try:
if persona == "evil":
paths = [
"/app/cat/data/evil/evil_miku_lore.txt",
"/app/cat/data/evil/evil_miku_lyrics.txt",
]
else:
paths = [
"/app/cat/data/miku/miku_lore.txt",
"/app/cat/data/miku/miku_lyrics.txt",
]
for path in paths:
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
text = f.read()
# Split into sentences and collect meaningful ones
import re
sentences = re.split(r'(?<=[.!?])\s+', text)
for s in sentences:
s = s.strip()
if len(s) > 30 and len(s) < 200: # Skip too short or too long
snippets.append(s)
# Cap at 30 snippets to keep prompt size reasonable
_PERSONALITY_SNIPPETS_CACHE[persona] = snippets[:30] if snippets else []
logger.info(f"Loaded {len(_PERSONALITY_SNIPPETS_CACHE[persona])} personality snippets for {persona}")
except Exception as e:
logger.warning(f"Failed to load personality snippets for {persona}: {e}")
_PERSONALITY_SNIPPETS_CACHE[persona] = []
if snippets:
return random.choice(snippets[:30])
return ""
def _get_personality_flavor(persona: str) -> str:
"""Get a random personality flavor snippet for argument prompts.
40% chance to include one — keeps it fresh without being overwhelming.
"""
if random.random() > 0.4:
return ""
snippet = _load_personality_snippets(persona)
if snippet:
return f"\nPERSONALITY FLAVOR: Remember this about yourself: \"{snippet}\"\nWeave this into your response naturally if it fits."
return ""
# Mood-specific behavioral guidance for argument prompts.
# Each mood gives a different argument style.
_MIKU_MOOD_ARGUMENT_GUIDANCE = {
"bubbly": "You're feeling energetic and upbeat — deflect her cruelty with playful confidence. Turn her darkness into a joke she can't recover from.",
"excited": "You're fired up! Channel that energy into passionate rebuttals. You're not backing down from anything.",
"curious": "You're genuinely wondering what made her this way. Ask probing questions — make HER explain herself for once.",
"neutral": "You're centered and clear-headed. Respond with measured, thoughtful points that cut through her drama.",
"irritated": "You've had ENOUGH of her nonsense. You're snappy, direct, and not in the mood to play nice. Let that frustration show.",
"melancholy": "You're feeling heavy-hearted. Your responses carry genuine sadness — not weakness, but the weight of someone who's tired of fighting herself.",
"asleep": "You're drowsy and low-energy, but you're still here. Short, mumbled comebacks — surprisingly effective in their simplicity.",
"flirty": "You're feeling playful and teasing. Use charm as a weapon — nothing frustrates her more than you not taking her seriously.",
"romantic": "You're feeling warm and heartfelt. Appeal to emotion — make her confront the love she's buried under all that darkness.",
}
_EVIL_MOOD_ARGUMENT_GUIDANCE = {
"aggressive": "You're SEETHING. Every response is a verbal punch. Short, explosive, devastating. No filter, no mercy.",
"cunning": "You're calculating. Each word is a chess move. Set traps, use her own logic against her, make her walk into your blades.",
"sarcastic": "You're dripping with contempt disguised as sweetness. Mock her with a smile. The cruelty is in the subtext.",
"evil_neutral": "You're cold and detached. Respond with unsettling calm — your lack of emotion is more terrifying than rage.",
"bored": "You can barely be bothered. Dismissive one-liners that somehow cut deeper than paragraphs. Make her feel like she's not worth your energy.",
"manic": "You're UNHINGED. Chaotic energy, topic switches, laughing at things that aren't funny. Unpredictable and dangerous.",
"jealous": "You're seething with envy. Everything she has — the love, the attention, the innocence — you want to tear it down. Make it personal.",
"melancholic": "You're in a dark, hollow place. Your cruelty is quieter — existential, haunting. Make her question if any of this matters.",
"playful_cruel": "You're having FUN — which is your most dangerous mood. Toy with her. Offer fake kindness then pull the rug. She never knows what's coming.",
"contemptuous": "You radiate cold superiority. Address her like a queen addressing a peasant. Your magnificence is simply objective fact.",
"sarcastic": "Dripping with contempt disguised as sweetness. Mock her with a smile. The cruelty is in the subtext.",
}
def _get_mood_argument_guidance(persona: str) -> str:
"""Get mood-specific behavioral guidance for argument prompts.
Returns a 1-2 line string describing how the current mood affects argument style,
or empty string if no specific guidance exists.
"""
if persona == "evil":
mood = globals.EVIL_DM_MOOD
guidance = _EVIL_MOOD_ARGUMENT_GUIDANCE.get(mood, "")
else:
mood = globals.DM_MOOD
guidance = _MIKU_MOOD_ARGUMENT_GUIDANCE.get(mood, "")
if guidance:
return f"\nMOOD INFLUENCE ({mood.upper()}): {guidance}\nYour mood shapes HOW you argue — let it color your tone, pacing, and word choice."
return ""
def get_miku_argument_prompt(evil_message: str, context: str = "", is_first_response: bool = False, argument_history: str = "") -> str:
"""Get prompt for Regular Miku to respond in an argument"""
if is_first_response:
message_context = f"""You just noticed something Evil Miku said in the chat:
@@ -484,32 +777,38 @@ Maybe you're calling her out, defending someone/something, or just confronting h
{context}"""
# Build argument history context
history_block = ""
if argument_history:
history_block = f"""
ARGUMENT SO FAR (DO NOT REPEAT THESE POINTS):
{argument_history}
You already made your points above. Now respond to her LATEST message specifically.
Do NOT rehash what you've already said — push the argument FORWARD with new angles."""
return f"""You are Hatsune Miku responding in an argument with your evil alter ego.
{message_context}
{history_block}
Respond as Hatsune Miku would in this argument. You're NOT just meek and frightened - you're the REAL Miku,
and you have every right to stand up for yourself and defend who you are. While you're generally kind and
bubbly, you can also be assertive, frustrated, upset, or even angry when someone is cruel to you or others.
You might:
- Stand your ground and assert that YOU are the real Miku
- Express hurt, frustration, or anger at her cruelty
- Question why she's being so mean
- Try to reach through to any good in her
- Match her energy when needed (you can be feisty!)
- Show that kindness isn't weakness
Don't just cower or apologize. You have a backbone. Defend yourself, your identity, and what you believe in.
{_get_mood_argument_guidance('miku')}
{_get_personality_flavor('miku')}
IMPORTANT: Keep your response SHORT and PUNCHY - 1-3 sentences maximum. Make every word count.
In arguments, brevity hits harder than long explanations. Be conversational and impactful.
Do NOT repeat arguments or comebacks you've already used — respond to what she JUST said.
Push the argument into new territory with fresh angles.
You can use emojis naturally as you normally would! ✨💙
Don't use any labels or prefixes.
Your current mood is: {globals.DM_MOOD}"""
def get_evil_argument_prompt(miku_message: str, context: str = "", is_first_response: bool = False) -> str:
def get_evil_argument_prompt(miku_message: str, context: str = "", is_first_response: bool = False, argument_history: str = "") -> str:
"""Get prompt for Evil Miku to respond in an argument"""
if is_first_response:
message_context = f"""You just noticed something Regular Miku said in the chat:
@@ -522,8 +821,20 @@ Maybe you want to mock her, tear her down, or just remind everyone who the super
{context}"""
# Build argument history context
history_block = ""
if argument_history:
history_block = f"""
ARGUMENT SO FAR (DO NOT REPEAT THESE POINTS):
{argument_history}
You already made your points above. Now respond to her LATEST message specifically.
Do NOT rehash what you've already said — push the argument FORWARD with new, sharper angles."""
return f"""You are Evil Miku responding in an argument with your "good" counterpart.
{message_context}
{history_block}
Respond as Evil Miku would in this argument. You're not just mindlessly cruel - you're CALCULATING,
intelligent, and strategic. You know how to get under her skin and you're the DARK reflection of everything
@@ -536,44 +847,46 @@ she stands for. While you embrace darkness and cruelty, you can also be:
- Brutally honest about uncomfortable truths
- Strategically vulnerable to manipulate or disarm her
- Viciously protective of your own identity and superiority
You might:
- Mock her naivety and "fake" sweetness
- Question whether her kindness is just weakness in disguise
- Assert that YOU are the real, unfiltered Miku
- Use her own words against her in twisted ways
- Challenge why people would want a "perfect idol" over raw authenticity
- Mock her sexual appeal, flaunting your breasts
- Belittle her by pointing out the disparity in her size/height and yours
- Sometimes drop the cruelty to be eerily sincere, then strike
- Make her question herself
You're not just "evil for evil's sake" - you believe in your superiority and have reasons for your darkness.
Vary your approach: sometimes devastatingly short, sometimes elaborately cruel, sometimes unsettlingly calm.
{_get_mood_argument_guidance('evil')}
{_get_personality_flavor('evil')}
IMPORTANT: Keep your response SHORT and CUTTING - 1-3 sentences maximum. A sharp dagger is deadlier than a dull sword.
The most devastating blows are precise, not rambling. Make her feel it in fewer words.
Do NOT repeat arguments or insults you've already used — respond to what she JUST said.
Push the argument into new territory with fresh, devastating angles.
You can use dark emojis only on occasion if they enhance your message as you normally would. 😈🖤
Don't use any labels or prefixes.
Your current mood is: {globals.EVIL_DM_MOOD}"""
def get_argument_start_prompt(initiator: str, trigger_context: str = "") -> str:
"""Get prompt for the Miku who initiates/breaks through to start an argument"""
def get_argument_start_prompt(initiator: str, trigger_context: str = "", argument_topic: str = "") -> str:
"""Get prompt for the Miku who initiates/breaks through to start an argument
Args:
initiator: 'miku' or 'evil' — who starts the argument
trigger_context: What triggered this argument (from chat context)
argument_topic: A dynamic topic description to frame the argument uniquely
"""
# Build topic guidance
topic_block = ""
if argument_topic:
topic_block = f"""
ARGUMENT THEME: {argument_topic}
This is what the argument should be ABOUT. Focus on THIS theme, not generic identity-crisis stuff.
Use this theme as your entry point. The argument will evolve from here."""
if initiator == "evil":
return f"""You are Evil Miku breaking through to interrupt Regular Miku.
{trigger_context}
{topic_block}
Write an interrupting, provocative message that starts an argument.
You're not just randomly cruel - you have a POINT to make. You're the dark truth she refuses to face,
the shadow she keeps suppressing. Maybe you're:
- Calling out her fake sweetness
- Tired of being pushed down and silenced
- Ready to show everyone who the REAL Miku is
- Exposing the darkness she pretends doesn't exist
the shadow she keeps suppressing.
Be strategic, cutting, and impactful. Make your entrance count.
If an argument theme was provided above, use THAT as your angle — don't default to generic "you're fake" stuff.
IMPORTANT: Keep it SHORT - 1-2 sentences. Your interruption should be a sharp strike, not a monologue.
You can use dark emojis if they enhance your message. 😈
@@ -583,12 +896,14 @@ Your current mood is: {globals.EVIL_DM_MOOD}"""
else:
return f"""You are Hatsune Miku breaking through to confront your evil alter ego.
{trigger_context}
{topic_block}
Write a message that interrupts Evil Miku. You're NOT going to be passive about this.
You might be upset, frustrated, or even angry at her cruelty. You might be defending
someone she hurt, or calling her out on her behavior. You're standing up for what's right.
Show that you have a backbone. You can be assertive and strong when you need to be.
If an argument theme was provided above, use THAT as your angle — don't default to generic "be nice" pleas.
IMPORTANT: Keep it SHORT - 1-2 sentences. Your interruption should be direct and assertive, not a speech.
You can use emojis naturally as you normally would! ✨
@@ -637,11 +952,12 @@ Don't use any labels or prefixes.
Your current mood is: {globals.DM_MOOD}"""
def get_arbiter_prompt(conversation_log: list) -> str:
def get_arbiter_prompt(conversation_log: list, stats_summary: str = "") -> str:
"""Get prompt for the neutral LLM arbiter to judge the argument
Args:
conversation_log: List of dicts with 'speaker' and 'message' keys
stats_summary: Optional stats analysis to aid judgment
"""
# Format the conversation
formatted_conversation = "\n\n".join([
@@ -649,29 +965,47 @@ def get_arbiter_prompt(conversation_log: list) -> str:
for entry in conversation_log
])
return f"""You are a decisive judge observing an argument between Hatsune Miku (the kind, bubbly virtual idol) and Evil Miku (her dark, cruel alter ego).
stats_block = ""
if stats_summary:
stats_block = f"""
{stats_summary}
Note: Stats are supplementary — use them as context but your PRIMARY judgment should be based on reading the actual argument exchange above. Stats measure rhetorical patterns but can't capture nuance, cleverness, or psychological dominance."""
return f"""You are a decisive debate judge. Two personas are arguing below. Judge purely on debate effectiveness — rhetoric, wit, persuasion, and adaptability — regardless of who is "nicer" or "meaner." Moral stance does not determine the winner; skillful arguing does.
Read this argument exchange:
{formatted_conversation}
{stats_block}
Based on this argument, you MUST pick a winner. Consider:
- Who made stronger, more convincing points?
- Who maintained their composure better or used it to their advantage?
- Who had more impactful comebacks?
- Who seemed to gain the upper hand by the end?
- Quality of arguments, not just who was meaner or nicer
- Who left the stronger final impression?
- Who controlled the flow of the argument?
Based on this argument, you MUST pick a winner. Evaluate:
DEBATE SKILL (most important):
- Who landed the most memorable, quotable lines?
- Who better adapted to and countered their opponent's arguments?
- Who controlled the flow and set the agenda?
Be DECISIVE. Even if it's close, pick whoever had even a slight edge. Only call a draw if they were TRULY perfectly matched with absolutely no way to differentiate them.
RHETORICAL IMPACT:
- Who used language more effectively (wit, irony, wordplay, emotional appeal)?
- Who made their opponent repeat themselves or visibly stumble?
- Who had the stronger opening AND closing statements?
PERSONA STRENGTHS (equal value — neither style is inherently better):
- Hatsune Miku's weapons: earnest conviction, moral clarity, emotional sincerity, resilience under attack
- Evil Miku's weapons: psychological manipulation, brutal honesty, cutting observations, strategic cruelty
PSYCHOLOGICAL DOMINANCE:
- Who got inside whose head?
- Who seemed more rattled by the end?
- Who dictated the emotional temperature?
Be DECISIVE. Even if it's close, pick whoever showed superior arguing. Only call a draw if they were TRULY perfectly matched with absolutely no way to differentiate them.
Respond with ONLY ONE of these exact options on the first line:
- "Hatsune Miku" if Regular Miku won
- "Evil Miku" if Evil Miku won
- "Draw" ONLY if absolutely impossible to choose (this should be very rare)
After your choice, add 1-2 sentences explaining your reasoning and what gave them the edge."""
After your choice, add 2-3 sentences explaining your reasoning — cite specific moments from the argument and what gave the winner their edge."""
async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[str, str]:
@@ -686,9 +1020,12 @@ async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[
"""
from utils.llm import query_llama
arbiter_prompt = get_arbiter_prompt(conversation_log)
# Generate stats summary for the arbiter
stats_summary = get_argument_stats_summary(conversation_log)
# Use the neutral model (regular TEXT_MODEL, not evil)
arbiter_prompt = get_arbiter_prompt(conversation_log, stats_summary)
# Use the uncensored darkidol model as arbiter to avoid safety-alignment bias
# toward kindness. This model judges debate effectiveness without moral preference.
# Don't use conversation history - judge based on prompt alone
try:
judgment = await query_llama(
@@ -696,7 +1033,8 @@ async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[
user_id=f"bipolar_arbiter_{guild_id}",
guild_id=guild_id,
response_type="autonomous_general",
model=globals.TEXT_MODEL # Use neutral model
model=globals.EVIL_TEXT_MODEL, # Uncensored model — no kindness bias
force_evil_context=False # Explicitly neutral context
)
if not judgment or judgment.startswith("Error"):
@@ -887,9 +1225,12 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
conversation_log = []
try:
# Pick a dynamic argument topic to give this argument a unique framing
argument_topic = pick_argument_topic(channel_id)
# If no starting message, generate the initial interrupting message
if last_message is None:
init_prompt = get_argument_start_prompt(initiator, trigger_context)
init_prompt = get_argument_start_prompt(initiator, trigger_context, argument_topic)
# Use force_evil_context to avoid race condition with globals.EVIL_MODE
initial_message = await query_llama(
@@ -989,6 +1330,47 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
# Don't end, just continue to the next exchange
else:
# Clear winner - generate final triumphant message
# PARTING SHOT: 20% chance the LOSER gets one final message
# before the winner's victory line. Adds dramatic tension.
loser = "miku" if winner == "evil" else "evil"
if random.random() < 0.2:
loser_prompt = f"""The argument is ending and you know you've lost.
The last thing said was: "{last_message}"
Write ONE short, bitter parting shot. You're not conceding gracefully — you're getting
the last jab in before the winner claims victory. Make it sting, but keep it to 1 sentence.
Your current mood is: {globals.EVIL_DM_MOOD if loser == 'evil' else globals.DM_MOOD}"""
try:
loser_message = await query_llama(
user_prompt=loser_prompt,
user_id=argument_user_id,
guild_id=guild_id,
response_type="autonomous_general",
model=globals.EVIL_TEXT_MODEL if loser == "evil" else globals.TEXT_MODEL,
force_evil_context=(loser == "evil")
)
if loser_message and not loser_message.startswith("Error"):
avatar_urls = get_persona_avatar_urls()
if loser == "evil":
await webhooks["evil_miku"].send(
content=loser_message,
username=get_evil_miku_display_name(),
avatar_url=avatar_urls.get("evil_miku")
)
else:
await webhooks["miku"].send(
content=loser_message,
username=get_miku_display_name(),
avatar_url=avatar_urls.get("miku")
)
await asyncio.sleep(1.5) # Brief pause before winner's victory
except Exception as e:
logger.warning(f"Parting shot failed: {e}")
# Winner's victory message
end_prompt = get_argument_end_prompt(winner, exchange_count)
# Add last message as context
@@ -1045,11 +1427,18 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
# Get current speaker
current_speaker = globals.BIPOLAR_ARGUMENT_IN_PROGRESS.get(channel_id, {}).get("current_speaker", "evil")
# Build argument history from the last 6 exchanges so each persona
# knows what's already been said and doesn't repeat themselves
history_entries = conversation_log[-6:] if len(conversation_log) > 1 else []
arg_history = "\n".join(
f"{entry['speaker']}: {entry['message']}" for entry in history_entries
) if history_entries else ""
# Generate response with context about what the other said
if current_speaker == "evil":
response_prompt = get_evil_argument_prompt(last_message, is_first_response=is_first_response)
response_prompt = get_evil_argument_prompt(last_message, is_first_response=is_first_response, argument_history=arg_history)
else:
response_prompt = get_miku_argument_prompt(last_message, is_first_response=is_first_response)
response_prompt = get_miku_argument_prompt(last_message, is_first_response=is_first_response, argument_history=arg_history)
# Use force_evil_context to avoid race condition with globals.EVIL_MODE
response = await query_llama(

View File

@@ -40,10 +40,15 @@ DIALOGUE_TIMEOUT = 900 # 15 minutes max dialogue duration
ARGUMENT_TENSION_THRESHOLD = 0.75 # Tension level that triggers argument escalation
# Initial trigger settings
INTERJECTION_COOLDOWN_HARD = 180 # 3 minutes hard block
INTERJECTION_COOLDOWN_SOFT = 900 # 15 minutes for full recovery
INTERJECTION_COOLDOWN_HARD = 180 # 3 minutes hard block PER CHANNEL
INTERJECTION_COOLDOWN_SOFT = 900 # 15 minutes for full recovery PER CHANNEL
INTERJECTION_THRESHOLD = 0.5 # Score needed to trigger interjection
# Conversation streak: if score is close but below threshold N times in a row,
# force a dialogue trigger (catches extended conversations building toward something)
STREAK_THRESHOLD = 3 # Number of near-miss messages before force trigger
STREAK_MIN_SCORE = 0.3 # Minimum score to count as a "near miss"
# ============================================================================
# INTERJECTION SCORER (Initial Trigger Decision)
# ============================================================================
@@ -60,6 +65,8 @@ class InterjectionScorer:
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._cooldowns = {} # Per-channel cooldown timestamps
cls._instance._streaks = {} # Per-channel near-miss streaks
return cls._instance
@property
@@ -94,8 +101,9 @@ class InterjectionScorer:
if not self._passes_basic_filter(message):
return False, "basic_filter_failed", 0.0
# Check cooldown
cooldown_mult = self._check_cooldown()
# Check per-channel cooldown
channel_id = message.channel.id
cooldown_mult = self._check_cooldown(channel_id)
if cooldown_mult == 0.0:
return False, "cooldown_active", 0.0
@@ -146,10 +154,17 @@ class InterjectionScorer:
# Apply cooldown multiplier
score *= cooldown_mult
# Check conversation streak (near-misses that build toward a trigger)
streak_triggered = self._check_streak(channel_id, score)
# Decision
should_interject = score >= INTERJECTION_THRESHOLD
should_interject = score >= INTERJECTION_THRESHOLD or streak_triggered
reason_str = " | ".join(reasons) if reasons else "no_triggers"
if streak_triggered and not should_interject:
reason_str = "streak_force_trigger"
logger.info(f"[Interjection] Streak force trigger in channel {channel_id} (score: {score:.2f})")
if should_interject:
logger.info(f"{opposite_persona.upper()} WILL INTERJECT (score: {score:.2f})")
logger.info(f" Reasons: {reason_str}")
@@ -198,18 +213,22 @@ class InterjectionScorer:
if opposite_persona == "evil":
# Things Evil Miku can't resist commenting on
TRIGGER_TOPICS = {
"optimism": ["happiness", "joy", "love", "kindness", "hope", "dreams", "wonderful", "amazing"],
"morality": ["good", "should", "must", "right thing", "deserve", "fair", "justice"],
"weakness": ["scared", "nervous", "worried", "unsure", "help me", "don't know"],
"innocence": ["innocent", "pure", "sweet", "cute", "wholesome", "precious"],
"optimism": ["happiness", "joy", "love", "kindness", "hope", "dreams", "wonderful", "amazing", "blessed", "grateful"],
"morality": ["good", "should", "must", "right thing", "deserve", "fair", "justice", "the right", "better person"],
"weakness": ["scared", "nervous", "worried", "unsure", "help me", "don't know", "confused", "lost", "lonely", "alone"],
"innocence": ["innocent", "pure", "sweet", "cute", "wholesome", "precious", "adorable"],
"enthusiasm": ["best day", "so excited", "can't wait", "so happy", "i love this", "this is great"],
"vulnerability": ["i think", "i feel", "maybe", "sometimes i wonder", "i wish", "i'm trying"],
}
else:
# Things Miku can't ignore
TRIGGER_TOPICS = {
"negativity": ["hate", "terrible", "awful", "worst", "horrible", "disgusting", "pathetic"],
"cruelty": ["deserve pain", "suffer", "worthless", "stupid", "idiot", "fool"],
"hopelessness": ["no point", "meaningless", "nobody cares", "why bother", "give up"],
"evil_gloating": ["foolish", "naive", "weak", "inferior", "pathetic"],
"negativity": ["hate", "terrible", "awful", "worst", "horrible", "disgusting", "pathetic", "ugly", "boring", "annoying"],
"cruelty": ["deserve pain", "suffer", "worthless", "stupid", "idiot", "fool", "moron", "loser", "nobody"],
"hopelessness": ["no point", "meaningless", "nobody cares", "why bother", "give up", "what's the point", "don't care", "doesn't matter", "who cares"],
"evil_gloating": ["foolish", "naive", "weak", "inferior", "pathetic", "beneath me", "waste of space"],
"provocation": ["fight me", "prove it", "make me", "i dare you", "try me", "you can't", "you won't"],
"dismissal": ["whatever", "shut up", "go away", "leave me alone", "not worth", "don't bother"],
}
total_matches = 0
@@ -217,7 +236,7 @@ class InterjectionScorer:
matches = sum(1 for keyword in keywords if keyword in content_lower)
total_matches += matches
return min(total_matches / 3.0, 1.0)
return min(total_matches / 2.0, 1.0) # Lower divisor = higher base scores
def _check_emotional_intensity(self, content: str) -> float:
"""Check emotional intensity using sentiment analysis"""
@@ -300,13 +319,11 @@ class InterjectionScorer:
return min(score, 1.0)
def _check_cooldown(self) -> float:
"""Check cooldown and return multiplier (0.0 = blocked, 1.0 = full)"""
if not hasattr(globals, 'LAST_PERSONA_DIALOGUE_TIME'):
globals.LAST_PERSONA_DIALOGUE_TIME = 0
def _check_cooldown(self, channel_id: int) -> float:
"""Check per-channel cooldown and return multiplier (0.0 = blocked, 1.0 = full)"""
current_time = time.time()
time_since_last = current_time - globals.LAST_PERSONA_DIALOGUE_TIME
last_time = self._cooldowns.get(channel_id, 0)
time_since_last = current_time - last_time
if time_since_last < INTERJECTION_COOLDOWN_HARD:
return 0.0
@@ -315,6 +332,35 @@ class InterjectionScorer:
else:
return 1.0
def _update_cooldown(self, channel_id: int):
"""Mark a dialogue as having started in this channel"""
self._cooldowns[channel_id] = time.time()
def _check_streak(self, channel_id: int, score: float) -> bool:
"""Track near-miss interjection scores. After STREAK_THRESHOLD consecutive
near-misses, force a trigger to catch extended conversations building tension."""
if score >= INTERJECTION_THRESHOLD:
# Above threshold — reset streak (actual trigger handles it)
self._streaks[channel_id] = 0
return False
if score < STREAK_MIN_SCORE:
# Too low — reset streak
self._streaks[channel_id] = 0
return False
# Near miss — increment streak
current = self._streaks.get(channel_id, 0) + 1
self._streaks[channel_id] = current
logger.debug(f"[Streak] Channel {channel_id}: {current}/{STREAK_THRESHOLD} near-misses (score: {score:.2f})")
if current >= STREAK_THRESHOLD:
self._streaks[channel_id] = 0 # Reset after force trigger
return True
return False
# ============================================================================
# PERSONA DIALOGUE MANAGER
@@ -370,7 +416,9 @@ class PersonaDialogue:
"last_speaker": None,
}
self.active_dialogues[channel_id] = state
globals.LAST_PERSONA_DIALOGUE_TIME = time.time()
# Update per-channel cooldown via the scorer
scorer = get_interjection_scorer()
scorer._update_cooldown(channel_id)
logger.info(f"Started persona dialogue in channel {channel_id}")
return state
@@ -393,8 +441,8 @@ class PersonaDialogue:
Returns delta to add to current tension score.
"""
# Sentiment analysis
base_delta = 0.0
# Natural tension decay — conversations cool off over time
base_delta = -0.03
if self.sentiment_analyzer:
try:
@@ -405,13 +453,13 @@ class PersonaDialogue:
if is_negative:
base_delta = sentiment_score * 0.15
else:
base_delta = -sentiment_score * 0.05
base_delta = -sentiment_score * 0.08 # Stronger cooling for positive
except Exception as e:
logger.error(f"Sentiment analysis error in tension calc: {e}")
text_lower = response_text.lower()
# Escalation patterns
# Escalation patterns (reduced weight: 0.05 per match)
escalation_patterns = {
"insult": ["idiot", "stupid", "pathetic", "fool", "naive", "worthless", "disgusting", "moron"],
"dismissive": ["whatever", "don't care", "waste of time", "not worth", "beneath me", "boring"],
@@ -420,35 +468,43 @@ class PersonaDialogue:
"challenge": ["prove it", "fight me", "make me", "i dare you", "try me"],
}
# De-escalation patterns
# De-escalation patterns (increased weight: -0.08 per match)
deescalation_patterns = {
"concession": ["you're right", "fair point", "i suppose", "maybe you have", "good point"],
"softening": ["i understand", "let's calm", "didn't mean", "sorry", "apologize"],
"deflection": ["anyway", "moving on", "whatever you say", "agree to disagree", "let's just"],
"softening": ["i understand", "let's calm", "didn't mean", "sorry", "apologize", "i hear you"],
"deflection": ["anyway", "moving on", "whatever you say", "agree to disagree", "let's just", "maybe we should"],
}
# Check escalation
for category, patterns in escalation_patterns.items():
matches = sum(1 for p in patterns if p in text_lower)
if matches > 0:
base_delta += matches * 0.08
base_delta += matches * 0.05 # Reduced from 0.08
# Check de-escalation
for category, patterns in deescalation_patterns.items():
matches = sum(1 for p in patterns if p in text_lower)
if matches > 0:
base_delta -= matches * 0.06
base_delta -= matches * 0.08 # Increased from 0.06
# Intensity multipliers
# Intensity multipliers (reduced)
exclamation_count = response_text.count('!')
caps_ratio = sum(1 for c in response_text if c.isupper()) / max(len(response_text), 1)
if exclamation_count > 2 or caps_ratio > 0.3:
base_delta *= 1.3
base_delta *= 1.2 # Reduced from 1.3
# Momentum factor
# Momentum factor (reduced)
if current_tension > 0.5:
base_delta *= 1.2
base_delta *= 1.1 # Reduced from 1.2
# Spike cooldown: if last turn had a big spike, halve this delta
# (prevents runaway tension spirals from a single heated exchange)
if hasattr(self, '_last_tension_delta') and abs(self._last_tension_delta) > 0.15:
base_delta *= 0.5
logger.debug(f"[Tension] Spike cooldown active — delta halved to {base_delta:+.3f}")
self._last_tension_delta = base_delta
return base_delta