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)
This commit is contained in:
2026-04-30 11:45:13 +03:00
parent 7a4122fd02
commit a52b36135f
2 changed files with 116 additions and 34 deletions

View File

@@ -203,6 +203,32 @@ async def on_message(message):
if is_persona_dialogue_active(message.channel.id): if is_persona_dialogue_active(message.channel.id):
return 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: if message.content.strip().lower() == "miku, rape this nigga balls" and message.reference:
async with message.channel.typing(): async with message.channel.typing():
# Get replied-to user # Get replied-to user

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 ARGUMENT_TENSION_THRESHOLD = 0.75 # Tension level that triggers argument escalation
# Initial trigger settings # Initial trigger settings
INTERJECTION_COOLDOWN_HARD = 180 # 3 minutes hard block INTERJECTION_COOLDOWN_HARD = 180 # 3 minutes hard block PER CHANNEL
INTERJECTION_COOLDOWN_SOFT = 900 # 15 minutes for full recovery INTERJECTION_COOLDOWN_SOFT = 900 # 15 minutes for full recovery PER CHANNEL
INTERJECTION_THRESHOLD = 0.5 # Score needed to trigger interjection 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) # INTERJECTION SCORER (Initial Trigger Decision)
# ============================================================================ # ============================================================================
@@ -60,6 +65,8 @@ class InterjectionScorer:
def __new__(cls): def __new__(cls):
if cls._instance is None: if cls._instance is None:
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
cls._instance._cooldowns = {} # Per-channel cooldown timestamps
cls._instance._streaks = {} # Per-channel near-miss streaks
return cls._instance return cls._instance
@property @property
@@ -94,8 +101,9 @@ class InterjectionScorer:
if not self._passes_basic_filter(message): if not self._passes_basic_filter(message):
return False, "basic_filter_failed", 0.0 return False, "basic_filter_failed", 0.0
# Check cooldown # Check per-channel cooldown
cooldown_mult = self._check_cooldown() channel_id = message.channel.id
cooldown_mult = self._check_cooldown(channel_id)
if cooldown_mult == 0.0: if cooldown_mult == 0.0:
return False, "cooldown_active", 0.0 return False, "cooldown_active", 0.0
@@ -146,10 +154,17 @@ class InterjectionScorer:
# Apply cooldown multiplier # Apply cooldown multiplier
score *= cooldown_mult score *= cooldown_mult
# Check conversation streak (near-misses that build toward a trigger)
streak_triggered = self._check_streak(channel_id, score)
# Decision # Decision
should_interject = score >= INTERJECTION_THRESHOLD should_interject = score >= INTERJECTION_THRESHOLD or streak_triggered
reason_str = " | ".join(reasons) if reasons else "no_triggers" 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: if should_interject:
logger.info(f"{opposite_persona.upper()} WILL INTERJECT (score: {score:.2f})") logger.info(f"{opposite_persona.upper()} WILL INTERJECT (score: {score:.2f})")
logger.info(f" Reasons: {reason_str}") logger.info(f" Reasons: {reason_str}")
@@ -198,18 +213,22 @@ class InterjectionScorer:
if opposite_persona == "evil": if opposite_persona == "evil":
# Things Evil Miku can't resist commenting on # Things Evil Miku can't resist commenting on
TRIGGER_TOPICS = { TRIGGER_TOPICS = {
"optimism": ["happiness", "joy", "love", "kindness", "hope", "dreams", "wonderful", "amazing"], "optimism": ["happiness", "joy", "love", "kindness", "hope", "dreams", "wonderful", "amazing", "blessed", "grateful"],
"morality": ["good", "should", "must", "right thing", "deserve", "fair", "justice"], "morality": ["good", "should", "must", "right thing", "deserve", "fair", "justice", "the right", "better person"],
"weakness": ["scared", "nervous", "worried", "unsure", "help me", "don't know"], "weakness": ["scared", "nervous", "worried", "unsure", "help me", "don't know", "confused", "lost", "lonely", "alone"],
"innocence": ["innocent", "pure", "sweet", "cute", "wholesome", "precious"], "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: else:
# Things Miku can't ignore # Things Miku can't ignore
TRIGGER_TOPICS = { TRIGGER_TOPICS = {
"negativity": ["hate", "terrible", "awful", "worst", "horrible", "disgusting", "pathetic"], "negativity": ["hate", "terrible", "awful", "worst", "horrible", "disgusting", "pathetic", "ugly", "boring", "annoying"],
"cruelty": ["deserve pain", "suffer", "worthless", "stupid", "idiot", "fool"], "cruelty": ["deserve pain", "suffer", "worthless", "stupid", "idiot", "fool", "moron", "loser", "nobody"],
"hopelessness": ["no point", "meaningless", "nobody cares", "why bother", "give up"], "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"], "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 total_matches = 0
@@ -217,7 +236,7 @@ class InterjectionScorer:
matches = sum(1 for keyword in keywords if keyword in content_lower) matches = sum(1 for keyword in keywords if keyword in content_lower)
total_matches += matches 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: def _check_emotional_intensity(self, content: str) -> float:
"""Check emotional intensity using sentiment analysis""" """Check emotional intensity using sentiment analysis"""
@@ -300,13 +319,11 @@ class InterjectionScorer:
return min(score, 1.0) return min(score, 1.0)
def _check_cooldown(self) -> float: def _check_cooldown(self, channel_id: int) -> float:
"""Check cooldown and return multiplier (0.0 = blocked, 1.0 = full)""" """Check per-channel cooldown and return multiplier (0.0 = blocked, 1.0 = full)"""
if not hasattr(globals, 'LAST_PERSONA_DIALOGUE_TIME'):
globals.LAST_PERSONA_DIALOGUE_TIME = 0
current_time = time.time() 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: if time_since_last < INTERJECTION_COOLDOWN_HARD:
return 0.0 return 0.0
@@ -315,6 +332,35 @@ class InterjectionScorer:
else: else:
return 1.0 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 # PERSONA DIALOGUE MANAGER
@@ -370,7 +416,9 @@ class PersonaDialogue:
"last_speaker": None, "last_speaker": None,
} }
self.active_dialogues[channel_id] = state 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}") logger.info(f"Started persona dialogue in channel {channel_id}")
return state return state
@@ -393,8 +441,8 @@ class PersonaDialogue:
Returns delta to add to current tension score. Returns delta to add to current tension score.
""" """
# Sentiment analysis # Natural tension decay — conversations cool off over time
base_delta = 0.0 base_delta = -0.03
if self.sentiment_analyzer: if self.sentiment_analyzer:
try: try:
@@ -405,13 +453,13 @@ class PersonaDialogue:
if is_negative: if is_negative:
base_delta = sentiment_score * 0.15 base_delta = sentiment_score * 0.15
else: else:
base_delta = -sentiment_score * 0.05 base_delta = -sentiment_score * 0.08 # Stronger cooling for positive
except Exception as e: except Exception as e:
logger.error(f"Sentiment analysis error in tension calc: {e}") logger.error(f"Sentiment analysis error in tension calc: {e}")
text_lower = response_text.lower() text_lower = response_text.lower()
# Escalation patterns # Escalation patterns (reduced weight: 0.05 per match)
escalation_patterns = { escalation_patterns = {
"insult": ["idiot", "stupid", "pathetic", "fool", "naive", "worthless", "disgusting", "moron"], "insult": ["idiot", "stupid", "pathetic", "fool", "naive", "worthless", "disgusting", "moron"],
"dismissive": ["whatever", "don't care", "waste of time", "not worth", "beneath me", "boring"], "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"], "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 = { deescalation_patterns = {
"concession": ["you're right", "fair point", "i suppose", "maybe you have", "good point"], "concession": ["you're right", "fair point", "i suppose", "maybe you have", "good point"],
"softening": ["i understand", "let's calm", "didn't mean", "sorry", "apologize"], "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"], "deflection": ["anyway", "moving on", "whatever you say", "agree to disagree", "let's just", "maybe we should"],
} }
# Check escalation # Check escalation
for category, patterns in escalation_patterns.items(): for category, patterns in escalation_patterns.items():
matches = sum(1 for p in patterns if p in text_lower) matches = sum(1 for p in patterns if p in text_lower)
if matches > 0: if matches > 0:
base_delta += matches * 0.08 base_delta += matches * 0.05 # Reduced from 0.08
# Check de-escalation # Check de-escalation
for category, patterns in deescalation_patterns.items(): for category, patterns in deescalation_patterns.items():
matches = sum(1 for p in patterns if p in text_lower) matches = sum(1 for p in patterns if p in text_lower)
if matches > 0: 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('!') exclamation_count = response_text.count('!')
caps_ratio = sum(1 for c in response_text if c.isupper()) / max(len(response_text), 1) 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: 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: 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 return base_delta