feat: Implement comprehensive non-hierarchical logging system

- Created new logging infrastructure with per-component filtering
- Added 6 log levels: DEBUG, INFO, API, WARNING, ERROR, CRITICAL
- Implemented non-hierarchical level control (any combination can be enabled)
- Migrated 917 print() statements across 31 files to structured logging
- Created web UI (system.html) for runtime configuration with dark theme
- Added global level controls to enable/disable levels across all components
- Added timestamp format control (off/time/date/datetime options)
- Implemented log rotation (10MB per file, 5 backups)
- Added API endpoints for dynamic log configuration
- Configured HTTP request logging with filtering via api.requests component
- Intercepted APScheduler logs with proper formatting
- Fixed persistence paths to use /app/memory for Docker volume compatibility
- Fixed checkbox display bug in web UI (enabled_levels now properly shown)
- Changed System Settings button to open in same tab instead of new window

Components: bot, api, api.requests, autonomous, persona, vision, llm,
conversation, mood, dm, scheduled, gpu, media, server, commands,
sentiment, core, apscheduler

All settings persist across container restarts via JSON config.
This commit is contained in:
2026-01-10 20:46:19 +02:00
parent ce00f9bd95
commit 32c2a7b930
34 changed files with 2766 additions and 936 deletions

View File

@@ -11,6 +11,9 @@ import random
import asyncio
import discord
import globals
from utils.logger import get_logger
logger = get_logger('persona')
# ============================================================================
# CONSTANTS
@@ -38,26 +41,26 @@ def save_bipolar_state():
}
with open(BIPOLAR_STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2)
print(f"💾 Saved bipolar mode state: enabled={globals.BIPOLAR_MODE}")
logger.info(f"Saved bipolar mode state: enabled={globals.BIPOLAR_MODE}")
except Exception as e:
print(f"⚠️ Failed to save bipolar mode state: {e}")
logger.error(f"Failed to save bipolar mode state: {e}")
def load_bipolar_state():
"""Load bipolar mode state from JSON file"""
try:
if not os.path.exists(BIPOLAR_STATE_FILE):
print(" No bipolar mode state file found, using defaults")
logger.info("No bipolar mode state file found, using defaults")
return False
with open(BIPOLAR_STATE_FILE, "r", encoding="utf-8") as f:
state = json.load(f)
bipolar_mode = state.get("bipolar_mode_enabled", False)
print(f"📂 Loaded bipolar mode state: enabled={bipolar_mode}")
logger.info(f"Loaded bipolar mode state: enabled={bipolar_mode}")
return bipolar_mode
except Exception as e:
print(f"⚠️ Failed to load bipolar mode state: {e}")
logger.error(f"Failed to load bipolar mode state: {e}")
return False
@@ -71,16 +74,16 @@ def save_webhooks():
with open(BIPOLAR_WEBHOOKS_FILE, "w", encoding="utf-8") as f:
json.dump(webhooks_data, f, indent=2)
print(f"💾 Saved bipolar webhooks for {len(webhooks_data)} server(s)")
logger.info(f"Saved bipolar webhooks for {len(webhooks_data)} server(s)")
except Exception as e:
print(f"⚠️ Failed to save bipolar webhooks: {e}")
logger.error(f"Failed to save bipolar webhooks: {e}")
def load_webhooks():
"""Load webhook URLs from JSON file"""
try:
if not os.path.exists(BIPOLAR_WEBHOOKS_FILE):
print(" No bipolar webhooks file found")
logger.info("No bipolar webhooks file found")
return {}
with open(BIPOLAR_WEBHOOKS_FILE, "r", encoding="utf-8") as f:
@@ -91,10 +94,10 @@ def load_webhooks():
for guild_id_str, webhook_data in webhooks_data.items():
webhooks[int(guild_id_str)] = webhook_data
print(f"📂 Loaded bipolar webhooks for {len(webhooks)} server(s)")
logger.info(f"Loaded bipolar webhooks for {len(webhooks)} server(s)")
return webhooks
except Exception as e:
print(f"⚠️ Failed to load bipolar webhooks: {e}")
logger.error(f"Failed to load bipolar webhooks: {e}")
return {}
@@ -105,8 +108,8 @@ def restore_bipolar_mode_on_startup():
globals.BIPOLAR_WEBHOOKS = load_webhooks()
if bipolar_mode:
print("🔄 Bipolar mode restored from previous session")
print("💬 Persona dialogue system enabled (natural conversations + arguments)")
logger.info("Bipolar mode restored from previous session")
logger.info("Persona dialogue system enabled (natural conversations + arguments)")
return bipolar_mode
@@ -124,7 +127,7 @@ def load_scoreboard() -> dict:
with open(BIPOLAR_SCOREBOARD_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"⚠️ Failed to load scoreboard: {e}")
logger.error(f"Failed to load scoreboard: {e}")
return {"miku": 0, "evil": 0, "history": []}
@@ -134,9 +137,9 @@ def save_scoreboard(scoreboard: dict):
os.makedirs(os.path.dirname(BIPOLAR_SCOREBOARD_FILE), exist_ok=True)
with open(BIPOLAR_SCOREBOARD_FILE, "w", encoding="utf-8") as f:
json.dump(scoreboard, f, indent=2)
print(f"💾 Saved scoreboard: Miku {scoreboard['miku']} - {scoreboard['evil']} Evil Miku")
logger.info(f"Saved scoreboard: Miku {scoreboard['miku']} - {scoreboard['evil']} Evil Miku")
except Exception as e:
print(f"⚠️ Failed to save scoreboard: {e}")
logger.error(f"Failed to save scoreboard: {e}")
def record_argument_result(winner: str, exchanges: int, reasoning: str = ""):
@@ -205,7 +208,7 @@ def enable_bipolar_mode():
"""Enable bipolar mode"""
globals.BIPOLAR_MODE = True
save_bipolar_state()
print("🔄 Bipolar mode enabled!")
logger.info("Bipolar mode enabled!")
def disable_bipolar_mode():
@@ -214,7 +217,7 @@ def disable_bipolar_mode():
# Clear any ongoing arguments
globals.BIPOLAR_ARGUMENT_IN_PROGRESS.clear()
save_bipolar_state()
print("🔄 Bipolar mode disabled!")
logger.info("Bipolar mode disabled!")
def toggle_bipolar_mode() -> bool:
@@ -256,11 +259,11 @@ async def get_or_create_webhooks_for_channel(channel: discord.TextChannel) -> di
if miku_webhook and evil_webhook:
return {"miku": miku_webhook, "evil_miku": evil_webhook}
except Exception as e:
print(f"⚠️ Failed to retrieve cached webhooks: {e}")
logger.warning(f"Failed to retrieve cached webhooks: {e}")
# Create new webhooks
try:
print(f"🔧 Creating bipolar webhooks for channel #{channel.name}")
logger.info(f"Creating bipolar webhooks for channel #{channel.name}")
# Load avatar images
miku_avatar = None
@@ -300,14 +303,14 @@ async def get_or_create_webhooks_for_channel(channel: discord.TextChannel) -> di
}
save_webhooks()
print(f"Created bipolar webhooks for #{channel.name}")
logger.info(f"Created bipolar webhooks for #{channel.name}")
return {"miku": miku_webhook, "evil_miku": evil_webhook}
except discord.Forbidden:
print(f"Missing permissions to create webhooks in #{channel.name}")
logger.error(f"Missing permissions to create webhooks in #{channel.name}")
return None
except Exception as e:
print(f"Failed to create webhooks: {e}")
logger.error(f"Failed to create webhooks: {e}")
return None
@@ -322,11 +325,11 @@ async def cleanup_webhooks(client):
await webhook.delete(reason="Bipolar mode cleanup")
cleaned_count += 1
except Exception as e:
print(f"⚠️ Failed to cleanup webhooks in {guild.name}: {e}")
logger.warning(f"Failed to cleanup webhooks in {guild.name}: {e}")
globals.BIPOLAR_WEBHOOKS.clear()
save_webhooks()
print(f"🧹 Cleaned up {cleaned_count} bipolar webhook(s)")
logger.info(f"Cleaned up {cleaned_count} bipolar webhook(s)")
return cleaned_count
@@ -602,7 +605,7 @@ async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[
)
if not judgment or judgment.startswith("Error"):
print("⚠️ Arbiter failed to make judgment, defaulting to draw")
logger.warning("Arbiter failed to make judgment, defaulting to draw")
return "draw", "The arbiter could not make a decision."
# Parse the judgment - look at the first line/sentence for the decision
@@ -610,37 +613,37 @@ async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[
first_line = judgment_lines[0].strip().strip('"').strip()
first_line_lower = first_line.lower()
print(f"🔍 Parsing arbiter first line: '{first_line}'")
logger.debug(f"Parsing arbiter first line: '{first_line}'")
# Check the first line for the decision - be very specific
# The arbiter should respond with ONLY the name on the first line
if first_line_lower == "evil miku":
winner = "evil"
print("Detected Evil Miku win from first line exact match")
logger.debug("Detected Evil Miku win from first line exact match")
elif first_line_lower == "hatsune miku":
winner = "miku"
print("Detected Hatsune Miku win from first line exact match")
logger.debug("Detected Hatsune Miku win from first line exact match")
elif first_line_lower == "draw":
winner = "draw"
print("Detected Draw from first line exact match")
logger.debug("Detected Draw from first line exact match")
elif "evil miku" in first_line_lower and "hatsune" not in first_line_lower:
# First line mentions Evil Miku but not Hatsune Miku
winner = "evil"
print("Detected Evil Miku win from first line (contains 'evil miku' only)")
logger.debug("Detected Evil Miku win from first line (contains 'evil miku' only)")
elif "hatsune miku" in first_line_lower and "evil" not in first_line_lower:
# First line mentions Hatsune Miku but not Evil Miku
winner = "miku"
print("Detected Hatsune Miku win from first line (contains 'hatsune miku' only)")
logger.debug("Detected Hatsune Miku win from first line (contains 'hatsune miku' only)")
else:
# Fallback: check the whole judgment
print(f"⚠️ First line ambiguous, using fallback counting method")
logger.debug(f"First line ambiguous, using fallback counting method")
judgment_lower = judgment.lower()
# Count mentions to break ties
evil_count = judgment_lower.count("evil miku")
miku_count = judgment_lower.count("hatsune miku")
draw_count = judgment_lower.count("draw")
print(f"📊 Counts - Evil: {evil_count}, Miku: {miku_count}, Draw: {draw_count}")
logger.debug(f"Counts - Evil: {evil_count}, Miku: {miku_count}, Draw: {draw_count}")
if draw_count > 0 and draw_count >= evil_count and draw_count >= miku_count:
winner = "draw"
@@ -654,7 +657,7 @@ async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[
return winner, judgment
except Exception as e:
print(f"⚠️ Error in arbiter judgment: {e}")
logger.error(f"Error in arbiter judgment: {e}")
return "draw", "An error occurred during judgment."
@@ -756,13 +759,13 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
guild_id = channel.guild.id
if is_argument_in_progress(channel_id):
print(f"⚠️ Argument already in progress in #{channel.name}")
logger.warning(f"Argument already in progress in #{channel.name}")
return
# Get webhooks for this channel
webhooks = await get_or_create_webhooks_for_channel(channel)
if not webhooks:
print(f"Could not create webhooks for argument in #{channel.name}")
logger.error(f"Could not create webhooks for argument in #{channel.name}")
return
# Determine who initiates based on starting_message or inactive persona
@@ -773,12 +776,12 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
is_evil_message = globals.EVIL_MODE or (starting_message.webhook_id is not None and "Evil" in (starting_message.author.name or ""))
initiator = "miku" if is_evil_message else "evil" # Opposite persona responds
last_message = starting_message.content
print(f"🔄 Starting argument from message, responder: {initiator}")
logger.info(f"Starting argument from message, responder: {initiator}")
else:
# The inactive persona breaks through
initiator = get_inactive_persona()
last_message = None
print(f"🔄 Starting bipolar argument in #{channel.name}, initiated by {initiator}")
logger.info(f"Starting bipolar argument in #{channel.name}, initiated by {initiator}")
start_argument(channel_id, initiator)
@@ -812,7 +815,7 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
globals.EVIL_MODE = original_evil_mode
if not initial_message or initial_message.startswith("Error") or initial_message.startswith("Sorry"):
print("Failed to generate initial argument message")
logger.error("Failed to generate initial argument message")
end_argument(channel_id)
return
@@ -877,22 +880,22 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
if should_end:
exchange_count = globals.BIPOLAR_ARGUMENT_IN_PROGRESS.get(channel_id, {}).get("exchange_count", 0)
print(f"⚖️ Argument complete with {exchange_count} exchanges. Calling arbiter...")
logger.info(f"Argument complete with {exchange_count} exchanges. Calling arbiter...")
# Use arbiter to judge the winner
winner, judgment = await judge_argument_winner(conversation_log, guild_id)
print(f"⚖️ Arbiter decision: {winner}")
print(f"📝 Judgment: {judgment}")
logger.info(f"Arbiter decision: {winner}")
logger.info(f"Judgment: {judgment}")
# If it's a draw, continue the argument instead of ending
if winner == "draw":
print("🤝 Arbiter ruled it's still a draw - argument continues...")
logger.info("Arbiter ruled it's still a draw - argument continues...")
# Reduce the end chance by 5% (but don't go below 5%)
current_end_chance = globals.BIPOLAR_ARGUMENT_IN_PROGRESS[channel_id].get("end_chance", 0.1)
new_end_chance = max(0.05, current_end_chance - 0.05)
globals.BIPOLAR_ARGUMENT_IN_PROGRESS[channel_id]["end_chance"] = new_end_chance
print(f"📉 Reduced end chance to {new_end_chance*100:.0f}% - argument continues...")
logger.info(f"Reduced end chance to {new_end_chance*100:.0f}% - argument continues...")
# Don't end, just continue to the next exchange
else:
# Clear winner - generate final triumphant message
@@ -938,10 +941,10 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
# Switch to winner's mode (including role color)
from utils.evil_mode import apply_evil_mode_changes, revert_evil_mode_changes
if winner == "evil":
print("👿 Evil Miku won! Switching to Evil Mode...")
logger.info("Evil Miku won! Switching to Evil Mode...")
await apply_evil_mode_changes(client, change_username=True, change_pfp=True, change_nicknames=True, change_role_color=True)
else:
print("💙 Hatsune Miku won! Switching to Normal Mode...")
logger.info("Hatsune Miku won! Switching to Normal Mode...")
await revert_evil_mode_changes(client, change_username=True, change_pfp=True, change_nicknames=True, change_role_color=True)
# Clean up argument conversation history
@@ -951,7 +954,7 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
pass # History cleanup is not critical
end_argument(channel_id)
print(f"Argument ended in #{channel.name}, winner: {winner}")
logger.info(f"Argument ended in #{channel.name}, winner: {winner}")
return
# Get current speaker
@@ -982,7 +985,7 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
globals.EVIL_MODE = original_evil_mode
if not response or response.startswith("Error") or response.startswith("Sorry"):
print(f"Failed to generate argument response")
logger.error(f"Failed to generate argument response")
end_argument(channel_id)
return
@@ -1021,7 +1024,7 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
is_first_response = False
except Exception as e:
print(f"Argument error: {e}")
logger.error(f"Argument error: {e}")
import traceback
traceback.print_exc()
end_argument(channel_id)
@@ -1057,11 +1060,11 @@ async def force_trigger_argument(channel: discord.TextChannel, client, context:
starting_message: Optional message to use as the first message in the argument
"""
if not globals.BIPOLAR_MODE:
print("⚠️ Cannot trigger argument - bipolar mode is not enabled")
logger.warning("Cannot trigger argument - bipolar mode is not enabled")
return False
if is_argument_in_progress(channel.id):
print("⚠️ Argument already in progress in this channel")
logger.warning("Argument already in progress in this channel")
return False
asyncio.create_task(run_argument(channel, client, context, starting_message))