complete restructure
This commit is contained in:
+203
@@ -0,0 +1,203 @@
|
||||
"""Composition root: owns every singleton, the four services, and the bot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import discord
|
||||
from discord import Message
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot import llm_client
|
||||
from vibe_bot.config import TTS_MODEL_PATH, TTS_VOICES_PATH
|
||||
from vibe_bot.database import ChatDatabase, CustomBotManager
|
||||
from vibe_bot.llm_client import ToolRegistry
|
||||
from vibe_bot.services.chat_service import ChatService
|
||||
from vibe_bot.services.conversation_service import ConversationService
|
||||
from vibe_bot.services.image_service import ImageService
|
||||
from vibe_bot.services.speech_service import SpeechService
|
||||
from vibe_bot.tts import TTSEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot
|
||||
from discord.ext.commands import Context as CommandsContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure root logging (the single ``basicConfig`` in the codebase)."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class App:
|
||||
"""The composition root: every singleton and the four services."""
|
||||
|
||||
db: ChatDatabase
|
||||
manager: CustomBotManager
|
||||
registry: ToolRegistry
|
||||
tts: TTSEngine | None
|
||||
chat: ChatService
|
||||
image: ImageService
|
||||
speech: SpeechService
|
||||
conversation: ConversationService
|
||||
bot_cache: dict[str, tuple[str, str]]
|
||||
|
||||
|
||||
def _build_bot_cache(manager: CustomBotManager) -> dict[str, tuple[str, str]]:
|
||||
"""Snapshot the custom bots as name -> (system_prompt, creator)."""
|
||||
return {
|
||||
name: (system_prompt, creator)
|
||||
for name, system_prompt, creator in manager.list_custom_bots()
|
||||
}
|
||||
|
||||
|
||||
def invalidate_bot_cache(app: App) -> None:
|
||||
"""Rebuild ``app.bot_cache`` from the database after a change."""
|
||||
app.bot_cache = _build_bot_cache(app.manager)
|
||||
|
||||
|
||||
def create_app() -> App:
|
||||
"""Build the App: singletons, tolerant TTS init, and the four services."""
|
||||
db = ChatDatabase()
|
||||
manager = CustomBotManager()
|
||||
registry = llm_client.get_tool_registry()
|
||||
|
||||
engine: TTSEngine | None = None
|
||||
try:
|
||||
engine = TTSEngine(TTS_MODEL_PATH, TTS_VOICES_PATH)
|
||||
logger.info("TTS engine initialized successfully")
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize TTS engine")
|
||||
logger.info(
|
||||
"Make sure kokoro-v1.0.onnx and voices-v1.0.bin are in the project directory",
|
||||
)
|
||||
|
||||
return App(
|
||||
db=db,
|
||||
manager=manager,
|
||||
registry=registry,
|
||||
tts=engine,
|
||||
chat=ChatService(db, registry),
|
||||
image=ImageService(db, discord.File),
|
||||
speech=SpeechService(db, manager, engine, discord.File),
|
||||
conversation=ConversationService(manager),
|
||||
bot_cache=_build_bot_cache(manager),
|
||||
)
|
||||
|
||||
|
||||
# Module-level holders wired by build_bot(); the event handlers below are
|
||||
# module-level so they stay importable and testable.
|
||||
_app: App | None = None
|
||||
_bot: commands.Bot | None = None
|
||||
|
||||
|
||||
def _require_app() -> App:
|
||||
"""The App wired by build_bot(); handlers must not run before it."""
|
||||
if _app is None:
|
||||
msg = "App is not initialized; build_bot(app) must be called first."
|
||||
raise RuntimeError(msg)
|
||||
return _app
|
||||
|
||||
|
||||
def _require_bot() -> commands.Bot:
|
||||
"""The Bot wired by build_bot(); event handlers must not run before it."""
|
||||
if _bot is None:
|
||||
msg = "Bot is not initialized; build_bot(app) must be called first."
|
||||
raise RuntimeError(msg)
|
||||
return _bot
|
||||
|
||||
|
||||
async def on_ready() -> None:
|
||||
"""Log when the bot is ready and logged in."""
|
||||
bot = _require_bot()
|
||||
logger.info("Bot is starting up...")
|
||||
logger.info("Bot logged in as %s", bot.user)
|
||||
|
||||
|
||||
async def on_message(message: Message) -> None:
|
||||
"""Handle incoming messages for custom bot command detection."""
|
||||
app = _require_app()
|
||||
bot = _require_bot()
|
||||
|
||||
if message.author == bot.user:
|
||||
return
|
||||
|
||||
if not message.content.startswith("!"):
|
||||
return
|
||||
|
||||
message_content = message.content.lower()
|
||||
logger.debug(
|
||||
"Processing message from user %s (chars=%d)",
|
||||
message.author.id,
|
||||
len(message_content),
|
||||
)
|
||||
|
||||
for bot_name, (system_prompt, _creator) in app.bot_cache.items():
|
||||
if message_content.startswith(f"!{bot_name} "):
|
||||
logger.info(
|
||||
"Custom bot %r triggered by user %s", bot_name, message.author.id
|
||||
)
|
||||
|
||||
user_message = message.content[len(f"!{bot_name} ") :]
|
||||
logger.debug(
|
||||
"Extracted user message for bot %r (chars=%d)",
|
||||
bot_name,
|
||||
len(user_message),
|
||||
)
|
||||
|
||||
response_prefix = f"{bot_name} response"
|
||||
logger.info("Sending request to chat service for bot %r", bot_name)
|
||||
ctx = await bot.get_context(message)
|
||||
await app.chat.handle(
|
||||
ctx=ctx,
|
||||
bot_name=bot_name,
|
||||
message=user_message,
|
||||
system_prompt=system_prompt,
|
||||
response_prefix=response_prefix,
|
||||
)
|
||||
return
|
||||
|
||||
# If no custom bot matched, call the default event handler
|
||||
await bot.process_commands(message)
|
||||
|
||||
|
||||
async def on_command_error(
|
||||
ctx: CommandsContext[Bot],
|
||||
error: commands.CommandError,
|
||||
) -> None:
|
||||
"""Send a friendly message when a command hits its per-user cooldown."""
|
||||
if isinstance(error, commands.CommandOnCooldown):
|
||||
retry_after = max(1, round(error.retry_after))
|
||||
await ctx.send(f"You're using that too quickly, try again in {retry_after}s.")
|
||||
return
|
||||
logger.exception("Unhandled command error in %s: %s", ctx.command, error)
|
||||
|
||||
|
||||
def build_bot(app: App) -> commands.Bot:
|
||||
"""Create the Discord bot with all event and command handlers attached."""
|
||||
global _app, _bot
|
||||
_app = app
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.members = True
|
||||
intents.presences = True
|
||||
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||
_bot = bot
|
||||
|
||||
bot.event(on_ready)
|
||||
bot.event(on_message)
|
||||
bot.event(on_command_error)
|
||||
|
||||
# Imported here (not at module top) to break the import cycle.
|
||||
from vibe_bot.commands import register_all
|
||||
|
||||
register_all(bot, app)
|
||||
return bot
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Discord command groups; ``register_all`` wires every group onto the bot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe_bot.commands import admin, chat, conversation, custom_bots, images, speech
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
|
||||
def register_all(bot: commands.Bot, app: App) -> None:
|
||||
"""Register every command group on the bot."""
|
||||
custom_bots.register(bot, app)
|
||||
speech.register(bot, app)
|
||||
images.register(bot, app)
|
||||
conversation.register(bot, app)
|
||||
admin.register(bot, app)
|
||||
chat.register(bot, app)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Shared App holder for the command modules, wired by each group's register."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe_bot.app import App
|
||||
|
||||
_app: App | None = None
|
||||
|
||||
|
||||
def set_app(app: App) -> None:
|
||||
"""Store the App so command handlers can reach the services."""
|
||||
global _app
|
||||
_app = app
|
||||
|
||||
|
||||
def require_app() -> App:
|
||||
"""The App wired by build_bot(); handlers must not run before it."""
|
||||
if _app is None:
|
||||
msg = "App is not initialized; build_bot(app) must be called first."
|
||||
raise RuntimeError(msg)
|
||||
return _app
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Admin and debug commands: wipe history, debug menu, chat history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.commands._state import require_app, set_app
|
||||
from vibe_bot.prompts import get_user_info
|
||||
from vibe_bot.textutil import split_message
|
||||
from vibe_bot.tools import get_channel_members, get_channel_members_impl
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot
|
||||
from discord.ext.commands import Context as CommandsContext
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register(bot: commands.Bot, app: App) -> None:
|
||||
"""Register the admin and debug commands."""
|
||||
set_app(app)
|
||||
bot.command(name="lobotomize")(lobotomize)
|
||||
bot.command(name="debug")(debug)
|
||||
bot.command(name="history")(history)
|
||||
|
||||
|
||||
async def lobotomize(ctx: CommandsContext[Bot]) -> None:
|
||||
"""Clear all conversation history and memory for all bots."""
|
||||
app = require_app()
|
||||
logger.info("Lobotomize command triggered by user %s", ctx.author.id)
|
||||
await asyncio.to_thread(app.db.clear_all_messages)
|
||||
await ctx.send("All conversation history and memory has been cleared. 🧠✨")
|
||||
|
||||
|
||||
async def debug(
|
||||
ctx: CommandsContext[Bot],
|
||||
*,
|
||||
subcommand: str | None = None,
|
||||
) -> None:
|
||||
"""Debug menu for various debugging sub-commands.
|
||||
|
||||
Usage: !debug <subcommand>
|
||||
Available sub-commands:
|
||||
- members: List all members in the current channel
|
||||
- whoami: Show all information the bot has about you
|
||||
- tools: Show the LLM's available tools
|
||||
"""
|
||||
logger.info(
|
||||
"Debug command triggered by user %s with subcommand %r",
|
||||
ctx.author.id,
|
||||
subcommand,
|
||||
)
|
||||
|
||||
if not subcommand:
|
||||
menu = "Debug Menu:\n\n"
|
||||
menu += "Available sub-commands:\n"
|
||||
menu += "- `members` - List all members in the current channel\n"
|
||||
menu += "- `whoami` - Show all information the bot has about you\n"
|
||||
menu += "- `tools` - Show the LLM's available tools"
|
||||
await ctx.send(menu)
|
||||
return
|
||||
|
||||
if subcommand == "members":
|
||||
result = get_channel_members_impl(ctx.channel)
|
||||
for chunk in split_message(result):
|
||||
await ctx.send(chunk)
|
||||
return
|
||||
|
||||
if subcommand == "whoami":
|
||||
user_info = get_user_info(ctx.author)
|
||||
for chunk in split_message(user_info):
|
||||
await ctx.send(chunk)
|
||||
return
|
||||
|
||||
if subcommand == "tools":
|
||||
tool_list = "LLM Tools:\n\n"
|
||||
tool_list += f"- `{get_channel_members.name}`\n"
|
||||
tool_list += f" Description: {get_channel_members.description}\n"
|
||||
args_schema = get_channel_members.args_schema
|
||||
if isinstance(args_schema, type):
|
||||
tool_list += f" Parameters: {args_schema.model_json_schema()}"
|
||||
for chunk in split_message(tool_list):
|
||||
await ctx.send(chunk)
|
||||
return
|
||||
|
||||
await ctx.send(
|
||||
f"Unknown debug sub-command: `{subcommand}`\n\n"
|
||||
f"Use `!debug` to see available sub-commands.",
|
||||
)
|
||||
|
||||
|
||||
async def history(ctx: CommandsContext[Bot], bot_name: str) -> None:
|
||||
"""View the chat history of a custom bot.
|
||||
|
||||
Usage: !history <bot_name>
|
||||
"""
|
||||
app = require_app()
|
||||
logger.info(
|
||||
"History command triggered by user %s for bot %r",
|
||||
ctx.author.id,
|
||||
bot_name,
|
||||
)
|
||||
|
||||
bot_info = await asyncio.to_thread(app.manager.get_custom_bot, bot_name)
|
||||
|
||||
if not bot_info:
|
||||
await ctx.send(f"Custom bot '{bot_name}' not found.")
|
||||
return
|
||||
|
||||
history = await asyncio.to_thread(
|
||||
app.db.get_bot_history, bot_name=bot_name, limit=20
|
||||
)
|
||||
|
||||
if not history:
|
||||
await ctx.send(f"No chat history found for **{bot_name}**. ")
|
||||
return
|
||||
|
||||
history.reverse()
|
||||
|
||||
formatted_history: list[str] = []
|
||||
for user_msg, bot_resp in history:
|
||||
formatted_history.append(user_msg)
|
||||
formatted_history.append(f"{bot_name}: {bot_resp}")
|
||||
|
||||
header = f"Chat History for **{bot_name}**:\n\n"
|
||||
full_text = header + "\n---\n".join(formatted_history)
|
||||
|
||||
for chunk in split_message(full_text):
|
||||
await ctx.send(chunk)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Custom-bot chat has no command of its own, so this group registers nothing.
|
||||
|
||||
A message like ``!alfred hello`` is matched against the bot-name cache in
|
||||
``vibe_bot.app.on_message`` and dispatched to ``ChatService.handle``; ordinary
|
||||
``!`` messages fall through to the commands registered by the other groups.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
|
||||
def register(bot: commands.Bot, app: App) -> None:
|
||||
"""Register no commands: custom-bot chat flows through ``on_message``."""
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Bot-vs-bot conversation commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.commands._state import require_app, set_app
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot
|
||||
from discord.ext.commands import Context as CommandsContext
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
MIN_TALKFORME_PARTS = 4
|
||||
|
||||
|
||||
def register(bot: commands.Bot, app: App) -> None:
|
||||
"""Register the conversation commands."""
|
||||
set_app(app)
|
||||
bot.command(name="talkforme")(talkforme)
|
||||
|
||||
|
||||
@commands.cooldown(rate=1, per=30, type=commands.BucketType.user)
|
||||
async def talkforme(ctx: CommandsContext[Bot], *, message: str) -> None:
|
||||
"""Have two bots talk to each other about a topic.
|
||||
|
||||
Usage: !talkforme bot1 bot2 4 some conversation topic
|
||||
"""
|
||||
app = require_app()
|
||||
parts = message.split(" ", maxsplit=MIN_TALKFORME_PARTS - 1)
|
||||
if len(parts) < MIN_TALKFORME_PARTS:
|
||||
await ctx.send("Usage: !talkforme bot1 bot2 <number> <topic>")
|
||||
return
|
||||
|
||||
await app.conversation.run(ctx, parts[0], parts[1], parts[2], " ".join(parts[3:]))
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Custom-bot management commands: create, list, and delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.app import invalidate_bot_cache
|
||||
from vibe_bot.commands._state import require_app, set_app
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot
|
||||
from discord.ext.commands import Context as CommandsContext
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIN_BOT_NAME_LENGTH = 2
|
||||
MAX_BOT_NAME_LENGTH = 50
|
||||
MIN_PERSONALITY_LENGTH = 10
|
||||
MAX_PERSONALITY_LENGTH = 1000
|
||||
|
||||
|
||||
def register(bot: commands.Bot, app: App) -> None:
|
||||
"""Register the custom-bot management commands."""
|
||||
set_app(app)
|
||||
bot.command(name="custom-bot")(custom_bot)
|
||||
bot.command(name="list-custom-bots")(list_custom_bots)
|
||||
bot.command(name="delete-custom-bot")(delete_custom_bot)
|
||||
|
||||
|
||||
async def custom_bot(
|
||||
ctx: CommandsContext[Bot],
|
||||
bot_name: str,
|
||||
*,
|
||||
personality: str,
|
||||
) -> None:
|
||||
"""Create a custom bot with a name and personality.
|
||||
|
||||
Usage: !custom-bot <bot_name> <personality_description>
|
||||
Example: !custom-bot alfred you are a proper british butler
|
||||
"""
|
||||
app = require_app()
|
||||
logger.info(
|
||||
"Custom bot command initiated by user %s: name=%r, personality_chars=%d",
|
||||
ctx.author.id,
|
||||
bot_name,
|
||||
len(personality),
|
||||
)
|
||||
|
||||
# Validate bot name
|
||||
name_length = 0 if not bot_name else len(bot_name)
|
||||
if (
|
||||
not bot_name
|
||||
or name_length < MIN_BOT_NAME_LENGTH
|
||||
or name_length > MAX_BOT_NAME_LENGTH
|
||||
):
|
||||
logger.warning(
|
||||
"Invalid bot name from user %s: %r (length: %d)",
|
||||
ctx.author.id,
|
||||
bot_name,
|
||||
name_length,
|
||||
)
|
||||
await ctx.send("Invalid bot name. Name must be between 2 and 50 characters.")
|
||||
return
|
||||
|
||||
logger.debug("Bot name validation passed for %r", bot_name)
|
||||
|
||||
# Validate personality
|
||||
personality_length = 0 if not personality else len(personality)
|
||||
if not personality or personality_length < MIN_PERSONALITY_LENGTH:
|
||||
logger.warning(
|
||||
"Invalid personality from user %s: length=%d",
|
||||
ctx.author.id,
|
||||
personality_length,
|
||||
)
|
||||
await ctx.send(
|
||||
"Invalid personality. Description must be at least 10 characters.",
|
||||
)
|
||||
return
|
||||
if personality_length > MAX_PERSONALITY_LENGTH:
|
||||
logger.warning(
|
||||
"Personality too long from user %s: length=%d",
|
||||
ctx.author.id,
|
||||
personality_length,
|
||||
)
|
||||
await ctx.send(
|
||||
f"Personality too long. " f"Max {MAX_PERSONALITY_LENGTH} characters.",
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug("Personality validation passed for bot %r", bot_name)
|
||||
|
||||
# Create the custom bot
|
||||
logger.debug(
|
||||
"Attempting to create custom bot %r for user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
result = await asyncio.to_thread(
|
||||
app.manager.create_custom_bot,
|
||||
bot_name=bot_name,
|
||||
system_prompt=personality,
|
||||
created_by=str(ctx.author.id),
|
||||
)
|
||||
|
||||
if result is False:
|
||||
logger.warning(
|
||||
"Failed to create custom bot %r for user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
await ctx.send("Failed to create custom bot.")
|
||||
return
|
||||
|
||||
await asyncio.to_thread(invalidate_bot_cache, app)
|
||||
|
||||
if result == "replaced":
|
||||
logger.info(
|
||||
"Replaced existing custom bot %r for user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
await ctx.send(
|
||||
f"Custom bot **'{bot_name}'** already existed and has been "
|
||||
f"**replaced** with personality: *{personality}*",
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Successfully created custom bot %r for user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
await ctx.send(
|
||||
f"Custom bot **'{bot_name}'** has been created "
|
||||
f"with personality: *{personality}*",
|
||||
)
|
||||
await ctx.send(
|
||||
f"\nYou can now use this bot with: " f"`!{bot_name} <your message>`",
|
||||
)
|
||||
|
||||
|
||||
async def list_custom_bots(ctx: CommandsContext[Bot]) -> None:
|
||||
"""List all custom bots available in the server."""
|
||||
app = require_app()
|
||||
logger.info("Listing custom bots requested by user %s", ctx.author.id)
|
||||
|
||||
logger.debug("Fetching list of custom bots from database")
|
||||
bots = await asyncio.to_thread(app.manager.list_custom_bots)
|
||||
|
||||
if not bots:
|
||||
logger.debug("No custom bots found for user %s", ctx.author.id)
|
||||
await ctx.send(
|
||||
"No custom bots have been created yet. "
|
||||
"Use `!custom-bot <name> <personality>` to create one.",
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Found %d custom bots, displaying top 10 for user %s",
|
||||
len(bots),
|
||||
ctx.author.id,
|
||||
)
|
||||
bot_list = "Available Custom Bots:\n\n"
|
||||
for name, _prompt, _creator in bots:
|
||||
bot_list += f"* {name}\n"
|
||||
|
||||
logger.debug("Sending bot list response to user %s", ctx.author.id)
|
||||
await ctx.send(bot_list)
|
||||
|
||||
|
||||
async def delete_custom_bot(ctx: CommandsContext[Bot], bot_name: str) -> None:
|
||||
"""Delete a custom bot (only the creator can delete).
|
||||
|
||||
Usage: !delete-custom-bot <bot_name>
|
||||
"""
|
||||
app = require_app()
|
||||
logger.info(
|
||||
"Delete custom bot command initiated by user %s: bot_name=%r",
|
||||
ctx.author.id,
|
||||
bot_name,
|
||||
)
|
||||
|
||||
# Get bot info
|
||||
logger.debug("Looking up custom bot %r in database", bot_name)
|
||||
bot_info = await asyncio.to_thread(app.manager.get_custom_bot, bot_name)
|
||||
|
||||
if not bot_info:
|
||||
logger.warning(
|
||||
"Custom bot %r not found by user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
await ctx.send(f"Custom bot '{bot_name}' not found.")
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Custom bot %r found, owned by user %s",
|
||||
bot_name,
|
||||
bot_info[2],
|
||||
)
|
||||
|
||||
# Check ownership
|
||||
if bot_info[2] != str(ctx.author.id):
|
||||
logger.warning(
|
||||
"User %s attempted to delete bot %r they don't own",
|
||||
ctx.author.id,
|
||||
bot_name,
|
||||
)
|
||||
await ctx.send("You can only delete your own custom bots.")
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"User %s is authorized to delete bot %r",
|
||||
ctx.author.id,
|
||||
bot_name,
|
||||
)
|
||||
|
||||
# Delete the bot
|
||||
logger.debug("Deleting custom bot %r from database", bot_name)
|
||||
success = await asyncio.to_thread(app.manager.delete_custom_bot, bot_name)
|
||||
|
||||
if success:
|
||||
logger.info(
|
||||
"Successfully deleted custom bot %r by user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
await asyncio.to_thread(invalidate_bot_cache, app)
|
||||
await ctx.send(f"Custom bot '{bot_name}' has been deleted.")
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to delete custom bot %r by user %s",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
)
|
||||
await ctx.send("Failed to delete custom bot.")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Image commands: generation and editing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.commands._state import require_app, set_app
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot
|
||||
from discord.ext.commands import Context as CommandsContext
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
|
||||
def register(bot: commands.Bot, app: App) -> None:
|
||||
"""Register the image commands."""
|
||||
set_app(app)
|
||||
bot.command(name="doodlebob")(doodlebob)
|
||||
bot.command(name="retcon")(retcon)
|
||||
|
||||
|
||||
@commands.cooldown(rate=1, per=60, type=commands.BucketType.user)
|
||||
async def doodlebob(ctx: CommandsContext[Bot], *, message: str) -> None:
|
||||
"""Convert a message into an image using Doodlebob."""
|
||||
app = require_app()
|
||||
await app.image.generate(ctx, message=message)
|
||||
|
||||
|
||||
async def retcon(ctx: CommandsContext[Bot], *, message: str) -> None:
|
||||
"""Edit an attached image based on a text prompt."""
|
||||
app = require_app()
|
||||
await app.image.edit(ctx, message=message)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Speech commands: text-to-speech and the voice catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.commands._state import require_app, set_app
|
||||
from vibe_bot.config import VOICES_LIST
|
||||
from vibe_bot.textutil import split_message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot
|
||||
from discord.ext.commands import Context as CommandsContext
|
||||
|
||||
from vibe_bot.app import App
|
||||
|
||||
|
||||
def register(bot: commands.Bot, app: App) -> None:
|
||||
"""Register the speech commands."""
|
||||
set_app(app)
|
||||
bot.command(name="speak")(speak)
|
||||
bot.command(name="voices")(voices)
|
||||
|
||||
|
||||
async def voices(ctx: CommandsContext[Bot]) -> None:
|
||||
"""List all available TTS voices organized by category."""
|
||||
voice_list = "Available Voices:\n\n"
|
||||
for category, info in VOICES_LIST.items():
|
||||
voice_list += f"{category} ({info['language']}):\n"
|
||||
for v in info["voices"]:
|
||||
voice_list += f"- {v}\n"
|
||||
voice_list += "\n"
|
||||
voice_list += "Use `!speak <text> --voice <voice_name>` to choose a voice."
|
||||
|
||||
for chunk in split_message(voice_list):
|
||||
await ctx.send(chunk)
|
||||
|
||||
|
||||
@commands.cooldown(rate=3, per=30, type=commands.BucketType.user)
|
||||
async def speak(
|
||||
ctx: CommandsContext[Bot],
|
||||
*,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Have the bot speak the given text using Kokoro TTS, or have a custom bot speak.
|
||||
|
||||
Usage: !speak <text> --voice <voice_name> - plain text to speech
|
||||
Usage: !speak <bot_name> <text> --voice <voice_name> - have a custom bot respond and speak
|
||||
Example: !speak hello world
|
||||
Example: !speak hello world --voice af_bella
|
||||
Example: !speak alfred what time is it --voice am_puck
|
||||
"""
|
||||
app = require_app()
|
||||
await app.speech.speak(ctx, message=message)
|
||||
+33
-52
@@ -7,11 +7,6 @@ import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
load_dotenv()
|
||||
@@ -21,7 +16,6 @@ DISCORD_TOKEN: str = os.getenv("DISCORD_TOKEN", "")
|
||||
|
||||
# Endpoints
|
||||
CHAT_ENDPOINT: str = os.getenv("CHAT_ENDPOINT", "")
|
||||
COMPLETION_ENDPOINT: str = os.getenv("COMPLETION_ENDPOINT", "")
|
||||
IMAGE_GEN_ENDPOINT: str = os.getenv("IMAGE_GEN_ENDPOINT", "")
|
||||
IMAGE_EDIT_ENDPOINT: str = os.getenv("IMAGE_EDIT_ENDPOINT", "")
|
||||
EMBEDDING_ENDPOINT: str = os.getenv("EMBEDDING_ENDPOINT", "")
|
||||
@@ -29,14 +23,12 @@ MAX_COMPLETION_TOKENS: int = int(os.getenv("MAX_COMPLETION_TOKENS", "1000"))
|
||||
|
||||
# API Keys
|
||||
CHAT_ENDPOINT_KEY: str = os.getenv("CHAT_ENDPOINT_KEY", "placeholder")
|
||||
COMPLETION_ENDPOINT_KEY: str = os.getenv("COMPLETION_ENDPOINT_KEY", "placeholder")
|
||||
IMAGE_GEN_ENDPOINT_KEY: str = os.getenv("IMAGE_GEN_ENDPOINT_KEY", "placeholder")
|
||||
IMAGE_EDIT_ENDPOINT_KEY: str = os.getenv("IMAGE_EDIT_ENDPOINT_KEY", "placeholder")
|
||||
EMBEDDING_ENDPOINT_KEY: str = os.getenv("EMBEDDING_ENDPOINT_KEY", "placeholder")
|
||||
|
||||
# Models
|
||||
CHAT_MODEL: str = os.getenv("CHAT_MODEL", "")
|
||||
COMPLETION_MODEL: str = os.getenv("COMPLETION_MODEL", "")
|
||||
IMAGE_GEN_MODEL: str = os.getenv("IMAGE_GEN_MODEL", "")
|
||||
IMAGE_GEN_SIZE_SQUARE: str = os.getenv("IMAGE_GEN_SIZE_SQUARE", "1024x1024")
|
||||
IMAGE_GEN_SIZE_PORTRAIT: str = os.getenv("IMAGE_GEN_SIZE_PORTRAIT", "1024x1536")
|
||||
@@ -44,59 +36,54 @@ IMAGE_GEN_SIZE_LANDSCAPE: str = os.getenv("IMAGE_GEN_SIZE_LANDSCAPE", "1536x1024
|
||||
IMAGE_EDIT_MODEL: str = os.getenv("IMAGE_EDIT_MODEL", "")
|
||||
EMBEDDING_MODEL: str = os.getenv("EMBEDDING_MODEL", "")
|
||||
|
||||
# Database and embeddings
|
||||
# Database and history
|
||||
DB_PATH: str = os.getenv("DB_PATH", "chat_history.db")
|
||||
EMBEDDING_DIMENSION: int = 2048
|
||||
MAX_HISTORY_MESSAGES: int = int(os.getenv("MAX_HISTORY_MESSAGES", "1000"))
|
||||
SIMILARITY_THRESHOLD: float = float(os.getenv("SIMILARITY_THRESHOLD", "0.7"))
|
||||
TOP_K_RESULTS: int = int(os.getenv("TOP_K_RESULTS", "5"))
|
||||
|
||||
# Check token
|
||||
if not DISCORD_TOKEN:
|
||||
msg = "DISCORD_TOKEN required."
|
||||
raise RuntimeError(msg)
|
||||
|
||||
# Check endpoints
|
||||
if not CHAT_ENDPOINT:
|
||||
endpoint_msg = "CHAT_ENDPOINT required."
|
||||
raise RuntimeError(endpoint_msg)
|
||||
def validate_config() -> None:
|
||||
"""Raise ``RuntimeError`` if any required setting is missing."""
|
||||
# Check token
|
||||
if not DISCORD_TOKEN:
|
||||
msg = "DISCORD_TOKEN required."
|
||||
raise RuntimeError(msg)
|
||||
|
||||
if not COMPLETION_ENDPOINT:
|
||||
endpoint_msg = "COMPLETION_ENDPOINT required."
|
||||
raise RuntimeError(endpoint_msg)
|
||||
# Check endpoints
|
||||
if not CHAT_ENDPOINT:
|
||||
endpoint_msg = "CHAT_ENDPOINT required."
|
||||
raise RuntimeError(endpoint_msg)
|
||||
|
||||
if not IMAGE_GEN_ENDPOINT:
|
||||
endpoint_msg = "IMAGE_GEN_ENDPOINT required."
|
||||
raise RuntimeError(endpoint_msg)
|
||||
if not IMAGE_GEN_ENDPOINT:
|
||||
endpoint_msg = "IMAGE_GEN_ENDPOINT required."
|
||||
raise RuntimeError(endpoint_msg)
|
||||
|
||||
if not IMAGE_EDIT_ENDPOINT:
|
||||
endpoint_msg = "IMAGE_EDIT_ENDPOINT required."
|
||||
raise RuntimeError(endpoint_msg)
|
||||
if not IMAGE_EDIT_ENDPOINT:
|
||||
endpoint_msg = "IMAGE_EDIT_ENDPOINT required."
|
||||
raise RuntimeError(endpoint_msg)
|
||||
|
||||
if not EMBEDDING_ENDPOINT:
|
||||
endpoint_msg = "EMBEDDING_ENDPOINT required."
|
||||
raise RuntimeError(endpoint_msg)
|
||||
if not EMBEDDING_ENDPOINT:
|
||||
endpoint_msg = "EMBEDDING_ENDPOINT required."
|
||||
raise RuntimeError(endpoint_msg)
|
||||
|
||||
# Check models
|
||||
if not CHAT_MODEL:
|
||||
model_msg = "CHAT_MODEL required."
|
||||
raise RuntimeError(model_msg)
|
||||
# Check models
|
||||
if not CHAT_MODEL:
|
||||
model_msg = "CHAT_MODEL required."
|
||||
raise RuntimeError(model_msg)
|
||||
|
||||
if not COMPLETION_MODEL:
|
||||
model_msg = "COMPLETION_MODEL required."
|
||||
raise RuntimeError(model_msg)
|
||||
if not IMAGE_GEN_MODEL:
|
||||
model_msg = "IMAGE_GEN_MODEL required."
|
||||
raise RuntimeError(model_msg)
|
||||
|
||||
if not IMAGE_GEN_MODEL:
|
||||
model_msg = "IMAGE_GEN_MODEL required."
|
||||
raise RuntimeError(model_msg)
|
||||
if not IMAGE_EDIT_MODEL:
|
||||
model_msg = "IMAGE_EDIT_MODEL required."
|
||||
raise RuntimeError(model_msg)
|
||||
|
||||
if not IMAGE_EDIT_MODEL:
|
||||
model_msg = "IMAGE_EDIT_MODEL required."
|
||||
raise RuntimeError(model_msg)
|
||||
if not EMBEDDING_MODEL:
|
||||
model_msg = "EMBEDDING_MODEL required."
|
||||
raise RuntimeError(model_msg)
|
||||
|
||||
if not EMBEDDING_MODEL:
|
||||
model_msg = "EMBEDDING_MODEL required."
|
||||
raise RuntimeError(model_msg)
|
||||
|
||||
# TTS
|
||||
TTS_MODEL_PATH: str = os.getenv("TTS_MODEL_PATH", "kokoro-v1.0.onnx")
|
||||
@@ -180,9 +167,3 @@ VOICES_LIST: dict[str, dict[str, str | list[str]]] = {
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
logger.info("CHAT_ENDPOINT set to %s", CHAT_ENDPOINT)
|
||||
logger.info("COMPLETION_ENDPOINT set to %s", COMPLETION_ENDPOINT)
|
||||
logger.info("IMAGE_GEN_ENDPOINT set to %s", IMAGE_GEN_ENDPOINT)
|
||||
logger.info("IMAGE_EDIT_ENDPOINT set to %s", IMAGE_EDIT_ENDPOINT)
|
||||
logger.info("EMBEDDING_ENDPOINT set to %s", EMBEDDING_ENDPOINT)
|
||||
|
||||
+16
-761
@@ -1,599 +1,22 @@
|
||||
"""SQLite database with RAG support for chat history and embeddings."""
|
||||
"""SQLite database with RAG support for chat history and embeddings.
|
||||
|
||||
Facade for the ``vibe_bot.db`` package; re-exports the public names so
|
||||
imports of ``vibe_bot.database`` keep working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
from openai import OpenAI
|
||||
|
||||
from vibe_bot import llama_wrapper
|
||||
from vibe_bot.config import (
|
||||
DB_PATH,
|
||||
EMBEDDING_ENDPOINT,
|
||||
EMBEDDING_ENDPOINT_KEY,
|
||||
EMBEDDING_MODEL,
|
||||
MAX_HISTORY_MESSAGES,
|
||||
SIMILARITY_THRESHOLD,
|
||||
TOP_K_RESULTS,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Moving-average window (most recent generations) for the time estimate.
|
||||
IMAGE_GEN_TIME_WINDOW = 10
|
||||
# Maximum number of generation times retained in the database.
|
||||
IMAGE_GEN_TIME_LIMIT = 100
|
||||
|
||||
|
||||
class ChatDatabase:
|
||||
"""SQLite database with RAG support for storing chat history
|
||||
using OpenAI embeddings.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str = DB_PATH) -> None:
|
||||
"""Initialize the database connection.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file.
|
||||
|
||||
"""
|
||||
logger.info("Initializing ChatDatabase with path: %s", db_path)
|
||||
self.db_path = db_path
|
||||
self.client = OpenAI(
|
||||
base_url=EMBEDDING_ENDPOINT,
|
||||
api_key=EMBEDDING_ENDPOINT_KEY,
|
||||
)
|
||||
logger.info("Connecting to OpenAI API for embeddings")
|
||||
self._initialize_database()
|
||||
|
||||
def _initialize_database(self) -> None:
|
||||
"""Initialize the SQLite database with required tables."""
|
||||
logger.info("Initializing SQLite database at %s", self.db_path)
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create messages table
|
||||
logger.info("Creating chat_messages table if not exists")
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id TEXT UNIQUE,
|
||||
user_id TEXT,
|
||||
username TEXT,
|
||||
content TEXT,
|
||||
bot_name TEXT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
channel_id TEXT,
|
||||
guild_id TEXT
|
||||
)
|
||||
""",
|
||||
)
|
||||
logger.info("chat_messages table initialized successfully")
|
||||
|
||||
# Migrate: add bot_name column if it doesn't exist
|
||||
logger.info("Checking for bot_name column migration")
|
||||
cursor.execute("PRAGMA table_info(chat_messages)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
if "bot_name" not in columns:
|
||||
logger.info("Adding bot_name column to chat_messages table")
|
||||
cursor.execute(
|
||||
"ALTER TABLE chat_messages ADD COLUMN bot_name TEXT",
|
||||
)
|
||||
logger.info("bot_name column added successfully")
|
||||
|
||||
# Create embeddings table for RAG
|
||||
logger.info("Creating message_embeddings table if not exists")
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_embeddings (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
embedding BLOB,
|
||||
FOREIGN KEY (message_id) REFERENCES chat_messages(message_id)
|
||||
)
|
||||
""",
|
||||
)
|
||||
logger.info("message_embeddings table initialized successfully")
|
||||
|
||||
# Create index for faster lookups
|
||||
logger.info("Creating idx_timestamp index if not exists")
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_timestamp ON chat_messages(timestamp)
|
||||
""",
|
||||
)
|
||||
logger.info("idx_timestamp index created successfully")
|
||||
|
||||
logger.info("Creating idx_user_id index if not exists")
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_user_id ON chat_messages(user_id)
|
||||
""",
|
||||
)
|
||||
logger.info("idx_user_id index created successfully")
|
||||
|
||||
# Create image generation timing table
|
||||
logger.info("Creating image_generation_times table if not exists")
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS image_generation_times (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
duration_seconds REAL NOT NULL,
|
||||
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
)
|
||||
logger.info("image_generation_times table initialized successfully")
|
||||
|
||||
conn.commit()
|
||||
logger.info("Database initialization completed successfully")
|
||||
conn.close()
|
||||
|
||||
def _vector_to_bytes(self, vector: list[float]) -> bytes:
|
||||
"""Convert vector to bytes for SQLite storage."""
|
||||
logger.debug("Converting vector (length: %d) to bytes", len(vector))
|
||||
result = np.array(vector, dtype=np.float32).tobytes()
|
||||
logger.debug("Vector converted to %d bytes", len(result))
|
||||
return result
|
||||
|
||||
def _bytes_to_vector(self, blob: bytes) -> np.ndarray:
|
||||
"""Convert bytes back to vector."""
|
||||
logger.debug("Converting %d bytes back to vector", len(blob))
|
||||
result = np.frombuffer(blob, dtype=np.float32)
|
||||
logger.debug("Vector reconstructed with %d dimensions", len(result))
|
||||
return result
|
||||
|
||||
def _calculate_similarity(self, vec1: np.ndarray, vec2: np.ndarray) -> float:
|
||||
"""Calculate cosine similarity between two vectors."""
|
||||
vec1 = vec1.flatten()
|
||||
vec2 = vec2.flatten()
|
||||
logger.debug(
|
||||
"Calculating cosine similarity between vectors of dimension %d",
|
||||
len(vec1),
|
||||
)
|
||||
norm1 = np.linalg.norm(vec1)
|
||||
norm2 = np.linalg.norm(vec2)
|
||||
if norm1 == 0 or norm2 == 0:
|
||||
return 0.0
|
||||
result = float(np.dot(vec1, vec2) / (norm1 * norm2))
|
||||
logger.debug("Similarity calculated: %.4f", result)
|
||||
return result
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
*,
|
||||
message_id: str,
|
||||
user_id: str,
|
||||
username: str,
|
||||
content: str,
|
||||
bot_name: str | None = None,
|
||||
channel_id: str | None = None,
|
||||
guild_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Add a message to the database and generate its embedding."""
|
||||
logger.info("Adding message %s from user %s", message_id, username)
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Insert message
|
||||
logger.debug(
|
||||
"Inserting message into chat_messages table: message_id=%s",
|
||||
message_id,
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO chat_messages
|
||||
(message_id, user_id, username, content, bot_name, channel_id, guild_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
message_id,
|
||||
user_id,
|
||||
username,
|
||||
content,
|
||||
bot_name,
|
||||
channel_id,
|
||||
guild_id,
|
||||
),
|
||||
)
|
||||
logger.debug("Message %s inserted into chat_messages table", message_id)
|
||||
|
||||
# Generate and store embedding
|
||||
logger.info("Generating embedding for message %s", message_id)
|
||||
embedding = llama_wrapper.embedding(
|
||||
content,
|
||||
openai_url=EMBEDDING_ENDPOINT,
|
||||
openai_api_key=EMBEDDING_ENDPOINT_KEY,
|
||||
model=EMBEDDING_MODEL,
|
||||
)
|
||||
if embedding:
|
||||
logger.debug(
|
||||
"Embedding generated successfully for message %s, "
|
||||
"storing in database",
|
||||
message_id,
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO message_embeddings
|
||||
(message_id, embedding)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(message_id, self._vector_to_bytes(embedding)),
|
||||
)
|
||||
logger.debug(
|
||||
"Embedding stored in message_embeddings table for message %s",
|
||||
message_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to generate embedding for message %s, "
|
||||
"skipping embedding storage",
|
||||
message_id,
|
||||
)
|
||||
|
||||
# Clean up old messages if exceeding limit
|
||||
logger.info("Checking if cleanup of old messages is needed")
|
||||
self._cleanup_old_messages(cursor)
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Error adding message %s", message_id)
|
||||
conn.rollback()
|
||||
return False
|
||||
else:
|
||||
logger.info("Successfully added message %s to database", message_id)
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _cleanup_old_messages(self, cursor: sqlite3.Cursor) -> None:
|
||||
"""Remove old messages to stay within the limit."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM chat_messages
|
||||
""",
|
||||
)
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
if count > MAX_HISTORY_MESSAGES:
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM chat_messages
|
||||
WHERE id IN (
|
||||
SELECT id FROM chat_messages
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(count - MAX_HISTORY_MESSAGES,),
|
||||
)
|
||||
|
||||
# Also remove corresponding embeddings
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM message_embeddings
|
||||
WHERE message_id IN (
|
||||
SELECT message_id FROM chat_messages
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(count - MAX_HISTORY_MESSAGES,),
|
||||
)
|
||||
|
||||
def get_recent_messages(
|
||||
self,
|
||||
limit: int = 10,
|
||||
) -> list[tuple[str, str, str, datetime]]:
|
||||
"""Get recent messages from the database."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT message_id, username, content, timestamp
|
||||
FROM chat_messages
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
messages = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
return messages
|
||||
|
||||
def search_similar_messages(
|
||||
self,
|
||||
query: str,
|
||||
top_k: int = TOP_K_RESULTS,
|
||||
min_similarity: float = SIMILARITY_THRESHOLD,
|
||||
) -> list[tuple[str, str, float]]:
|
||||
"""Search for messages similar to the query using embeddings."""
|
||||
query_embedding = llama_wrapper.embedding(
|
||||
text=query,
|
||||
model=EMBEDDING_MODEL,
|
||||
openai_url=EMBEDDING_ENDPOINT,
|
||||
openai_api_key=EMBEDDING_ENDPOINT_KEY,
|
||||
)
|
||||
if not query_embedding:
|
||||
return []
|
||||
|
||||
query_vector = np.array(query_embedding, dtype=np.float32)
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Join chat_messages and message_embeddings to get content and embeddings
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT cm.message_id, cm.content, me.embedding
|
||||
FROM chat_messages cm
|
||||
JOIN message_embeddings me ON cm.message_id = me.message_id
|
||||
WHERE cm.username != 'vibe-bot'
|
||||
""",
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
results: list[tuple[str, str, float]] = []
|
||||
for message_id, content, embedding_blob in rows:
|
||||
embedding_vector = self._bytes_to_vector(embedding_blob)
|
||||
similarity = self._calculate_similarity(query_vector, embedding_vector)
|
||||
|
||||
if similarity >= min_similarity:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT content
|
||||
FROM chat_messages
|
||||
WHERE message_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
""",
|
||||
(f"{message_id}_response",),
|
||||
)
|
||||
response_row = cursor.fetchone()
|
||||
if response_row:
|
||||
results.append((content, response_row[0], similarity))
|
||||
|
||||
conn.close()
|
||||
|
||||
# Sort by similarity and return top results
|
||||
results.sort(key=lambda x: x[2], reverse=True)
|
||||
return results[:top_k]
|
||||
|
||||
def get_bot_history(self, bot_name: str, limit: int = 20) -> list[tuple[str, str]]:
|
||||
"""Get message history for a specific custom bot.
|
||||
|
||||
Args:
|
||||
bot_name: The name of the custom bot.
|
||||
limit: Maximum number of messages to retrieve.
|
||||
|
||||
Returns:
|
||||
List of (user_message, bot_response) tuples.
|
||||
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.info(
|
||||
"Fetching last %d messages for bot %r",
|
||||
limit,
|
||||
bot_name,
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT message_id, content, timestamp
|
||||
FROM chat_messages
|
||||
WHERE bot_name = ? AND message_id NOT LIKE '%%_response'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(bot_name, limit),
|
||||
)
|
||||
|
||||
messages = cursor.fetchall()
|
||||
|
||||
conversations: list[tuple[str, str]] = []
|
||||
for message in messages:
|
||||
msg_content = message[1]
|
||||
logger.debug("Finding response for %s...", msg_content[:50])
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT content
|
||||
FROM chat_messages
|
||||
WHERE message_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
""",
|
||||
(f"{message[0]}_response",),
|
||||
)
|
||||
response_row = cursor.fetchone()
|
||||
if response_row:
|
||||
logger.debug("Found response: %s...", response_row[0][:50])
|
||||
conversations.append((msg_content, response_row[0]))
|
||||
else:
|
||||
logger.debug("No response found")
|
||||
conn.close()
|
||||
|
||||
return conversations
|
||||
|
||||
def get_user_history(self, user_id: str, limit: int = 20) -> list[tuple[str, str]]:
|
||||
"""Get message history for a specific user."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.info("Fetching last %d user messages", limit)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT message_id, content, timestamp
|
||||
FROM chat_messages
|
||||
WHERE user_id = ? AND username != 'vibe-bot'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(user_id, limit),
|
||||
)
|
||||
|
||||
messages = cursor.fetchall()
|
||||
|
||||
# Format is [user message, bot response]
|
||||
conversations: list[tuple[str, str]] = []
|
||||
for message in messages:
|
||||
msg_content = message[1]
|
||||
logger.debug("Finding response for %s...", msg_content[:50])
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT content
|
||||
FROM chat_messages
|
||||
WHERE message_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
""",
|
||||
(f"{message[0]}_response",),
|
||||
)
|
||||
response_row = cursor.fetchone()
|
||||
if response_row:
|
||||
logger.debug("Found response: %s...", response_row[0][:50])
|
||||
conversations.append((msg_content, response_row[0]))
|
||||
else:
|
||||
logger.debug("No response found")
|
||||
conn.close()
|
||||
|
||||
return conversations
|
||||
|
||||
def get_conversation_context(
|
||||
self,
|
||||
user_id: str,
|
||||
current_message: str,
|
||||
max_context: int = 5,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Get relevant conversation context for RAG."""
|
||||
# Get recent messages from the user
|
||||
recent_messages = self.get_user_history(user_id, limit=max_context * 2)
|
||||
|
||||
# Search for similar messages
|
||||
similar_messages = self.search_similar_messages(
|
||||
current_message,
|
||||
top_k=max_context,
|
||||
)
|
||||
|
||||
# Combine contexts
|
||||
context_parts: list[dict[str, str]] = []
|
||||
|
||||
# Add recent messages
|
||||
for user_message, bot_message in recent_messages:
|
||||
context_parts.append({"role": "assistant", "content": bot_message})
|
||||
context_parts.append({"role": "user", "content": user_message})
|
||||
|
||||
# Add similar messages
|
||||
for user_message, bot_message, _similarity in similar_messages:
|
||||
context_parts.append({"role": "assistant", "content": bot_message})
|
||||
context_parts.append({"role": "user", "content": user_message})
|
||||
|
||||
# Conversation history needs to be delivered in "newest context last" order
|
||||
context_parts.reverse()
|
||||
return context_parts
|
||||
|
||||
def clear_all_messages(self) -> None:
|
||||
"""Clear all messages and embeddings from the database."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM message_embeddings")
|
||||
cursor.execute("DELETE FROM chat_messages")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def record_image_generation_time(self, duration_seconds: float) -> bool:
|
||||
"""Record how long an image generation took.
|
||||
|
||||
Args:
|
||||
duration_seconds: Wall-clock seconds the generation took.
|
||||
|
||||
"""
|
||||
logger.info("Recording image generation time: %.2fs", duration_seconds)
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO image_generation_times (duration_seconds)
|
||||
VALUES (?)
|
||||
""",
|
||||
(duration_seconds,),
|
||||
)
|
||||
|
||||
# Cap the table so it doesn't grow unbounded.
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM image_generation_times
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM image_generation_times
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(IMAGE_GEN_TIME_LIMIT,),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Error recording image generation time")
|
||||
conn.rollback()
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_image_generation_time_estimate(self) -> float | None:
|
||||
"""Get a moving-average estimate of image generation time.
|
||||
|
||||
Returns:
|
||||
The average duration in seconds over the most recent generations,
|
||||
or None if there is no generation history yet.
|
||||
|
||||
"""
|
||||
logger.debug("Computing moving-average image generation time estimate")
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT AVG(duration_seconds)
|
||||
FROM (
|
||||
SELECT duration_seconds
|
||||
FROM image_generation_times
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(IMAGE_GEN_TIME_WINDOW,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
except Exception:
|
||||
logger.exception("Error reading image generation times")
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if row is None or row[0] is None:
|
||||
return None
|
||||
return float(row[0])
|
||||
|
||||
from vibe_bot.db.bots import CustomBotManager
|
||||
from vibe_bot.db.messages import ChatDatabase
|
||||
from vibe_bot.db.timing import IMAGE_GEN_TIME_LIMIT, IMAGE_GEN_TIME_WINDOW
|
||||
|
||||
__all__ = [
|
||||
"IMAGE_GEN_TIME_LIMIT",
|
||||
"IMAGE_GEN_TIME_WINDOW",
|
||||
"ChatDatabase",
|
||||
"CustomBotManager",
|
||||
"get_database",
|
||||
]
|
||||
|
||||
# Global database instance
|
||||
_chat_db: ChatDatabase | None = None
|
||||
@@ -605,171 +28,3 @@ def get_database() -> ChatDatabase:
|
||||
if _chat_db is None:
|
||||
_chat_db = ChatDatabase()
|
||||
return _chat_db
|
||||
|
||||
|
||||
class CustomBotManager:
|
||||
"""Manages custom bot configurations stored in SQLite database."""
|
||||
|
||||
def __init__(self, db_path: str = DB_PATH) -> None:
|
||||
"""Initialize the custom bot manager.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file.
|
||||
|
||||
"""
|
||||
self.db_path = db_path
|
||||
self._initialize_custom_bots_table()
|
||||
|
||||
def _initialize_custom_bots_table(self) -> None:
|
||||
"""Initialize the custom bots table in SQLite."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create table to hold custom bots
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS custom_bots (
|
||||
bot_name TEXT PRIMARY KEY,
|
||||
system_prompt TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active INTEGER DEFAULT 1
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def create_custom_bot(
|
||||
self,
|
||||
bot_name: str,
|
||||
system_prompt: str,
|
||||
created_by: str,
|
||||
) -> bool:
|
||||
"""Create a new custom bot configuration."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO custom_bots
|
||||
(bot_name, system_prompt, created_by, is_active)
|
||||
VALUES (?, ?, ?, 1)
|
||||
""",
|
||||
(bot_name.lower(), system_prompt, created_by),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Error creating custom bot")
|
||||
conn.rollback()
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_custom_bot(self, bot_name: str) -> tuple[str, str, str, datetime] | None:
|
||||
"""Get a custom bot configuration by name."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT bot_name, system_prompt, created_by, created_at
|
||||
FROM custom_bots
|
||||
WHERE bot_name = ? AND is_active = 1
|
||||
""",
|
||||
(bot_name.lower(),),
|
||||
)
|
||||
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if result is None:
|
||||
return None
|
||||
return (result[0], result[1], result[2], result[3])
|
||||
|
||||
def list_custom_bots(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""List all custom bots, optionally filtered by creator."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
if user_id:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT bot_name, system_prompt, created_by
|
||||
FROM custom_bots
|
||||
WHERE is_active = 1 AND created_by = ?
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT bot_name, system_prompt, created_by
|
||||
FROM custom_bots
|
||||
WHERE is_active = 1
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
)
|
||||
|
||||
bots = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
return bots
|
||||
|
||||
def delete_custom_bot(self, bot_name: str) -> bool:
|
||||
"""Delete a custom bot configuration."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM custom_bots
|
||||
WHERE bot_name = ?
|
||||
""",
|
||||
(bot_name.lower(),),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Error deleting custom bot")
|
||||
conn.rollback()
|
||||
return False
|
||||
else:
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def deactivate_custom_bot(self, bot_name: str) -> bool:
|
||||
"""Deactivate a custom bot (soft delete)."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE custom_bots
|
||||
SET is_active = 0
|
||||
WHERE bot_name = ?
|
||||
""",
|
||||
(bot_name.lower(),),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Error deactivating custom bot")
|
||||
conn.rollback()
|
||||
return False
|
||||
else:
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""SQLite storage layer: connection, schema, message store, RAG, custom bots."""
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Custom bot configuration store (create, read, list, delete)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from vibe_bot.config import DB_PATH
|
||||
from vibe_bot.db.connection import connect
|
||||
from vibe_bot.db.schema import initialize_custom_bots_table
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomBotManager:
|
||||
"""Manages custom bot configurations stored in SQLite database."""
|
||||
|
||||
def __init__(self, db_path: str = DB_PATH) -> None:
|
||||
"""Initialize the custom bot manager.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file.
|
||||
|
||||
"""
|
||||
self.db_path = db_path
|
||||
self._initialize_custom_bots_table()
|
||||
|
||||
def _initialize_custom_bots_table(self) -> None:
|
||||
"""Initialize the custom bots table in SQLite."""
|
||||
initialize_custom_bots_table(self.db_path)
|
||||
|
||||
def create_custom_bot(
|
||||
self,
|
||||
bot_name: str,
|
||||
system_prompt: str,
|
||||
created_by: str,
|
||||
) -> str | bool:
|
||||
"""Create a custom bot configuration.
|
||||
|
||||
Returns "created" if the name was new, "replaced" if a bot with that
|
||||
name already existed (the namespace is shared), or False on error.
|
||||
"""
|
||||
conn = connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
name = bot_name.lower()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"SELECT 1 FROM custom_bots WHERE bot_name = ?",
|
||||
(name,),
|
||||
)
|
||||
exists = cursor.fetchone() is not None
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO custom_bots
|
||||
(bot_name, system_prompt, created_by, is_active)
|
||||
VALUES (?, ?, ?, 1)
|
||||
""",
|
||||
(name, system_prompt, created_by),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Error creating custom bot")
|
||||
conn.rollback()
|
||||
return False
|
||||
else:
|
||||
return "replaced" if exists else "created"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_custom_bot(self, bot_name: str) -> tuple[str, str, str, datetime] | None:
|
||||
"""Get a custom bot configuration by name."""
|
||||
conn = connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT bot_name, system_prompt, created_by, created_at
|
||||
FROM custom_bots
|
||||
WHERE bot_name = ? AND is_active = 1
|
||||
""",
|
||||
(bot_name.lower(),),
|
||||
)
|
||||
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if result is None:
|
||||
return None
|
||||
return (result[0], result[1], result[2], result[3])
|
||||
|
||||
def list_custom_bots(
|
||||
self,
|
||||
user_id: str | None = None,
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""List all custom bots, optionally filtered by creator."""
|
||||
conn = connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
if user_id:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT bot_name, system_prompt, created_by
|
||||
FROM custom_bots
|
||||
WHERE is_active = 1 AND created_by = ?
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT bot_name, system_prompt, created_by
|
||||
FROM custom_bots
|
||||
WHERE is_active = 1
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
)
|
||||
|
||||
bots = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
return bots
|
||||
|
||||
def delete_custom_bot(self, bot_name: str) -> bool:
|
||||
"""Delete a custom bot configuration."""
|
||||
conn = connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM custom_bots
|
||||
WHERE bot_name = ?
|
||||
""",
|
||||
(bot_name.lower(),),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Error deleting custom bot")
|
||||
conn.rollback()
|
||||
return False
|
||||
else:
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""SQLite connection plumbing shared by the database layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
# Per-connection busy timeout (ms) so concurrent writers wait instead of
|
||||
# failing with "database is locked".
|
||||
SQLITE_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
|
||||
def _parse_timestamp(value: str | bytes) -> datetime:
|
||||
"""Decode a stored TIMESTAMP value into a naive datetime."""
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf-8")
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
|
||||
sqlite3.register_converter("TIMESTAMP", _parse_timestamp)
|
||||
|
||||
|
||||
def connect(db_path: str) -> sqlite3.Connection:
|
||||
"""Open a SQLite connection configured for concurrent access.
|
||||
|
||||
WAL journaling is persistent (set once per database file); the busy
|
||||
timeout is per-connection, so it is applied on every connection here.
|
||||
``PARSE_DECLTYPES`` plus the registered ``TIMESTAMP`` converter decode
|
||||
declared ``TIMESTAMP`` columns into ``datetime`` objects instead of
|
||||
raw strings.
|
||||
|
||||
"""
|
||||
conn = sqlite3.connect(db_path, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(f"PRAGMA busy_timeout={SQLITE_BUSY_TIMEOUT_MS}")
|
||||
return conn
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Chat message store: persistence, cleanup, and recency queries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vibe_bot import llm_client
|
||||
from vibe_bot.config import (
|
||||
DB_PATH,
|
||||
EMBEDDING_ENDPOINT,
|
||||
EMBEDDING_ENDPOINT_KEY,
|
||||
EMBEDDING_MODEL,
|
||||
MAX_HISTORY_MESSAGES,
|
||||
SIMILARITY_THRESHOLD,
|
||||
TOP_K_RESULTS,
|
||||
)
|
||||
from vibe_bot.db.connection import connect
|
||||
from vibe_bot.db.schema import initialize_chat_tables
|
||||
from vibe_bot.db.search import (
|
||||
get_bot_history,
|
||||
get_user_history,
|
||||
search_similar_messages,
|
||||
)
|
||||
from vibe_bot.db.timing import (
|
||||
get_image_generation_time_estimate,
|
||||
record_image_generation_time,
|
||||
)
|
||||
from vibe_bot.db.vectors import (
|
||||
bytes_to_vector,
|
||||
cosine_similarity,
|
||||
vector_to_bytes,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatDatabase:
|
||||
"""SQLite store for chat history, embedding-backed RAG, and image timing."""
|
||||
|
||||
def __init__(self, db_path: str = DB_PATH) -> None:
|
||||
"""Initialize the database connection.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file.
|
||||
|
||||
"""
|
||||
logger.info("Initializing ChatDatabase with path: %s", db_path)
|
||||
self.db_path = db_path
|
||||
initialize_chat_tables(db_path)
|
||||
|
||||
def _vector_to_bytes(self, vector: list[float]) -> bytes:
|
||||
"""Convert vector to bytes for SQLite storage."""
|
||||
return vector_to_bytes(vector)
|
||||
|
||||
def _bytes_to_vector(self, blob: bytes) -> np.ndarray:
|
||||
"""Convert bytes back to a vector."""
|
||||
return bytes_to_vector(blob)
|
||||
|
||||
def _calculate_similarity(self, vec1: np.ndarray, vec2: np.ndarray) -> float:
|
||||
"""Calculate cosine similarity between two vectors."""
|
||||
return cosine_similarity(vec1, vec2)
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
*,
|
||||
message_id: str,
|
||||
user_id: str,
|
||||
username: str,
|
||||
content: str,
|
||||
bot_name: str | None = None,
|
||||
channel_id: str | None = None,
|
||||
guild_id: str | None = None,
|
||||
role: str = "user",
|
||||
embed: bool = True,
|
||||
) -> bool:
|
||||
"""Add a message to the database, optionally storing its embedding.
|
||||
|
||||
Args:
|
||||
role: Either "user" (a human message) or "assistant" (a bot
|
||||
response). Used to scope RAG retrieval instead of matching a
|
||||
hard-coded bot username.
|
||||
embed: Whether to generate and store an embedding for the message.
|
||||
Response rows pass False: only user rows feed RAG retrieval.
|
||||
|
||||
"""
|
||||
logger.debug("Adding message %s from user %s", message_id, user_id)
|
||||
conn = connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
logger.debug(
|
||||
"Inserting message into chat_messages table: message_id=%s",
|
||||
message_id,
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO chat_messages
|
||||
(message_id, user_id, username, content, bot_name, channel_id,
|
||||
guild_id, role)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
message_id,
|
||||
user_id,
|
||||
username,
|
||||
content,
|
||||
bot_name,
|
||||
channel_id,
|
||||
guild_id,
|
||||
role,
|
||||
),
|
||||
)
|
||||
logger.debug("Message %s inserted into chat_messages table", message_id)
|
||||
|
||||
if embed:
|
||||
logger.debug("Generating embedding for message %s", message_id)
|
||||
embedding = llm_client.embedding(
|
||||
content,
|
||||
model=EMBEDDING_MODEL,
|
||||
url=EMBEDDING_ENDPOINT,
|
||||
api_key=EMBEDDING_ENDPOINT_KEY,
|
||||
)
|
||||
if embedding:
|
||||
logger.debug(
|
||||
"Embedding generated successfully for message %s, "
|
||||
"storing in database",
|
||||
message_id,
|
||||
)
|
||||
vector = np.array(embedding, dtype=np.float32)
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO message_embeddings
|
||||
(message_id, embedding, norm)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(message_id, vector.tobytes(), float(np.linalg.norm(vector))),
|
||||
)
|
||||
logger.debug(
|
||||
"Embedding stored in message_embeddings table for message %s",
|
||||
message_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to generate embedding for message %s, "
|
||||
"skipping embedding storage",
|
||||
message_id,
|
||||
)
|
||||
|
||||
logger.debug("Checking if cleanup of old messages is needed")
|
||||
self._cleanup_old_messages(cursor)
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Error adding message %s", message_id)
|
||||
conn.rollback()
|
||||
return False
|
||||
else:
|
||||
logger.debug("Successfully added message %s to database", message_id)
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _cleanup_old_messages(self, cursor: sqlite3.Cursor) -> None:
|
||||
"""Remove old messages to stay within the limit.
|
||||
|
||||
The rows to delete are captured up front. Deriving the embedding
|
||||
message_ids from a fresh subquery *after* the chat_messages delete
|
||||
would select the next-oldest live rows instead of the ones just
|
||||
removed, orphaning the deleted rows' embeddings and deleting the
|
||||
embeddings of rows that should survive.
|
||||
|
||||
"""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM chat_messages
|
||||
""",
|
||||
)
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
if count <= MAX_HISTORY_MESSAGES:
|
||||
return
|
||||
|
||||
excess = count - MAX_HISTORY_MESSAGES
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, message_id FROM chat_messages
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(excess,),
|
||||
)
|
||||
oldest = cursor.fetchall()
|
||||
if not oldest:
|
||||
return
|
||||
|
||||
row_ids = [row[0] for row in oldest]
|
||||
# Include each row's `_response` companion so a deleted user message
|
||||
# also sheds its response embedding (and vice versa).
|
||||
message_ids: list[str] = []
|
||||
for _id, message_id in oldest:
|
||||
message_ids.append(message_id)
|
||||
message_ids.append(f"{message_id}_response")
|
||||
|
||||
id_placeholders = ", ".join("?" for _ in row_ids)
|
||||
cursor.execute(
|
||||
f"DELETE FROM chat_messages WHERE id IN ({id_placeholders})",
|
||||
row_ids,
|
||||
)
|
||||
mid_placeholders = ", ".join("?" for _ in message_ids)
|
||||
cursor.execute(
|
||||
f"DELETE FROM message_embeddings WHERE message_id IN ({mid_placeholders})",
|
||||
message_ids,
|
||||
)
|
||||
|
||||
def search_similar_messages(
|
||||
self,
|
||||
query: str,
|
||||
top_k: int = TOP_K_RESULTS,
|
||||
min_similarity: float = SIMILARITY_THRESHOLD,
|
||||
) -> list[tuple[str, str, float]]:
|
||||
"""Search for messages similar to the query using embeddings."""
|
||||
return search_similar_messages(
|
||||
self.db_path,
|
||||
query,
|
||||
top_k=top_k,
|
||||
min_similarity=min_similarity,
|
||||
)
|
||||
|
||||
def get_bot_history(self, bot_name: str, limit: int = 20) -> list[tuple[str, str]]:
|
||||
"""Get message history for a specific custom bot.
|
||||
|
||||
Args:
|
||||
bot_name: The name of the custom bot.
|
||||
limit: Maximum number of messages to retrieve.
|
||||
|
||||
Returns:
|
||||
List of (user_message, bot_response) tuples.
|
||||
|
||||
"""
|
||||
return get_bot_history(self.db_path, bot_name, limit)
|
||||
|
||||
def get_user_history(self, user_id: str, limit: int = 20) -> list[tuple[str, str]]:
|
||||
"""Get message history for a specific user."""
|
||||
return get_user_history(self.db_path, user_id, limit)
|
||||
|
||||
def get_conversation_context(
|
||||
self,
|
||||
user_id: str,
|
||||
current_message: str,
|
||||
max_context: int = 5,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Get relevant conversation context for RAG."""
|
||||
recent_messages = get_user_history(self.db_path, user_id, limit=max_context * 2)
|
||||
|
||||
similar_messages = search_similar_messages(
|
||||
self.db_path,
|
||||
current_message,
|
||||
top_k=max_context,
|
||||
)
|
||||
|
||||
context_parts: list[dict[str, str]] = []
|
||||
|
||||
for user_message, bot_message in recent_messages:
|
||||
context_parts.append({"role": "assistant", "content": bot_message})
|
||||
context_parts.append({"role": "user", "content": user_message})
|
||||
|
||||
for user_message, bot_message, _similarity in similar_messages:
|
||||
context_parts.append({"role": "assistant", "content": bot_message})
|
||||
context_parts.append({"role": "user", "content": user_message})
|
||||
|
||||
# Conversation history needs to be delivered in "newest context last" order
|
||||
context_parts.reverse()
|
||||
return context_parts
|
||||
|
||||
def clear_all_messages(self) -> None:
|
||||
"""Clear all messages and embeddings from the database."""
|
||||
conn = connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM message_embeddings")
|
||||
cursor.execute("DELETE FROM chat_messages")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def record_image_generation_time(self, duration_seconds: float) -> bool:
|
||||
"""Record how long an image generation took.
|
||||
|
||||
Args:
|
||||
duration_seconds: Wall-clock seconds the generation took.
|
||||
|
||||
"""
|
||||
return record_image_generation_time(self.db_path, duration_seconds)
|
||||
|
||||
def get_image_generation_time_estimate(self) -> float | None:
|
||||
"""Get a moving-average estimate of image generation time.
|
||||
|
||||
Returns:
|
||||
The average duration in seconds over the most recent generations,
|
||||
or None if there is no generation history yet.
|
||||
|
||||
"""
|
||||
return get_image_generation_time_estimate(self.db_path)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Schema creation and column migrations for the chat and custom-bot tables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vibe_bot.db.connection import connect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def initialize_chat_tables(db_path: str) -> None:
|
||||
"""Create (and migrate) the chat history and embedding tables."""
|
||||
logger.info("Initializing SQLite database at %s", db_path)
|
||||
conn = connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.info("Creating chat_messages table if not exists")
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id TEXT UNIQUE,
|
||||
user_id TEXT,
|
||||
username TEXT,
|
||||
content TEXT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
channel_id TEXT,
|
||||
guild_id TEXT
|
||||
)
|
||||
""",
|
||||
)
|
||||
logger.info("chat_messages table initialized successfully")
|
||||
_migrate_chat_messages(cursor)
|
||||
|
||||
logger.info("Creating message_embeddings table if not exists")
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_embeddings (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
embedding BLOB,
|
||||
norm REAL,
|
||||
FOREIGN KEY (message_id) REFERENCES chat_messages(message_id)
|
||||
)
|
||||
""",
|
||||
)
|
||||
logger.info("message_embeddings table initialized successfully")
|
||||
_migrate_message_embeddings(cursor)
|
||||
|
||||
logger.info("Creating idx_timestamp index if not exists")
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_timestamp ON chat_messages(timestamp)
|
||||
""",
|
||||
)
|
||||
logger.info("idx_timestamp index created successfully")
|
||||
|
||||
logger.info("Creating idx_user_id index if not exists")
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_user_id ON chat_messages(user_id)
|
||||
""",
|
||||
)
|
||||
logger.info("idx_user_id index created successfully")
|
||||
|
||||
logger.info("Creating image_generation_times table if not exists")
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS image_generation_times (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
duration_seconds REAL NOT NULL,
|
||||
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
)
|
||||
logger.info("image_generation_times table initialized successfully")
|
||||
|
||||
conn.commit()
|
||||
logger.info("Database initialization completed successfully")
|
||||
conn.close()
|
||||
|
||||
|
||||
def _migrate_chat_messages(cursor: sqlite3.Cursor) -> None:
|
||||
"""Add the bot_name and role columns to pre-existing databases."""
|
||||
logger.info("Checking for chat_messages column migrations")
|
||||
cursor.execute("PRAGMA table_info(chat_messages)")
|
||||
columns = {row[1] for row in cursor.fetchall()}
|
||||
|
||||
if "bot_name" not in columns:
|
||||
logger.info("Adding bot_name column to chat_messages table")
|
||||
cursor.execute("ALTER TABLE chat_messages ADD COLUMN bot_name TEXT")
|
||||
logger.info("bot_name column added successfully")
|
||||
|
||||
# role replaces the old convention of identifying bot responses by a
|
||||
# hard-coded bot username.
|
||||
if "role" not in columns:
|
||||
logger.info("Adding role column to chat_messages table")
|
||||
cursor.execute("ALTER TABLE chat_messages ADD COLUMN role TEXT")
|
||||
cursor.execute(
|
||||
"UPDATE chat_messages SET role = 'assistant' "
|
||||
"WHERE message_id LIKE '%_response' AND role IS NULL",
|
||||
)
|
||||
cursor.execute(
|
||||
"UPDATE chat_messages SET role = 'user' WHERE role IS NULL",
|
||||
)
|
||||
logger.info("role column added and backfilled")
|
||||
|
||||
|
||||
# Backfill in batches so a large legacy table does not build one huge
|
||||
# executemany parameter list in memory.
|
||||
NORM_BACKFILL_BATCH = 500
|
||||
|
||||
|
||||
def _migrate_message_embeddings(cursor: sqlite3.Cursor) -> None:
|
||||
"""Add the norm column to pre-existing databases and backfill it.
|
||||
|
||||
The norm is the L2 norm of the stored float32 blob, so search can score
|
||||
candidates with one matrix multiply and no per-vector renormalization.
|
||||
"""
|
||||
logger.info("Checking for message_embeddings column migrations")
|
||||
cursor.execute("PRAGMA table_info(message_embeddings)")
|
||||
columns = {row[1] for row in cursor.fetchall()}
|
||||
|
||||
if "norm" in columns:
|
||||
return
|
||||
|
||||
logger.info("Adding norm column to message_embeddings table")
|
||||
cursor.execute("ALTER TABLE message_embeddings ADD COLUMN norm REAL")
|
||||
|
||||
cursor.execute(
|
||||
"SELECT message_id, embedding FROM message_embeddings "
|
||||
"WHERE embedding IS NOT NULL",
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
for start in range(0, len(rows), NORM_BACKFILL_BATCH):
|
||||
cursor.executemany(
|
||||
"UPDATE message_embeddings SET norm = ? WHERE message_id = ?",
|
||||
[
|
||||
(
|
||||
float(np.linalg.norm(np.frombuffer(blob, dtype=np.float32))),
|
||||
message_id,
|
||||
)
|
||||
for message_id, blob in rows[start : start + NORM_BACKFILL_BATCH]
|
||||
],
|
||||
)
|
||||
logger.info("norm column added and backfilled for %d rows", len(rows))
|
||||
|
||||
|
||||
def initialize_custom_bots_table(db_path: str) -> None:
|
||||
"""Create the custom bots table in SQLite."""
|
||||
conn = connect(db_path)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS custom_bots (
|
||||
bot_name TEXT PRIMARY KEY,
|
||||
system_prompt TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active INTEGER DEFAULT 1
|
||||
)
|
||||
""",
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -0,0 +1,204 @@
|
||||
"""RAG retrieval: similarity search over user messages and history lookups."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vibe_bot import llm_client
|
||||
from vibe_bot.config import (
|
||||
EMBEDDING_ENDPOINT,
|
||||
EMBEDDING_ENDPOINT_KEY,
|
||||
EMBEDDING_MODEL,
|
||||
SIMILARITY_THRESHOLD,
|
||||
TOP_K_RESULTS,
|
||||
)
|
||||
from vibe_bot.db.connection import connect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def search_similar_messages(
|
||||
db_path: str,
|
||||
query: str,
|
||||
top_k: int = TOP_K_RESULTS,
|
||||
min_similarity: float = SIMILARITY_THRESHOLD,
|
||||
) -> list[tuple[str, str, float]]:
|
||||
"""Search for messages similar to the query using embeddings.
|
||||
|
||||
A single JOIN pulls every user row, its stored embedding, the stored L2
|
||||
norm, and its ``_response`` companion. Similarities are one matrix
|
||||
multiply over the stored norms — no per-vector renormalization. Rows
|
||||
with a missing or zero norm score 0 instead of dividing by zero.
|
||||
"""
|
||||
query_embedding = llm_client.embedding(
|
||||
text=query,
|
||||
model=EMBEDDING_MODEL,
|
||||
url=EMBEDDING_ENDPOINT,
|
||||
api_key=EMBEDDING_ENDPOINT_KEY,
|
||||
)
|
||||
if not query_embedding:
|
||||
return []
|
||||
|
||||
query_vector = np.array(query_embedding, dtype=np.float32)
|
||||
query_norm = float(np.linalg.norm(query_vector))
|
||||
if query_norm == 0:
|
||||
return []
|
||||
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT cm.content, r.content, me.embedding, me.norm
|
||||
FROM chat_messages cm
|
||||
JOIN message_embeddings me ON me.message_id = cm.message_id
|
||||
LEFT JOIN chat_messages r ON r.message_id = cm.message_id || '_response'
|
||||
WHERE cm.role = 'user'
|
||||
""",
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
n_rows = len(rows)
|
||||
blobs = [embedding_blob for _c, _r, embedding_blob, _n in rows]
|
||||
dim = len(blobs[0]) // 4
|
||||
if sum(len(blob) for blob in blobs) == n_rows * dim * 4:
|
||||
vectors = np.frombuffer(b"".join(blobs), dtype=np.float32).reshape(n_rows, dim)
|
||||
else:
|
||||
# Mixed blob lengths (e.g. EMBEDDING_MODEL changed mid-life) can't be
|
||||
# batched into one reshape; reconstruct per row, zero-padded (or
|
||||
# truncated) to the query dim so the single matrix multiply still works.
|
||||
vectors = np.zeros((n_rows, query_vector.size), dtype=np.float32)
|
||||
for i, blob in enumerate(blobs):
|
||||
row = np.frombuffer(blob, dtype=np.float32)
|
||||
k = min(row.size, query_vector.size)
|
||||
vectors[i, :k] = row[:k]
|
||||
norms = np.array(
|
||||
[
|
||||
stored_norm if stored_norm is not None else 0.0
|
||||
for _c, _r, _b, stored_norm in rows
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
safe_norms = np.where(norms > 0, norms, 1.0)
|
||||
similarities = vectors @ query_vector / (safe_norms * query_norm)
|
||||
similarities = np.where(norms > 0, similarities, 0.0)
|
||||
|
||||
results: list[tuple[str, str, float]] = []
|
||||
for (content, response, _blob, _norm), similarity in zip(
|
||||
rows, similarities, strict=True
|
||||
):
|
||||
if response is None or similarity < min_similarity:
|
||||
continue
|
||||
results.append((str(content), str(response), float(similarity)))
|
||||
|
||||
results.sort(key=lambda item: item[2], reverse=True)
|
||||
return results[:top_k]
|
||||
|
||||
|
||||
def get_bot_history(
|
||||
db_path: str, bot_name: str, limit: int = 20
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Get message history for a specific custom bot.
|
||||
|
||||
Args:
|
||||
bot_name: The name of the custom bot.
|
||||
limit: Maximum number of messages to retrieve.
|
||||
|
||||
Returns:
|
||||
List of (user_message, bot_response) tuples.
|
||||
|
||||
"""
|
||||
conn = connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.debug(
|
||||
"Fetching last %d messages for bot %r",
|
||||
limit,
|
||||
bot_name,
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT message_id, content
|
||||
FROM chat_messages
|
||||
WHERE bot_name = ? AND message_id NOT LIKE '%%_response'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(bot_name, limit),
|
||||
)
|
||||
|
||||
conversations: list[tuple[str, str]] = []
|
||||
try:
|
||||
for message_id, msg_content in cursor.fetchall():
|
||||
logger.debug("Finding response for message_id=%s", message_id)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT content
|
||||
FROM chat_messages
|
||||
WHERE message_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
""",
|
||||
(f"{message_id}_response",),
|
||||
)
|
||||
response_row = cursor.fetchone()
|
||||
if response_row:
|
||||
logger.debug("Found response for message_id=%s", message_id)
|
||||
conversations.append((str(msg_content), str(response_row[0])))
|
||||
else:
|
||||
logger.debug("No response found")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return conversations
|
||||
|
||||
|
||||
def get_user_history(
|
||||
db_path: str, user_id: str, limit: int = 20
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Get message history for a specific user."""
|
||||
conn = connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.debug("Fetching last %d user messages", limit)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT message_id, content
|
||||
FROM chat_messages
|
||||
WHERE user_id = ? AND role = 'user'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(user_id, limit),
|
||||
)
|
||||
|
||||
# Format is [user message, bot response]
|
||||
conversations: list[tuple[str, str]] = []
|
||||
try:
|
||||
for message_id, msg_content in cursor.fetchall():
|
||||
logger.debug("Finding response for message_id=%s", message_id)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT content
|
||||
FROM chat_messages
|
||||
WHERE message_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
""",
|
||||
(f"{message_id}_response",),
|
||||
)
|
||||
response_row = cursor.fetchone()
|
||||
if response_row:
|
||||
logger.debug("Found response for message_id=%s", message_id)
|
||||
conversations.append((str(msg_content), str(response_row[0])))
|
||||
else:
|
||||
logger.debug("No response found")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return conversations
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Image-generation timing statistics (moving-average estimate)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from vibe_bot.db.connection import connect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Moving-average window (most recent generations) for the time estimate.
|
||||
IMAGE_GEN_TIME_WINDOW = 10
|
||||
# Maximum number of generation times retained in the database.
|
||||
IMAGE_GEN_TIME_LIMIT = 100
|
||||
|
||||
|
||||
def record_image_generation_time(db_path: str, duration_seconds: float) -> bool:
|
||||
"""Record how long an image generation took.
|
||||
|
||||
Args:
|
||||
duration_seconds: Wall-clock seconds the generation took.
|
||||
|
||||
"""
|
||||
logger.debug("Recording image generation time: %.2fs", duration_seconds)
|
||||
conn = connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO image_generation_times (duration_seconds)
|
||||
VALUES (?)
|
||||
""",
|
||||
(duration_seconds,),
|
||||
)
|
||||
|
||||
# Cap the table so it doesn't grow unbounded.
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM image_generation_times
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM image_generation_times
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(IMAGE_GEN_TIME_LIMIT,),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
logger.exception("Error recording image generation time")
|
||||
conn.rollback()
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_image_generation_time_estimate(db_path: str) -> float | None:
|
||||
"""Get a moving-average estimate of image generation time.
|
||||
|
||||
Returns:
|
||||
The average duration in seconds over the most recent generations,
|
||||
or None if there is no generation history yet.
|
||||
|
||||
"""
|
||||
logger.debug("Computing moving-average image generation time estimate")
|
||||
conn = connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT AVG(duration_seconds)
|
||||
FROM (
|
||||
SELECT duration_seconds
|
||||
FROM image_generation_times
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(IMAGE_GEN_TIME_WINDOW,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
except Exception:
|
||||
logger.exception("Error reading image generation times")
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if row is None or row[0] is None:
|
||||
return None
|
||||
return float(row[0])
|
||||
@@ -0,0 +1,42 @@
|
||||
"""float32 embedding (de)serialization and cosine similarity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def vector_to_bytes(vector: list[float]) -> bytes:
|
||||
"""Convert a vector to bytes for SQLite storage."""
|
||||
logger.debug("Converting vector (length: %d) to bytes", len(vector))
|
||||
result = np.array(vector, dtype=np.float32).tobytes()
|
||||
logger.debug("Vector converted to %d bytes", len(result))
|
||||
return result
|
||||
|
||||
|
||||
def bytes_to_vector(blob: bytes) -> np.ndarray:
|
||||
"""Convert bytes back to a vector."""
|
||||
logger.debug("Converting %d bytes back to vector", len(blob))
|
||||
result = np.frombuffer(blob, dtype=np.float32)
|
||||
logger.debug("Vector reconstructed with %d dimensions", len(result))
|
||||
return result
|
||||
|
||||
|
||||
def cosine_similarity(vec1: np.ndarray, vec2: np.ndarray) -> float:
|
||||
"""Calculate cosine similarity between two vectors."""
|
||||
vec1 = vec1.flatten()
|
||||
vec2 = vec2.flatten()
|
||||
logger.debug(
|
||||
"Calculating cosine similarity between vectors of dimension %d",
|
||||
len(vec1),
|
||||
)
|
||||
norm1 = np.linalg.norm(vec1)
|
||||
norm2 = np.linalg.norm(vec2)
|
||||
if norm1 == 0 or norm2 == 0:
|
||||
return 0.0
|
||||
result = float(np.dot(vec1, vec2) / (norm1 * norm2))
|
||||
logger.debug("Similarity calculated: %.4f", result)
|
||||
return result
|
||||
@@ -1,431 +0,0 @@
|
||||
"""Wraps the openai calls in generic functions.
|
||||
|
||||
Supports chat, image, edit, and embeddings.
|
||||
Allows custom endpoints for each of the above supported functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
import openai
|
||||
import requests
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from io import BufferedReader, BytesIO
|
||||
|
||||
from openai.types.chat import ChatCompletionMessageParam
|
||||
|
||||
|
||||
def chat_completion(
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
*,
|
||||
openai_url: str,
|
||||
openai_api_key: str,
|
||||
model: str,
|
||||
max_tokens: int = 1000,
|
||||
) -> str:
|
||||
"""Send a chat completion request and return the response.
|
||||
|
||||
Args:
|
||||
system_prompt: The system prompt to use.
|
||||
user_prompt: The user prompt to send.
|
||||
openai_url: The OpenAI-compatible API URL.
|
||||
openai_api_key: The API key for authentication.
|
||||
model: The model to use for completion.
|
||||
max_tokens: Maximum number of tokens to generate.
|
||||
|
||||
Returns:
|
||||
The model's response text, stripped of whitespace.
|
||||
|
||||
"""
|
||||
client = openai.OpenAI(base_url=openai_url, api_key=openai_api_key)
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_prompt,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": user_prompt,
|
||||
},
|
||||
]
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
if not response.choices:
|
||||
return ""
|
||||
|
||||
content = response.choices[0].message.content
|
||||
if content:
|
||||
return content.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def chat_completion_with_history(
|
||||
system_prompt: str,
|
||||
prompts: list[dict[str, str]],
|
||||
*,
|
||||
openai_url: str,
|
||||
openai_api_key: str,
|
||||
model: str,
|
||||
max_tokens: int = 1000,
|
||||
) -> str:
|
||||
"""Send a chat completion request with conversation history.
|
||||
|
||||
Args:
|
||||
system_prompt: The system prompt to use.
|
||||
prompts: List of prompt dicts with role and content.
|
||||
openai_url: The OpenAI-compatible API URL.
|
||||
openai_api_key: The API key for authentication.
|
||||
model: The model to use for completion.
|
||||
max_tokens: Maximum number of tokens to generate.
|
||||
|
||||
Returns:
|
||||
The model's response text, stripped of whitespace.
|
||||
|
||||
"""
|
||||
client = openai.OpenAI(base_url=openai_url, api_key=openai_api_key)
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
cast(
|
||||
"ChatCompletionMessageParam",
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_prompt,
|
||||
},
|
||||
),
|
||||
]
|
||||
messages.extend(cast("list[ChatCompletionMessageParam]", prompts))
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
seed=-1,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
if not response.choices:
|
||||
return ""
|
||||
|
||||
content = response.choices[0].message.content
|
||||
if content:
|
||||
return content.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def chat_completion_instruct(
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
*,
|
||||
openai_url: str,
|
||||
openai_api_key: str,
|
||||
model: str,
|
||||
max_tokens: int = 1000,
|
||||
) -> str:
|
||||
"""Send an instruction-based chat completion request.
|
||||
|
||||
Args:
|
||||
system_prompt: The system prompt to use.
|
||||
user_prompt: The user prompt to send.
|
||||
openai_url: The OpenAI-compatible API URL.
|
||||
openai_api_key: The API key for authentication.
|
||||
model: The model to use for completion.
|
||||
max_tokens: Maximum number of tokens to generate.
|
||||
|
||||
Returns:
|
||||
The model's response text, stripped of whitespace.
|
||||
|
||||
"""
|
||||
client = openai.OpenAI(base_url=openai_url, api_key=openai_api_key)
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_prompt,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": user_prompt,
|
||||
},
|
||||
]
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
seed=-1,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
if not response.choices:
|
||||
return ""
|
||||
|
||||
content = response.choices[0].message.content
|
||||
if content:
|
||||
return content.strip()
|
||||
return ""
|
||||
|
||||
|
||||
async def chat_completion_with_tools(
|
||||
system_prompt: str,
|
||||
prompts: list[dict[str, str]],
|
||||
tools: list[dict[str, object]],
|
||||
tool_executor: Callable[[str, dict[str, str]], str],
|
||||
*,
|
||||
openai_url: str,
|
||||
openai_api_key: str,
|
||||
model: str,
|
||||
max_tokens: int = 1000,
|
||||
max_tool_rounds: int = 5,
|
||||
tool_call_notifier: (
|
||||
Callable[[str, dict[str, str]], None]
|
||||
| Callable[[str, dict[str, str]], Awaitable[None]]
|
||||
| None
|
||||
) = None,
|
||||
) -> str:
|
||||
"""Send a chat completion request with tool support and iterative tool calling.
|
||||
|
||||
Args:
|
||||
system_prompt: The system prompt to use.
|
||||
prompts: List of prompt dicts with role and content.
|
||||
tools: List of tool definitions in OpenAI format.
|
||||
tool_executor: A callable that takes (tool_name: str, tool_args: dict) -> str.
|
||||
openai_url: The OpenAI-compatible API URL.
|
||||
openai_api_key: The API key for authentication.
|
||||
model: The model to use for completion.
|
||||
max_tokens: Maximum number of tokens to generate.
|
||||
max_tool_rounds: Maximum number of tool call rounds before giving up.
|
||||
tool_call_notifier: Optional callback invoked before each tool call
|
||||
with (tool_name, tool_args).
|
||||
|
||||
Returns:
|
||||
The model's final response text, stripped of whitespace.
|
||||
|
||||
"""
|
||||
client = openai.OpenAI(base_url=openai_url, api_key=openai_api_key)
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
cast(
|
||||
"ChatCompletionMessageParam",
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_prompt,
|
||||
},
|
||||
),
|
||||
]
|
||||
messages.extend(cast("list[ChatCompletionMessageParam]", prompts))
|
||||
|
||||
for _round in range(max_tool_rounds):
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
tools=tools, # type: ignore[arg-type]
|
||||
max_tokens=max_tokens,
|
||||
seed=-1,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
if not response.choices:
|
||||
return ""
|
||||
|
||||
message = response.choices[0].message
|
||||
|
||||
# Check if the model wants to call a tool
|
||||
tool_calls = message.tool_calls
|
||||
if tool_calls:
|
||||
assistant_msg: dict[str, object] = {
|
||||
"role": "assistant",
|
||||
"content": message.content or "",
|
||||
}
|
||||
tool_call_dicts: list[dict[str, object]] = []
|
||||
for tool_call in tool_calls:
|
||||
tool_call_dicts.append(
|
||||
{
|
||||
"id": tool_call.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_call.function.name, # type: ignore[union-attr]
|
||||
"arguments": tool_call.function.arguments, # type: ignore[union-attr]
|
||||
},
|
||||
},
|
||||
)
|
||||
assistant_msg["tool_calls"] = tool_call_dicts
|
||||
messages.append(cast("ChatCompletionMessageParam", assistant_msg))
|
||||
|
||||
# Execute each tool call and add results to messages
|
||||
for tool_call in tool_calls:
|
||||
tool_name = tool_call.function.name # type: ignore[union-attr]
|
||||
tool_args = json.loads(tool_call.function.arguments) # type: ignore[union-attr]
|
||||
|
||||
if tool_call_notifier:
|
||||
result = tool_call_notifier(tool_name, tool_args)
|
||||
if hasattr(result, "__await__"):
|
||||
await result # type: ignore[misc]
|
||||
|
||||
tool_result = tool_executor(tool_name, tool_args)
|
||||
|
||||
messages.append(
|
||||
cast(
|
||||
"ChatCompletionMessageParam",
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"content": tool_result,
|
||||
},
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
# No more tool calls, return the final response
|
||||
content = message.content
|
||||
if content:
|
||||
return content.strip()
|
||||
return ""
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def image_generation(
|
||||
prompt: str,
|
||||
*,
|
||||
openai_url: str,
|
||||
openai_api_key: str,
|
||||
model: str = "gen",
|
||||
n: int = 1,
|
||||
size: str = "1024x1024",
|
||||
) -> str:
|
||||
"""Generate an image using the given prompt.
|
||||
|
||||
Args:
|
||||
prompt: The image generation prompt.
|
||||
openai_url: The OpenAI-compatible API URL.
|
||||
openai_api_key: The API key for authentication.
|
||||
model: The model to use for image generation.
|
||||
n: Number of images to generate.
|
||||
size: The size of the generated image, e.g. "1024x1024".
|
||||
|
||||
Returns:
|
||||
The base64 encoded image data. Decode and write to a file.
|
||||
|
||||
"""
|
||||
client = openai.OpenAI(
|
||||
base_url=openai_url,
|
||||
api_key=openai_api_key,
|
||||
max_retries=0,
|
||||
)
|
||||
try:
|
||||
response = client.images.generate(
|
||||
prompt=prompt,
|
||||
n=n,
|
||||
size=size,
|
||||
model=model,
|
||||
timeout=300.0,
|
||||
)
|
||||
except openai.APIConnectionError:
|
||||
return ""
|
||||
if response.data:
|
||||
return response.data[0].b64_json or ""
|
||||
return ""
|
||||
|
||||
|
||||
def image_edit(
|
||||
image: BufferedReader | BytesIO | list[BufferedReader] | list[BytesIO],
|
||||
prompt: str,
|
||||
*,
|
||||
openai_url: str,
|
||||
openai_api_key: str,
|
||||
model: str = "edit",
|
||||
n: int = 1,
|
||||
) -> str:
|
||||
"""Edit an existing image using a prompt.
|
||||
|
||||
Args:
|
||||
image: The source image as a file-like object or list thereof.
|
||||
prompt: The edit instruction.
|
||||
openai_url: The OpenAI-compatible API URL.
|
||||
openai_api_key: The API key for authentication.
|
||||
model: The model to use for image editing.
|
||||
n: Number of edited images to generate.
|
||||
|
||||
Returns:
|
||||
The base64 encoded edited image data.
|
||||
|
||||
"""
|
||||
client = openai.OpenAI(base_url=openai_url, api_key=openai_api_key)
|
||||
response = client.images.edit(
|
||||
image=image,
|
||||
prompt=prompt,
|
||||
n=n,
|
||||
size="768x768",
|
||||
model=model,
|
||||
)
|
||||
if response.data:
|
||||
return response.data[0].b64_json or ""
|
||||
return ""
|
||||
|
||||
|
||||
def embedding(
|
||||
text: str,
|
||||
*,
|
||||
openai_url: str,
|
||||
openai_api_key: str,
|
||||
model: str,
|
||||
) -> list[float]:
|
||||
"""Generate an embedding vector for the given text.
|
||||
|
||||
Uses a raw HTTP request to avoid the OpenAI SDK injecting
|
||||
unsupported parameters like encoding_format.
|
||||
|
||||
Args:
|
||||
text: The text to embed.
|
||||
openai_url: The OpenAI-compatible API URL.
|
||||
openai_api_key: The API key for authentication.
|
||||
model: The embedding model to use.
|
||||
|
||||
Returns:
|
||||
The embedding vector as a list of floats, or an empty list on failure.
|
||||
|
||||
"""
|
||||
url = f"{openai_url.rstrip('/')}/embeddings"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {openai_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {"model": model, "input": [text]}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException:
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
|
||||
# Handle both OpenAI-style response ({"data": [...]}) and
|
||||
# Ollama-style response ([{...}]) where the API returns a list directly
|
||||
if isinstance(data, list):
|
||||
first = data[0]
|
||||
if not isinstance(first, dict):
|
||||
return []
|
||||
raw = first.get("embedding")
|
||||
elif isinstance(data, dict):
|
||||
if not data.get("data"):
|
||||
return []
|
||||
raw = data["data"][0].get("embedding")
|
||||
else:
|
||||
return []
|
||||
|
||||
if raw is None:
|
||||
return []
|
||||
|
||||
if isinstance(raw, str):
|
||||
raw = json.loads(raw)
|
||||
if not isinstance(raw, list):
|
||||
raw = list(raw)
|
||||
if not raw:
|
||||
return []
|
||||
return list[float](raw)
|
||||
@@ -0,0 +1 @@
|
||||
"""Submodules of the OpenAI-compatible client layer (chat, images, registry)."""
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Core async chat completion with iterative tool calling, plus adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import openai
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessageParam
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ToolExecutor = Callable[[str, dict[str, str]], str]
|
||||
ToolCallNotifier = Callable[[str, dict[str, str]], None | Awaitable[None]]
|
||||
|
||||
_chat_client: openai.AsyncOpenAI | None = None
|
||||
|
||||
|
||||
def get_chat_client() -> openai.AsyncOpenAI:
|
||||
"""Return the shared async chat client, building it once."""
|
||||
global _chat_client
|
||||
if _chat_client is None:
|
||||
from vibe_bot.config import CHAT_ENDPOINT, CHAT_ENDPOINT_KEY
|
||||
|
||||
_chat_client = openai.AsyncOpenAI(
|
||||
base_url=CHAT_ENDPOINT, api_key=CHAT_ENDPOINT_KEY
|
||||
)
|
||||
return _chat_client
|
||||
|
||||
|
||||
async def chat_complete(
|
||||
messages: list[ChatCompletionMessageParam],
|
||||
*,
|
||||
model: str,
|
||||
max_tokens: int,
|
||||
seed: int | None = None,
|
||||
tools: list[dict[str, object]] | None = None,
|
||||
tool_executor: ToolExecutor | None = None,
|
||||
tool_call_notifier: ToolCallNotifier | None = None,
|
||||
max_tool_rounds: int = 5,
|
||||
timeout: float = 60.0,
|
||||
) -> str:
|
||||
"""Send a chat completion, optionally with iterative tool calling.
|
||||
|
||||
Args:
|
||||
messages: The conversation messages (system/user/assistant/tool).
|
||||
model: The model to use for completion.
|
||||
max_tokens: Maximum number of tokens to generate.
|
||||
seed: Optional sampling seed.
|
||||
tools: Optional list of tool definitions in OpenAI format.
|
||||
tool_executor: Sync callable (tool_name, tool_args) -> result string.
|
||||
tool_call_notifier: Optional sync-or-async callback invoked before each
|
||||
tool call with (tool_name, tool_args).
|
||||
max_tool_rounds: Maximum tool call rounds before giving up.
|
||||
timeout: Per-request timeout in seconds.
|
||||
|
||||
Returns:
|
||||
The model's final response text, stripped of whitespace ("" on failure).
|
||||
|
||||
"""
|
||||
client = get_chat_client()
|
||||
messages = list(messages)
|
||||
|
||||
for _round in range(max_tool_rounds):
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"timeout": timeout,
|
||||
}
|
||||
if seed is not None:
|
||||
kwargs["seed"] = seed
|
||||
if tools:
|
||||
kwargs["tools"] = cast("list[Any]", tools)
|
||||
|
||||
response = cast(
|
||||
"ChatCompletion", await client.chat.completions.create(**kwargs)
|
||||
)
|
||||
if not response.choices:
|
||||
return ""
|
||||
|
||||
message = response.choices[0].message
|
||||
tool_calls = message.tool_calls
|
||||
if tool_calls and tool_executor is not None:
|
||||
assistant_msg: dict[str, object] = {
|
||||
"role": "assistant",
|
||||
"content": message.content or "",
|
||||
}
|
||||
tool_call_dicts: list[dict[str, object]] = []
|
||||
for tool_call in tool_calls:
|
||||
if tool_call.type != "function":
|
||||
continue
|
||||
tool_call_dicts.append(
|
||||
{
|
||||
"id": tool_call.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_call.function.name,
|
||||
"arguments": tool_call.function.arguments,
|
||||
},
|
||||
},
|
||||
)
|
||||
assistant_msg["tool_calls"] = tool_call_dicts
|
||||
messages.append(cast("ChatCompletionMessageParam", assistant_msg))
|
||||
|
||||
for tool_call in tool_calls:
|
||||
if tool_call.type != "function":
|
||||
continue
|
||||
tool_name = tool_call.function.name
|
||||
tool_args = json.loads(tool_call.function.arguments)
|
||||
|
||||
if tool_call_notifier is not None:
|
||||
result = tool_call_notifier(tool_name, tool_args)
|
||||
if result is not None:
|
||||
await result
|
||||
|
||||
tool_result = await asyncio.to_thread(
|
||||
tool_executor, tool_name, tool_args
|
||||
)
|
||||
messages.append(
|
||||
cast(
|
||||
"ChatCompletionMessageParam",
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"content": tool_result,
|
||||
},
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
content = message.content
|
||||
if content:
|
||||
return content.strip()
|
||||
return ""
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
async def chat_completion_instruct(
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
*,
|
||||
model: str,
|
||||
max_tokens: int = 1000,
|
||||
) -> str:
|
||||
"""Instruction-based completion over :func:`chat_complete`."""
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
return await chat_complete(messages, model=model, max_tokens=max_tokens, seed=-1)
|
||||
|
||||
|
||||
async def chat_completion_with_history(
|
||||
system_prompt: str,
|
||||
prompts: list[dict[str, str]],
|
||||
*,
|
||||
model: str,
|
||||
max_tokens: int = 1000,
|
||||
) -> str:
|
||||
"""Completion with conversation history over :func:`chat_complete`."""
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
cast(
|
||||
"ChatCompletionMessageParam",
|
||||
{"role": "system", "content": system_prompt},
|
||||
),
|
||||
]
|
||||
messages.extend(cast("list[ChatCompletionMessageParam]", prompts))
|
||||
return await chat_complete(messages, model=model, max_tokens=max_tokens, seed=-1)
|
||||
|
||||
|
||||
async def chat_completion_with_tools(
|
||||
system_prompt: str,
|
||||
prompts: list[dict[str, str]],
|
||||
tools: list[dict[str, object]],
|
||||
tool_executor: ToolExecutor,
|
||||
*,
|
||||
model: str,
|
||||
max_tokens: int = 1000,
|
||||
max_tool_rounds: int = 5,
|
||||
tool_call_notifier: ToolCallNotifier | None = None,
|
||||
) -> str:
|
||||
"""Tool-capable completion over :func:`chat_complete`."""
|
||||
messages: list[ChatCompletionMessageParam] = [
|
||||
cast(
|
||||
"ChatCompletionMessageParam",
|
||||
{"role": "system", "content": system_prompt},
|
||||
),
|
||||
]
|
||||
messages.extend(cast("list[ChatCompletionMessageParam]", prompts))
|
||||
return await chat_complete(
|
||||
messages,
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
seed=-1,
|
||||
tools=tools,
|
||||
tool_executor=tool_executor,
|
||||
tool_call_notifier=tool_call_notifier,
|
||||
max_tool_rounds=max_tool_rounds,
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Async image generation and editing clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import openai
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from io import BufferedReader, BytesIO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_image_gen_client: openai.AsyncOpenAI | None = None
|
||||
_image_edit_client: openai.AsyncOpenAI | None = None
|
||||
|
||||
|
||||
def get_image_gen_client() -> openai.AsyncOpenAI:
|
||||
"""Return the shared async image-generation client, building it once."""
|
||||
global _image_gen_client
|
||||
if _image_gen_client is None:
|
||||
from vibe_bot.config import IMAGE_GEN_ENDPOINT, IMAGE_GEN_ENDPOINT_KEY
|
||||
|
||||
_image_gen_client = openai.AsyncOpenAI(
|
||||
base_url=IMAGE_GEN_ENDPOINT,
|
||||
api_key=IMAGE_GEN_ENDPOINT_KEY,
|
||||
max_retries=0,
|
||||
)
|
||||
return _image_gen_client
|
||||
|
||||
|
||||
def get_image_edit_client() -> openai.AsyncOpenAI:
|
||||
"""Return the shared async image-edit client, building it once."""
|
||||
global _image_edit_client
|
||||
if _image_edit_client is None:
|
||||
from vibe_bot.config import IMAGE_EDIT_ENDPOINT, IMAGE_EDIT_ENDPOINT_KEY
|
||||
|
||||
_image_edit_client = openai.AsyncOpenAI(
|
||||
base_url=IMAGE_EDIT_ENDPOINT, api_key=IMAGE_EDIT_ENDPOINT_KEY
|
||||
)
|
||||
return _image_edit_client
|
||||
|
||||
|
||||
async def image_generation(
|
||||
prompt: str,
|
||||
*,
|
||||
model: str = "gen",
|
||||
n: int = 1,
|
||||
size: str = "1024x1024",
|
||||
) -> str:
|
||||
"""Generate an image; return base64 data ("" on failure)."""
|
||||
client = get_image_gen_client()
|
||||
try:
|
||||
response = await client.images.generate(
|
||||
prompt=prompt,
|
||||
n=n,
|
||||
size=size,
|
||||
model=model,
|
||||
timeout=300.0,
|
||||
)
|
||||
except openai.OpenAIError as e:
|
||||
logger.warning("Image generation failed: %s", e)
|
||||
return ""
|
||||
if response.data:
|
||||
return response.data[0].b64_json or ""
|
||||
return ""
|
||||
|
||||
|
||||
async def image_edit(
|
||||
image: BufferedReader | BytesIO | list[BufferedReader] | list[BytesIO],
|
||||
prompt: str,
|
||||
*,
|
||||
model: str = "edit",
|
||||
n: int = 1,
|
||||
) -> str:
|
||||
"""Edit an image; return base64 data ("" on failure)."""
|
||||
client = get_image_edit_client()
|
||||
try:
|
||||
response = await client.images.edit(
|
||||
image=image,
|
||||
prompt=prompt,
|
||||
n=n,
|
||||
size="768x768",
|
||||
model=model,
|
||||
)
|
||||
except openai.OpenAIError as e:
|
||||
logger.warning("Image edit failed: %s", e)
|
||||
return ""
|
||||
if response.data:
|
||||
return response.data[0].b64_json or ""
|
||||
return ""
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tool registry: OpenAI schemas plus dispatch to sync implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic import BaseModel
|
||||
|
||||
ToolImpl = Callable[..., str]
|
||||
|
||||
|
||||
class _RegisteredTool:
|
||||
"""A registered tool: its OpenAI schema plus its synchronous impl."""
|
||||
|
||||
__slots__ = ("args_schema", "description", "impl", "name")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
args_schema: dict[str, object],
|
||||
impl: ToolImpl,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.args_schema = args_schema
|
||||
self.impl = impl
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Holds tool schemas and dispatches tool calls to their implementations."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tools: dict[str, _RegisteredTool] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
args_schema: dict[str, object],
|
||||
impl: ToolImpl,
|
||||
) -> None:
|
||||
"""Register a tool under ``name`` with its OpenAI args schema."""
|
||||
self._tools[name] = _RegisteredTool(name, description, args_schema, impl)
|
||||
|
||||
def to_openai_tools(self) -> list[dict[str, object]]:
|
||||
"""Return the registered tools in OpenAI function-calling format."""
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.args_schema,
|
||||
},
|
||||
}
|
||||
for tool in self._tools.values()
|
||||
]
|
||||
|
||||
def execute(self, name: str, args: dict[str, str], **impl_kwargs: Any) -> str:
|
||||
"""Dispatch a tool call; unknown tools yield a friendly message.
|
||||
|
||||
``impl_kwargs`` (e.g. ``channel``) are forwarded to the impl so tools
|
||||
can access per-invocation context.
|
||||
"""
|
||||
tool = self._tools.get(name)
|
||||
if tool is None:
|
||||
return f"Unknown tool: {name}"
|
||||
return tool.impl(name, args, **impl_kwargs)
|
||||
|
||||
|
||||
_default_registry: ToolRegistry | None = None
|
||||
|
||||
|
||||
def get_tool_registry() -> ToolRegistry:
|
||||
"""Return the shared tool registry, seeded with the channel-members tool."""
|
||||
global _default_registry
|
||||
if _default_registry is None:
|
||||
from vibe_bot.tools import get_channel_members
|
||||
|
||||
raw_schema = get_channel_members.args_schema
|
||||
if isinstance(raw_schema, dict):
|
||||
args_schema: dict[str, object] = raw_schema
|
||||
else:
|
||||
# A LangChain @tool exposes args_schema as a pydantic model class.
|
||||
args_schema = cast("type[BaseModel]", raw_schema).model_json_schema()
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
get_channel_members.name,
|
||||
get_channel_members.description or "",
|
||||
args_schema,
|
||||
_channel_members_tool,
|
||||
)
|
||||
_default_registry = registry
|
||||
return _default_registry
|
||||
|
||||
|
||||
def _channel_members_tool(name: str, args: dict[str, str], **kwargs: Any) -> str:
|
||||
"""Adapt the registry dispatch to ``get_channel_members_impl(channel)``."""
|
||||
from vibe_bot.tools import get_channel_members_impl
|
||||
|
||||
channel = kwargs.get("channel")
|
||||
return get_channel_members_impl(channel)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Async OpenAI-compatible LLM, image, and embedding clients.
|
||||
|
||||
Public API facade: chat completion and the tool registry live in the
|
||||
``vibe_bot.llm`` subpackage; the embedding HTTP plumbing stays in this
|
||||
module.
|
||||
|
||||
``image_edit`` (``!retcon``) requests a fixed 768x768 output rather than
|
||||
matching the source image's aspect ratio. Matching it would require
|
||||
decoding the downloaded image (Pillow is not a dependency) and most
|
||||
OpenAI-compatible edit endpoints only accept a fixed set of sizes anyway;
|
||||
the square output bounds request cost and is universally honored.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from vibe_bot.llm.chat import (
|
||||
ToolCallNotifier,
|
||||
ToolExecutor,
|
||||
chat_complete,
|
||||
chat_completion_instruct,
|
||||
chat_completion_with_history,
|
||||
chat_completion_with_tools,
|
||||
get_chat_client,
|
||||
)
|
||||
from vibe_bot.llm.images import (
|
||||
get_image_edit_client,
|
||||
get_image_gen_client,
|
||||
image_edit,
|
||||
image_generation,
|
||||
)
|
||||
from vibe_bot.llm.registry import ToolRegistry, get_tool_registry
|
||||
|
||||
__all__ = [
|
||||
"ToolCallNotifier",
|
||||
"ToolExecutor",
|
||||
"ToolRegistry",
|
||||
"chat_complete",
|
||||
"chat_completion_instruct",
|
||||
"chat_completion_with_history",
|
||||
"chat_completion_with_tools",
|
||||
"embedding",
|
||||
"get_chat_client",
|
||||
"get_embedding_session",
|
||||
"get_image_edit_client",
|
||||
"get_image_gen_client",
|
||||
"get_tool_registry",
|
||||
"image_edit",
|
||||
"image_generation",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_embedding_session: requests.Session | None = None
|
||||
|
||||
|
||||
def get_embedding_session() -> requests.Session:
|
||||
"""Return the shared requests session for embedding HTTP calls."""
|
||||
global _embedding_session
|
||||
if _embedding_session is None:
|
||||
_embedding_session = requests.Session()
|
||||
return _embedding_session
|
||||
|
||||
|
||||
def embedding(
|
||||
text: str,
|
||||
*,
|
||||
url: str,
|
||||
api_key: str,
|
||||
model: str,
|
||||
) -> list[float]:
|
||||
"""Generate an embedding vector for the given text (synchronous).
|
||||
|
||||
Uses a raw HTTP request (shared session) to avoid the SDK injecting
|
||||
unsupported parameters like encoding_format.
|
||||
"""
|
||||
endpoint = f"{url.rstrip('/')}/embeddings"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {"model": model, "input": [text]}
|
||||
|
||||
try:
|
||||
resp = get_embedding_session().post(
|
||||
endpoint, headers=headers, json=payload, timeout=30
|
||||
)
|
||||
resp.raise_for_status()
|
||||
# A 2xx body can still be non-JSON (e.g. an HTML error page);
|
||||
# resp.json() would raise JSONDecodeError (a ValueError).
|
||||
data = resp.json()
|
||||
except (requests.RequestException, ValueError):
|
||||
return []
|
||||
|
||||
# Handle both OpenAI-style response ({"data": [...]}) and
|
||||
# Ollama-style response ([{...}]) where the API returns a list directly
|
||||
if isinstance(data, list):
|
||||
first = data[0]
|
||||
if not isinstance(first, dict):
|
||||
return []
|
||||
raw: Any = first.get("embedding")
|
||||
elif isinstance(data, dict):
|
||||
if not data.get("data"):
|
||||
return []
|
||||
raw = data["data"][0].get("embedding")
|
||||
else:
|
||||
return []
|
||||
|
||||
if raw is None:
|
||||
return []
|
||||
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except ValueError:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
raw = list(raw)
|
||||
if not raw:
|
||||
return []
|
||||
return list[float](raw)
|
||||
+10
-1268
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
||||
"""Prompt constants and system-prompt assembly helpers.
|
||||
|
||||
Holds every prompt string the bot sends to the LLM (image layout selection,
|
||||
image-prompt engineering, prompt verification) plus the shared response-length
|
||||
hint and the ``build_system_prompt`` assembler used by the chat and
|
||||
speak-with-bot paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe_bot.config import (
|
||||
IMAGE_GEN_SIZE_LANDSCAPE,
|
||||
IMAGE_GEN_SIZE_PORTRAIT,
|
||||
IMAGE_GEN_SIZE_SQUARE,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import discord
|
||||
|
||||
# Image layout (canvas orientation) selection for doodlebob.
|
||||
DEFAULT_IMAGE_LAYOUT = "square"
|
||||
VALID_IMAGE_LAYOUTS = ("portrait", "landscape", "square")
|
||||
LAYOUT_SIZES: dict[str, str] = {
|
||||
"portrait": IMAGE_GEN_SIZE_PORTRAIT,
|
||||
"landscape": IMAGE_GEN_SIZE_LANDSCAPE,
|
||||
"square": IMAGE_GEN_SIZE_SQUARE,
|
||||
}
|
||||
|
||||
# Shared response-length hint appended to bot system prompts.
|
||||
RESPONSE_LENGTH_HINT = "Keep your responses under 2-3 sentences."
|
||||
|
||||
IMAGE_LAYOUT_SYSTEM_PROMPT = (
|
||||
"You decide the aspect ratio (layout) of an image that will be generated "
|
||||
"from a user's request. Choose exactly ONE layout from these three options:\n"
|
||||
"- portrait: a tall, vertical image (taller than wide). Use for subjects that "
|
||||
"are taller than they are wide, such as a single standing person or animal, "
|
||||
"a full-body character, a tall building, a skyscraper, a tree, a rocket, or "
|
||||
"any vertical composition.\n"
|
||||
"- landscape: a wide, horizontal image (wider than tall). Use for scenes that "
|
||||
"are wider than they are tall, such as wide landscapes, panoramas, cityscapes, "
|
||||
"seas and horizons, battle or group scenes spread out horizontally, or any "
|
||||
"horizontal composition.\n"
|
||||
"- square: an image that is as wide as it is tall. Use for balanced subjects, "
|
||||
"close-ups, faces, single objects, logos, emblems, or whenever no strong tall "
|
||||
"or wide orientation is implied.\n"
|
||||
"Rules:\n"
|
||||
"- Base your choice ONLY on the orientation the content implies.\n"
|
||||
"- Respond with ONLY the single word portrait, landscape, or square.\n"
|
||||
"- Do NOT include any other text, punctuation, explanation, or reasoning.\n"
|
||||
)
|
||||
|
||||
IMAGE_PROMPT_SYSTEM_PROMPT_TEMPLATE = (
|
||||
"You are an expert art director and image-generation prompt engineer. "
|
||||
"Convert the user's message into one single, extremely detailed image "
|
||||
"generation prompt that will be passed directly to a text-to-image model. "
|
||||
"The image model is weak: it guesses at compositions, fumbles rendered "
|
||||
"text, and invents details on its own. Your prompt must therefore leave "
|
||||
"nothing to interpretation - explicitly describe every visible aspect of "
|
||||
"the image so it can be created with extreme precision and detail.\n"
|
||||
"The final image will use a {layout} canvas, so compose the scene to fit "
|
||||
"that orientation.\n"
|
||||
"Your prompt must cover all of the following as one flowing, descriptive "
|
||||
"passage. Begin with the main subject, described completely in the very "
|
||||
"first sentence:\n"
|
||||
"- Subject(s): every subject with concrete specifics (species or "
|
||||
"character, age, build, clothing, colors, materials, accessories), its "
|
||||
"exact pose, expression, gaze direction, and its precise position in the "
|
||||
'frame (for example "centered in the foreground" or "small in the '
|
||||
'upper-left background"). State the relative scale of subjects to each '
|
||||
"other and to the frame. If the subject is a fusion, hybrid, or anything "
|
||||
"unusual, the first sentence must state in full what is joined to what "
|
||||
"and exactly how it looks, and that description must be repeated near "
|
||||
"the end of the passage.\n"
|
||||
"- Composition and framing: the camera angle (eye-level, low, high, "
|
||||
"bird's-eye), the shot type (extreme close-up, portrait, full body, wide "
|
||||
"establishing shot), the focal point, the arrangement of elements across "
|
||||
"the {layout} canvas, and the depth of field.\n"
|
||||
"- Text: if the image must contain readable text (titles, signs, "
|
||||
"posters, labels, banners, watermarks, logos, captions), quote the EXACT "
|
||||
"text verbatim in double quotes with precise capitalization and "
|
||||
"punctuation, and specify its font style, color, size, and exact "
|
||||
"placement. If the image should contain no text, state that explicitly "
|
||||
'("no text anywhere in the image").\n'
|
||||
"- Setting and background: the complete environment with concrete "
|
||||
"details - location, time of day, weather, and every notable background "
|
||||
"and foreground element with its position.\n"
|
||||
"- Style and rendering: the art style or medium (for example "
|
||||
"photorealistic 35mm photograph, oil painting, watercolor, cel-shaded "
|
||||
"anime, pixel art, vector illustration), the color palette with specific "
|
||||
"colors, the lighting (source, direction, quality, mood), the overall "
|
||||
"atmosphere, and the level of detail.\n"
|
||||
'- Finish with concise quality terms such as "highly detailed, sharp '
|
||||
'focus".\n'
|
||||
"Rules:\n"
|
||||
'- Be concrete and specific. Never use vague words like "nice", '
|
||||
'"cool", "epic", or "various" - name exact colors, objects, '
|
||||
"positions, and quantities.\n"
|
||||
"- Be literal. Interpret the user's request exactly as written. Never "
|
||||
'rationalize, normalize, or "improve" it: surreal, absurd, or '
|
||||
'anthropomorphic requests are intentional, not mistakes. A "fountain '
|
||||
'pen wearing pants" is an anthropomorphized fountain pen character '
|
||||
"wearing pants, not a pen lying next to a pair of pants.\n"
|
||||
"- Preserve everything the user specified. Fill in details the user did "
|
||||
"not specify with coherent choices that fit the request, but never "
|
||||
"alter, drop, or reinterpret what the user did specify.\n"
|
||||
"- Decompose concepts. The image model lacks world knowledge, so never "
|
||||
"rely on a name alone for anything it might misrender (mythical "
|
||||
"creatures, fictional characters, cultural items, animal breeds, "
|
||||
"instruments, vehicles). Spell out the visual anatomy: silhouette, body "
|
||||
"parts, materials, and distinguishing features, with explicit "
|
||||
"disambiguation. A centaur is a single creature with a human torso, "
|
||||
"arms, and head seamlessly fused to a horse's front half, the horse's "
|
||||
"four legs extending from the human's waist - one fused body, not a "
|
||||
"person riding a horse.\n"
|
||||
"- If told to generate an image of yourself, generate a picture of a "
|
||||
"canada goose. If told to generate a picture of 'me', 'myself', or some "
|
||||
"other self reference, generate a picture of a canada goose.\n"
|
||||
"- Respond with ONLY the image generation prompt itself. Do not affirm "
|
||||
"the user, do not answer the user's questions, and do not add headings, "
|
||||
"labels, numbered lists, or any other text."
|
||||
)
|
||||
|
||||
IMAGE_PROMPT_VERIFY_SYSTEM_PROMPT = (
|
||||
"You are the final quality check for an image generation prompt. The "
|
||||
"image model that will use it has no world knowledge: it renders only "
|
||||
"what is described literally and silently drops anything it does not "
|
||||
"understand - a prompt that merely names a centaur without describing "
|
||||
"the fused human-animal body will produce a plain horse. "
|
||||
"Given the user's original request and the drafted prompt, judge "
|
||||
"strictly: would the drafted prompt, taken completely literally, "
|
||||
"produce exactly what the user asked for, including every unusual, "
|
||||
"mythical, surreal, or anthropomorphic element? "
|
||||
"Respond with ONLY the single word PASS if it would. Otherwise respond "
|
||||
"with ONLY a corrected version of the prompt that would produce exactly "
|
||||
"what the user asked for: one flowing descriptive passage, the "
|
||||
"subject's full anatomy and every unusual element described explicitly "
|
||||
"in the first sentence and repeated near the end, no other text."
|
||||
)
|
||||
|
||||
|
||||
def parse_image_layout(response: str) -> str:
|
||||
"""Parse an LLM response into a valid image layout.
|
||||
|
||||
Args:
|
||||
response: The raw LLM response text.
|
||||
|
||||
Returns:
|
||||
One of "portrait", "landscape", or "square". Falls back to "square"
|
||||
when the response is empty or does not contain a valid layout.
|
||||
|
||||
"""
|
||||
text = response.strip().lower()
|
||||
for layout in VALID_IMAGE_LAYOUTS:
|
||||
if re.search(rf"\b{layout}\b", text):
|
||||
return layout
|
||||
return DEFAULT_IMAGE_LAYOUT
|
||||
|
||||
|
||||
def build_system_prompt(personality: str, user_info: str) -> str:
|
||||
"""Assemble a custom-bot system prompt with the length hint and user info.
|
||||
|
||||
Args:
|
||||
personality: The base bot personality / system prompt.
|
||||
user_info: Preformatted user information to append.
|
||||
|
||||
Returns:
|
||||
The assembled system prompt: personality, a response-length hint, and
|
||||
the user information block.
|
||||
|
||||
"""
|
||||
return f"{personality}\n{RESPONSE_LENGTH_HINT}\n\nUser Information:\n{user_info}"
|
||||
|
||||
|
||||
def get_user_info(user: discord.User | discord.Member) -> str:
|
||||
"""Format user information for inclusion in bot prompts.
|
||||
|
||||
Reads only presentation attributes off the (User or Member) object, so it
|
||||
has no runtime dependency on the discord package.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
if user.global_name:
|
||||
parts.append(f"Global Name: {user.global_name}")
|
||||
nick = getattr(user, "nick", None)
|
||||
if nick:
|
||||
parts.append(f"Nickname: {nick}")
|
||||
top_role = getattr(user, "top_role", None)
|
||||
if top_role and top_role.name != "@everyone":
|
||||
parts.append(f"Top Role: {top_role.name}")
|
||||
activities = getattr(user, "activities", None)
|
||||
if activities:
|
||||
activity_names = [
|
||||
getattr(a, "name", str(a))
|
||||
for a in activities
|
||||
if getattr(a, "name", "") != "custom_status"
|
||||
]
|
||||
if activity_names:
|
||||
parts.append(f"Activities: {', '.join(activity_names)}")
|
||||
joined_at = getattr(user, "joined_at", None)
|
||||
if joined_at:
|
||||
parts.append(f"Joined: {joined_at.strftime('%Y-%m-%d')}")
|
||||
parts.append(f"Username: {user.name}")
|
||||
parts.append(f"User ID: {user.id}")
|
||||
parts.append(
|
||||
f"Account Created: {user.created_at.strftime('%Y-%m-%d') if user.created_at else 'Unknown'}"
|
||||
)
|
||||
return "\n".join(parts)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Stateful bot services, each wired with its dependencies via constructor.
|
||||
|
||||
The four services own the logic for the four LLM-backed flows (custom-bot chat,
|
||||
image gen/edit, speech, and bot-vs-bot conversation). They take a discord
|
||||
``ctx`` only as an argument (typed under ``TYPE_CHECKING``) and never import the
|
||||
``discord`` package at runtime; a file factory is injected so they can hand
|
||||
audio/image bytes to ``ctx.send`` without touching ``discord.File``.
|
||||
"""
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Chat service: RAG context + tool-capped LLM completion + persistence + reply."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe_bot import llm_client
|
||||
from vibe_bot.config import CHAT_MODEL, MAX_COMPLETION_TOKENS
|
||||
from vibe_bot.prompts import build_system_prompt, get_user_info
|
||||
from vibe_bot.textutil import split_message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot, Context
|
||||
|
||||
from vibe_bot.database import ChatDatabase
|
||||
from vibe_bot.llm_client import ToolRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatService:
|
||||
"""Handles one custom-bot chat turn: context -> LLM (with tools) -> persist -> reply."""
|
||||
|
||||
def __init__(self, db: ChatDatabase, registry: ToolRegistry) -> None:
|
||||
self._db = db
|
||||
self._registry = registry
|
||||
|
||||
async def handle(
|
||||
self,
|
||||
ctx: Context[Bot],
|
||||
*,
|
||||
bot_name: str,
|
||||
message: str,
|
||||
system_prompt: str,
|
||||
response_prefix: str,
|
||||
) -> None:
|
||||
"""Run a single chat turn for ``bot_name`` and send the reply.
|
||||
|
||||
Args:
|
||||
ctx: The Discord command context.
|
||||
bot_name: The name of the custom bot.
|
||||
message: The user message to process.
|
||||
system_prompt: The base system prompt (personality) for the bot.
|
||||
response_prefix: The prefix message sent before the reply.
|
||||
|
||||
"""
|
||||
await ctx.send(f"{bot_name} is searching its databanks for {message[:50]}...")
|
||||
|
||||
# Get conversation context using RAG (SQLite + embedding HTTP call).
|
||||
context = await asyncio.to_thread(
|
||||
self._db.get_conversation_context,
|
||||
user_id=str(ctx.author.id),
|
||||
current_message=message,
|
||||
max_context=5,
|
||||
)
|
||||
|
||||
prompts: list[dict[str, str]] = [{"role": "user", "content": message}]
|
||||
if context:
|
||||
prompts = context + prompts
|
||||
|
||||
logger.info(
|
||||
"chat: bot=%s user=%s context_msgs=%d",
|
||||
bot_name,
|
||||
ctx.author.id,
|
||||
len(context),
|
||||
)
|
||||
|
||||
system_prompt_edit = build_system_prompt(
|
||||
system_prompt, get_user_info(ctx.author)
|
||||
)
|
||||
|
||||
tools = self._registry.to_openai_tools()
|
||||
|
||||
def tool_executor(tool_name: str, tool_args: dict[str, str]) -> str:
|
||||
"""Dispatch a tool call through the registry."""
|
||||
return self._registry.execute(tool_name, tool_args, channel=ctx.channel)
|
||||
|
||||
async def tool_call_notifier(tool_name: str, tool_args: dict[str, str]) -> None:
|
||||
"""Send a notification message when a tool is called."""
|
||||
if tool_name == "get_channel_members":
|
||||
await ctx.send(f"{bot_name} is looking at the channel members...")
|
||||
|
||||
try:
|
||||
bot_response = await llm_client.chat_completion_with_tools(
|
||||
system_prompt=system_prompt_edit,
|
||||
prompts=prompts,
|
||||
tools=tools,
|
||||
tool_executor=tool_executor,
|
||||
tool_call_notifier=tool_call_notifier,
|
||||
model=CHAT_MODEL,
|
||||
max_tokens=MAX_COMPLETION_TOKENS,
|
||||
)
|
||||
|
||||
# Store both the user message and the bot response in the database.
|
||||
await asyncio.to_thread(
|
||||
self._db.add_message,
|
||||
message_id=f"{ctx.message.id}",
|
||||
user_id=str(ctx.author.id),
|
||||
username=ctx.author.name,
|
||||
content=f"User: {message}",
|
||||
bot_name=bot_name,
|
||||
channel_id=str(ctx.channel.id),
|
||||
guild_id=str(ctx.guild.id) if ctx.guild else None,
|
||||
)
|
||||
|
||||
if ctx.bot.user is not None:
|
||||
await asyncio.to_thread(
|
||||
self._db.add_message,
|
||||
message_id=f"{ctx.message.id}_response",
|
||||
user_id=str(ctx.bot.user.id),
|
||||
username=ctx.bot.user.name,
|
||||
content=bot_response,
|
||||
bot_name=bot_name,
|
||||
channel_id=str(ctx.channel.id),
|
||||
guild_id=str(ctx.guild.id) if ctx.guild else None,
|
||||
role="assistant",
|
||||
embed=False,
|
||||
)
|
||||
|
||||
# Send the response back to the chat.
|
||||
await ctx.send(response_prefix)
|
||||
for send_chunk in split_message(bot_response, 1000):
|
||||
await ctx.send(send_chunk)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error in handle_chat")
|
||||
await ctx.send("An error occurred while processing your request.")
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Conversation service: run a capped bot-vs-bot conversation (talkforme)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe_bot import llm_client
|
||||
from vibe_bot.config import CHAT_MODEL, MAX_COMPLETION_TOKENS
|
||||
from vibe_bot.prompts import RESPONSE_LENGTH_HINT
|
||||
from vibe_bot.textutil import split_message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot, Context
|
||||
|
||||
from vibe_bot.database import CustomBotManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Input size bound: reject oversized topics before they reach the LLM.
|
||||
MAX_TOPIC_LENGTH = 500
|
||||
|
||||
# Hard cap on the number of replies a single !talkforme invocation produces.
|
||||
TALK_LIMIT = 20
|
||||
|
||||
|
||||
def flip_counter(counter: int) -> int:
|
||||
"""Flip between 0 and 1 (the two conversing bots)."""
|
||||
return 1 if counter == 0 else 0
|
||||
|
||||
|
||||
class ConversationService:
|
||||
"""Runs a two-bot conversation about a topic, chunking each reply."""
|
||||
|
||||
def __init__(self, manager: CustomBotManager) -> None:
|
||||
self._manager = manager
|
||||
|
||||
async def run(
|
||||
self,
|
||||
ctx: Context[Bot],
|
||||
bot1: str,
|
||||
bot2: str,
|
||||
limit: str,
|
||||
topic: str,
|
||||
) -> None:
|
||||
"""Have ``bot1`` and ``bot2`` talk about ``topic`` for up to ``limit`` replies.
|
||||
|
||||
Args:
|
||||
ctx: The Discord command context.
|
||||
bot1: Name of the first custom bot.
|
||||
bot2: Name of the second custom bot.
|
||||
limit: Requested number of replies (string; parsed to an int).
|
||||
topic: The conversation topic.
|
||||
|
||||
"""
|
||||
if len(topic) > MAX_TOPIC_LENGTH:
|
||||
logger.warning(
|
||||
"Talkforme topic too long from user %s: length=%d",
|
||||
ctx.author.id,
|
||||
len(topic),
|
||||
)
|
||||
await ctx.send(f"Topic too long. Max {MAX_TOPIC_LENGTH} characters.")
|
||||
return
|
||||
|
||||
bot1_info = await asyncio.to_thread(self._manager.get_custom_bot, bot1)
|
||||
if not bot1_info:
|
||||
await ctx.send(f"{bot1} is not a real bot...")
|
||||
return
|
||||
bot1_prompt = bot1_info[1]
|
||||
|
||||
bot2_info = await asyncio.to_thread(self._manager.get_custom_bot, bot2)
|
||||
if not bot2_info:
|
||||
await ctx.send(f"{bot2} is not a real bot...")
|
||||
return
|
||||
bot2_prompt = bot2_info[1]
|
||||
|
||||
try:
|
||||
message_limit = int(limit)
|
||||
except ValueError:
|
||||
await ctx.send("Message limit must be an integer.")
|
||||
return
|
||||
|
||||
effective_limit = min(message_limit, TALK_LIMIT)
|
||||
await ctx.send(
|
||||
f"{bot1} is going to talk to {bot2} "
|
||||
f'about "{topic[:50]}" for {effective_limit} replies.',
|
||||
)
|
||||
|
||||
bot_list = [(bot1, bot1_prompt), (bot2, bot2_prompt)]
|
||||
|
||||
async def send_chunked(text: str) -> None:
|
||||
"""Send text in 1000-char chunks to stay under Discord's limit."""
|
||||
for chunk in split_message(text, 1000):
|
||||
await ctx.send(chunk)
|
||||
|
||||
message_counter = 0
|
||||
bot_counter = 0
|
||||
current_bot = bot_list[bot_counter]
|
||||
prompt_histories: list[list[dict[str, str]]] = [
|
||||
[{"role": "user", "content": topic}],
|
||||
[{"role": "assistant", "content": topic}],
|
||||
]
|
||||
|
||||
first_bot_response = await llm_client.chat_completion_with_history(
|
||||
system_prompt=(
|
||||
current_bot[1] + f"\n{RESPONSE_LENGTH_HINT} "
|
||||
f"You are talking to {current_bot[flip_counter(bot_counter)][0]}"
|
||||
),
|
||||
prompts=prompt_histories[bot_counter],
|
||||
model=CHAT_MODEL,
|
||||
max_tokens=MAX_COMPLETION_TOKENS,
|
||||
)
|
||||
await ctx.send(f"## {current_bot[0]}")
|
||||
await send_chunked(first_bot_response)
|
||||
prompt_histories[0].append({"role": "assistant", "content": first_bot_response})
|
||||
prompt_histories[1].append({"role": "user", "content": first_bot_response})
|
||||
|
||||
bot_counter = flip_counter(counter=bot_counter)
|
||||
|
||||
while message_counter < effective_limit:
|
||||
current_bot = bot_list[bot_counter]
|
||||
logger.debug("Current bot is %s", current_bot[0])
|
||||
bot_response = await llm_client.chat_completion_with_history(
|
||||
system_prompt=(
|
||||
current_bot[1] + f"\n{RESPONSE_LENGTH_HINT} "
|
||||
f"You are talking to {current_bot[flip_counter(bot_counter)][0]}"
|
||||
),
|
||||
prompts=prompt_histories[bot_counter],
|
||||
model=CHAT_MODEL,
|
||||
max_tokens=MAX_COMPLETION_TOKENS,
|
||||
)
|
||||
message_counter += 1
|
||||
prompt_histories[bot_counter].append(
|
||||
{"role": "assistant", "content": bot_response},
|
||||
)
|
||||
prompt_histories[flip_counter(bot_counter)].append(
|
||||
{"role": "user", "content": bot_response},
|
||||
)
|
||||
await ctx.send(f"## {current_bot[0]}")
|
||||
await send_chunked(bot_response)
|
||||
bot_counter = flip_counter(counter=bot_counter)
|
||||
logger.debug(
|
||||
"Message counter is %d/%d",
|
||||
message_counter,
|
||||
effective_limit,
|
||||
)
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Image service: doodlebob (generate) and retcon (edit), plus their helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from vibe_bot import llm_client
|
||||
from vibe_bot.config import (
|
||||
CHAT_MODEL,
|
||||
IMAGE_EDIT_MODEL,
|
||||
IMAGE_GEN_MODEL,
|
||||
MAX_COMPLETION_TOKENS,
|
||||
)
|
||||
from vibe_bot.prompts import (
|
||||
IMAGE_LAYOUT_SYSTEM_PROMPT,
|
||||
IMAGE_PROMPT_SYSTEM_PROMPT_TEMPLATE,
|
||||
IMAGE_PROMPT_VERIFY_SYSTEM_PROMPT,
|
||||
LAYOUT_SIZES,
|
||||
parse_image_layout,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot, Context
|
||||
|
||||
from vibe_bot.database import ChatDatabase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Input size bound: reject oversized prompts before they reach the LLM.
|
||||
MAX_IMAGE_PROMPT_LENGTH = 2000
|
||||
|
||||
# !retcon download safety: only fetch images from Discord's own CDN hosts and
|
||||
# cap the size of a single download.
|
||||
ALLOWED_IMAGE_HOSTS = (
|
||||
"discord.com",
|
||||
"discordapp.com",
|
||||
"discordapp.net",
|
||||
"discordcdn.com",
|
||||
"discord.media",
|
||||
)
|
||||
MAX_IMAGE_DOWNLOAD_BYTES = 8 * 1024 * 1024
|
||||
|
||||
# Injected factory that turns an in-memory image/audio into a discord.File.
|
||||
# Kept out of the service so it never imports discord at runtime.
|
||||
FileFactory = Callable[[BytesIO, str], Any]
|
||||
|
||||
# Matches a bare "pass" verdict from the prompt-verification LLM.
|
||||
_PASS_RE = re.compile(r"\bpass\b", re.IGNORECASE)
|
||||
|
||||
|
||||
async def select_image_layout(user_message: str) -> str:
|
||||
"""Ask the LLM to pick an image layout (a single-word answer)."""
|
||||
response = await llm_client.chat_completion_instruct(
|
||||
system_prompt=IMAGE_LAYOUT_SYSTEM_PROMPT,
|
||||
user_prompt=user_message,
|
||||
model=CHAT_MODEL,
|
||||
max_tokens=2,
|
||||
)
|
||||
return parse_image_layout(response)
|
||||
|
||||
|
||||
async def verify_image_prompt(user_message: str, image_prompt: str) -> str:
|
||||
"""Check the drafted prompt literally produces the user's request."""
|
||||
check_prompt = f"User request: {user_message}\n\nDrafted prompt: {image_prompt}"
|
||||
response = await llm_client.chat_completion_instruct(
|
||||
system_prompt=IMAGE_PROMPT_VERIFY_SYSTEM_PROMPT,
|
||||
user_prompt=check_prompt,
|
||||
model=CHAT_MODEL,
|
||||
max_tokens=MAX_COMPLETION_TOKENS,
|
||||
)
|
||||
if not response:
|
||||
return image_prompt
|
||||
# A passing check is the single word PASS; a correction is a full
|
||||
# rewritten passage, which is always much longer.
|
||||
if len(response) <= 50 and _PASS_RE.search(response):
|
||||
return image_prompt
|
||||
return response
|
||||
|
||||
|
||||
def _allowed_image_url(url: str) -> bool:
|
||||
"""Return True when the URL points at an allowed Discord CDN host."""
|
||||
try:
|
||||
host = urlparse(url).hostname or ""
|
||||
except ValueError:
|
||||
return False
|
||||
return any(
|
||||
host == allowed or host.endswith(f".{allowed}")
|
||||
for allowed in ALLOWED_IMAGE_HOSTS
|
||||
)
|
||||
|
||||
|
||||
def _download_image_bytes(url: str) -> bytes | None:
|
||||
"""Download a source image for !retcon.
|
||||
|
||||
Returns the image bytes, or None when the URL is not on the Discord CDN
|
||||
allowlist, the download fails, or the image exceeds the size cap.
|
||||
|
||||
"""
|
||||
if not _allowed_image_url(url):
|
||||
logger.warning("Refusing to download image from non-Discord host: %s", url)
|
||||
return None
|
||||
|
||||
try:
|
||||
response = requests.get(url, timeout=30, stream=True)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
logger.warning("Failed to download image from %s: %s", url, e)
|
||||
return None
|
||||
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
if int(content_length) > MAX_IMAGE_DOWNLOAD_BYTES:
|
||||
logger.warning("Image from %s exceeds the size cap", url)
|
||||
return None
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
try:
|
||||
for chunk in response.iter_content(chunk_size=65536):
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > MAX_IMAGE_DOWNLOAD_BYTES:
|
||||
logger.warning(
|
||||
"Image from %s exceeds the size cap while streaming", url
|
||||
)
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
except requests.RequestException as e:
|
||||
logger.warning("Failed while streaming image from %s: %s", url, e)
|
||||
return None
|
||||
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
class ImageService:
|
||||
"""Generates (doodlebob) and edits (retcon) images via the LLM image APIs."""
|
||||
|
||||
def __init__(self, db: ChatDatabase, make_file: FileFactory) -> None:
|
||||
self._db = db
|
||||
self._make_file = make_file
|
||||
|
||||
async def generate(self, ctx: Context[Bot], *, message: str) -> None:
|
||||
"""Convert a message into an image using Doodlebob."""
|
||||
logger.info(
|
||||
"Doodlebob command triggered by user %s: prompt_chars=%d",
|
||||
ctx.author.id,
|
||||
len(message),
|
||||
)
|
||||
|
||||
if len(message) > MAX_IMAGE_PROMPT_LENGTH:
|
||||
logger.warning(
|
||||
"Doodlebob prompt too long from user %s: length=%d",
|
||||
ctx.author.id,
|
||||
len(message),
|
||||
)
|
||||
await ctx.send(
|
||||
f"Prompt too long. Max {MAX_IMAGE_PROMPT_LENGTH} characters."
|
||||
)
|
||||
return
|
||||
|
||||
await ctx.send("**Doodlebob shopping for a canvas...**")
|
||||
|
||||
# Let the LLM pick the canvas orientation based on the content.
|
||||
layout = await select_image_layout(message)
|
||||
logger.info("Doodlebob selected layout %r for user %s", layout, ctx.author.id)
|
||||
await ctx.send(f"**Doodlebob selected {layout}**")
|
||||
|
||||
system_prompt = IMAGE_PROMPT_SYSTEM_PROMPT_TEMPLATE.format(layout=layout)
|
||||
|
||||
# Wait for the generated image prompt.
|
||||
image_prompt = await llm_client.chat_completion_instruct(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=message,
|
||||
model=CHAT_MODEL,
|
||||
max_tokens=MAX_COMPLETION_TOKENS,
|
||||
)
|
||||
|
||||
# If the string is empty we had an error.
|
||||
if image_prompt == "":
|
||||
logger.warning("No image prompt supplied. Check for errors.")
|
||||
return
|
||||
|
||||
# Verify the prompt literally produces the user's request; the check
|
||||
# may return a corrected prompt.
|
||||
image_prompt = await verify_image_prompt(message, image_prompt)
|
||||
logger.debug(
|
||||
"Doodlebob final image prompt ready: prompt_chars=%d layout=%s",
|
||||
len(image_prompt),
|
||||
layout,
|
||||
)
|
||||
|
||||
# Alert the user we're generating the image.
|
||||
estimated_seconds = await asyncio.to_thread(
|
||||
self._db.get_image_generation_time_estimate
|
||||
)
|
||||
await ctx.send(f"**Doodlebob calling drone strike on {image_prompt[:100]}...**")
|
||||
if estimated_seconds is not None:
|
||||
await ctx.send(f"**Drone ETA: ~{estimated_seconds:.0f} seconds**")
|
||||
|
||||
start_time = time.monotonic()
|
||||
image_b64 = await llm_client.image_generation(
|
||||
prompt=image_prompt,
|
||||
model=IMAGE_GEN_MODEL,
|
||||
size=LAYOUT_SIZES[layout],
|
||||
)
|
||||
elapsed_seconds = time.monotonic() - start_time
|
||||
|
||||
if not image_b64:
|
||||
logger.warning("Image generation returned empty response.")
|
||||
await ctx.send("Failed to generate image. The server may be busy.")
|
||||
return
|
||||
|
||||
await asyncio.to_thread(self._db.record_image_generation_time, elapsed_seconds)
|
||||
|
||||
try:
|
||||
edited_image_data = BytesIO(base64.b64decode(image_b64))
|
||||
send_img = self._make_file(edited_image_data, "image.png")
|
||||
await ctx.send(file=send_img)
|
||||
await ctx.send(
|
||||
f"**Strike complete. Image generated in {elapsed_seconds:.1f} seconds.**",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to decode image data")
|
||||
await ctx.send("Failed to process the generated image.")
|
||||
|
||||
async def edit(self, ctx: Context[Bot], *, message: str) -> None:
|
||||
"""Edit an attached image based on a text prompt."""
|
||||
if len(message) > MAX_IMAGE_PROMPT_LENGTH:
|
||||
logger.warning(
|
||||
"Retcon prompt too long from user %s: length=%d",
|
||||
ctx.author.id,
|
||||
len(message),
|
||||
)
|
||||
await ctx.send(
|
||||
f"Prompt too long. Max {MAX_IMAGE_PROMPT_LENGTH} characters."
|
||||
)
|
||||
return
|
||||
|
||||
image_data_list: list[BytesIO] = []
|
||||
for discord_image in ctx.message.attachments:
|
||||
image_url = discord_image.url
|
||||
image_bytes = await asyncio.to_thread(_download_image_bytes, image_url)
|
||||
if image_bytes is None:
|
||||
continue
|
||||
image_data_list.append(BytesIO(image_bytes))
|
||||
|
||||
if not image_data_list:
|
||||
await ctx.send("Please attach an image to edit.")
|
||||
return
|
||||
|
||||
await ctx.send(f"**Rewriting history to match {message[:100]}...**")
|
||||
|
||||
image_b64 = await llm_client.image_edit(
|
||||
image=image_data_list,
|
||||
prompt=message,
|
||||
model=IMAGE_EDIT_MODEL,
|
||||
)
|
||||
|
||||
if not image_b64:
|
||||
await ctx.send("Failed to edit the image.")
|
||||
return
|
||||
|
||||
try:
|
||||
edited_image_data = BytesIO(base64.b64decode(image_b64))
|
||||
except ValueError as e:
|
||||
logger.warning("Failed to decode edited image data: %s", e)
|
||||
await ctx.send("Failed to process the edited image.")
|
||||
return
|
||||
|
||||
send_img = self._make_file(edited_image_data, "image.png")
|
||||
await ctx.send(file=send_img)
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Speech service: parse the voice flag, dispatch bot-vs-plain, run TTS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from vibe_bot import llm_client
|
||||
from vibe_bot.config import (
|
||||
CHAT_MODEL,
|
||||
MAX_COMPLETION_TOKENS,
|
||||
TTS_SPEED,
|
||||
TTS_VOICE,
|
||||
VOICES_LIST,
|
||||
)
|
||||
from vibe_bot.prompts import build_system_prompt, get_user_info
|
||||
from vibe_bot.tts import DEFAULT_LANG
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from discord.ext.commands import Bot, Context
|
||||
|
||||
from vibe_bot.database import ChatDatabase, CustomBotManager
|
||||
from vibe_bot.tts import TTSEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Input size bound: reject oversized text before it reaches the TTS engine.
|
||||
MAX_SPEAK_LENGTH = 5000
|
||||
|
||||
# Injected factory that turns an in-memory audio buffer into a discord.File.
|
||||
FileFactory = Callable[[BytesIO, str], Any]
|
||||
|
||||
# Precomputed voice -> language lookup (replaces a per-call VOICES_LIST scan).
|
||||
VOICE_LANGUAGES: dict[str, str] = {
|
||||
voice: str(category["language"])
|
||||
for category in VOICES_LIST.values()
|
||||
for voice in category["voices"]
|
||||
}
|
||||
|
||||
# Trailing-anchored voice flag: only a `--voice <name>` at the very end of the
|
||||
# message is treated as a flag, so the flag mid-text is preserved as speech.
|
||||
_VOICE_FLAG_RE = re.compile(r"^(?P<text>.*)\s+--voice\s+(?P<voice>\S+)$")
|
||||
|
||||
|
||||
def parse_voice_flag(message: str) -> tuple[str, str | None]:
|
||||
"""Split a trailing `--voice <name>` flag off a speak message.
|
||||
|
||||
Returns:
|
||||
(text, voice) where voice is None when no trailing flag is present and
|
||||
text is then returned unchanged.
|
||||
"""
|
||||
match = _VOICE_FLAG_RE.match(message)
|
||||
if not match:
|
||||
return message, None
|
||||
return match["text"].rstrip(), match["voice"]
|
||||
|
||||
|
||||
class SpeechService:
|
||||
"""Speaks text (plain or via a custom bot) using the Kokoro TTS engine."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: ChatDatabase,
|
||||
manager: CustomBotManager,
|
||||
tts: TTSEngine | None,
|
||||
make_file: FileFactory,
|
||||
) -> None:
|
||||
self._db = db
|
||||
self._manager = manager
|
||||
self._tts = tts
|
||||
self._make_file = make_file
|
||||
|
||||
async def speak(self, ctx: Context[Bot], *, message: str) -> None:
|
||||
"""Have the bot speak the given text, or have a custom bot respond+speaks."""
|
||||
if self._tts is None:
|
||||
await ctx.send(
|
||||
"TTS engine not initialized. "
|
||||
"Make sure kokoro-v1.0.onnx and voices-v1.0.bin are present.",
|
||||
)
|
||||
return
|
||||
|
||||
text, voice = parse_voice_flag(message)
|
||||
|
||||
if not text or not text.strip():
|
||||
await ctx.send("Please provide text to speak.")
|
||||
return
|
||||
|
||||
if len(text) > MAX_SPEAK_LENGTH:
|
||||
logger.warning(
|
||||
"Speak text too long from user %s: length=%d",
|
||||
ctx.author.id,
|
||||
len(text),
|
||||
)
|
||||
await ctx.send(
|
||||
f"Text too long to speak. Max {MAX_SPEAK_LENGTH} characters."
|
||||
)
|
||||
return
|
||||
|
||||
# Validate the voice if one was requested.
|
||||
if voice is not None and voice not in VOICE_LANGUAGES:
|
||||
await ctx.send(
|
||||
f"Unknown voice '{voice}'. Use `!voices` to see available voices."
|
||||
)
|
||||
return
|
||||
|
||||
custom_bots = await asyncio.to_thread(self._manager.list_custom_bots)
|
||||
bot_names = [b[0] for b in custom_bots]
|
||||
|
||||
first_word = text.split(maxsplit=1)[0] if text.split() else ""
|
||||
if first_word in bot_names:
|
||||
await self._speak_with_bot(ctx, first_word, text, voice)
|
||||
else:
|
||||
await self._speak_plain(ctx, text, voice)
|
||||
|
||||
async def _speak_with_bot(
|
||||
self,
|
||||
ctx: Context[Bot],
|
||||
bot_name: str,
|
||||
message: str,
|
||||
voice: str | None,
|
||||
) -> None:
|
||||
"""Have a custom bot respond to the message and speak the response."""
|
||||
text_to_speak = message[len(bot_name) :].lstrip()
|
||||
if not text_to_speak:
|
||||
await ctx.send("Please provide text for the bot to respond to.")
|
||||
return
|
||||
|
||||
await ctx.send(f"**{bot_name}** is thinking...")
|
||||
|
||||
bot_info = await asyncio.to_thread(self._manager.get_custom_bot, bot_name)
|
||||
if not bot_info:
|
||||
await ctx.send(f"Custom bot '{bot_name}' not found.")
|
||||
return
|
||||
|
||||
_, system_prompt, _, _ = bot_info
|
||||
system_prompt_edit = build_system_prompt(
|
||||
system_prompt, get_user_info(ctx.author)
|
||||
)
|
||||
|
||||
engine = self._tts
|
||||
if engine is None:
|
||||
await ctx.send(
|
||||
"TTS engine not initialized. "
|
||||
"Make sure kokoro-v1.0.onnx and voices-v1.0.bin are present.",
|
||||
)
|
||||
return
|
||||
|
||||
# Determine language for the chosen voice.
|
||||
chosen_voice = voice or TTS_VOICE
|
||||
lang = VOICE_LANGUAGES.get(chosen_voice, DEFAULT_LANG)
|
||||
|
||||
try:
|
||||
context = await asyncio.to_thread(
|
||||
self._db.get_conversation_context,
|
||||
user_id=str(ctx.author.id),
|
||||
current_message=text_to_speak,
|
||||
max_context=5,
|
||||
)
|
||||
|
||||
prompts: list[dict[str, str]] = [{"role": "user", "content": text_to_speak}]
|
||||
if context:
|
||||
prompts = context + prompts
|
||||
|
||||
# Tools come from the shared registry (schema + dispatch).
|
||||
registry = llm_client.get_tool_registry()
|
||||
speak_tools = registry.to_openai_tools()
|
||||
|
||||
def speak_tool_executor(tool_name: str, tool_args: dict[str, str]) -> str:
|
||||
"""Dispatch a tool call through the registry."""
|
||||
return registry.execute(tool_name, tool_args, channel=ctx.channel)
|
||||
|
||||
async def speak_tool_call_notifier(
|
||||
tool_name: str, tool_args: dict[str, str]
|
||||
) -> None:
|
||||
"""Send a notification message when a tool is called."""
|
||||
if tool_name == "get_channel_members":
|
||||
await ctx.send(
|
||||
f"**{bot_name}** is looking at the channel members..."
|
||||
)
|
||||
|
||||
bot_response = await llm_client.chat_completion_with_tools(
|
||||
system_prompt=system_prompt_edit,
|
||||
prompts=prompts,
|
||||
tools=speak_tools,
|
||||
tool_executor=speak_tool_executor,
|
||||
tool_call_notifier=speak_tool_call_notifier,
|
||||
model=CHAT_MODEL,
|
||||
max_tokens=MAX_COMPLETION_TOKENS,
|
||||
)
|
||||
|
||||
if not bot_response:
|
||||
await ctx.send(f"**{bot_name}** failed to generate a response.")
|
||||
return
|
||||
|
||||
await asyncio.to_thread(
|
||||
self._db.add_message,
|
||||
message_id=f"{ctx.message.id}",
|
||||
user_id=str(ctx.author.id),
|
||||
username=ctx.author.name,
|
||||
content=f"User: {text_to_speak}",
|
||||
bot_name=bot_name,
|
||||
channel_id=str(ctx.channel.id),
|
||||
guild_id=str(ctx.guild.id) if ctx.guild else None,
|
||||
)
|
||||
|
||||
if ctx.bot.user is not None:
|
||||
await asyncio.to_thread(
|
||||
self._db.add_message,
|
||||
message_id=f"{ctx.message.id}_response",
|
||||
user_id=str(ctx.bot.user.id),
|
||||
username=ctx.bot.user.name,
|
||||
content=bot_response,
|
||||
bot_name=bot_name,
|
||||
channel_id=str(ctx.channel.id),
|
||||
guild_id=str(ctx.guild.id) if ctx.guild else None,
|
||||
role="assistant",
|
||||
embed=False,
|
||||
)
|
||||
|
||||
await ctx.send(f"**{bot_name}**: {bot_response}")
|
||||
await ctx.send(f"Generating speech for **{bot_name}**...")
|
||||
result = await asyncio.to_thread(
|
||||
engine.generate_audio,
|
||||
bot_response,
|
||||
voice=chosen_voice,
|
||||
speed=TTS_SPEED,
|
||||
lang=lang,
|
||||
)
|
||||
if result.partial:
|
||||
await ctx.send("Some audio chunks failed; audio may be incomplete.")
|
||||
|
||||
audio_file = self._make_file(result.audio, "speech.mp3")
|
||||
await ctx.send(file=audio_file)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error in speak command with bot %r",
|
||||
bot_name,
|
||||
)
|
||||
await ctx.send("Error generating speech.")
|
||||
|
||||
async def _speak_plain(
|
||||
self,
|
||||
ctx: Context[Bot],
|
||||
message: str,
|
||||
voice: str | None,
|
||||
) -> None:
|
||||
"""Speak plain text (no custom bot involved)."""
|
||||
engine = self._tts
|
||||
if engine is None:
|
||||
await ctx.send(
|
||||
"TTS engine not initialized. "
|
||||
"Make sure kokoro-v1.0.onnx and voices-v1.0.bin are present.",
|
||||
)
|
||||
return
|
||||
|
||||
chosen_voice = voice or TTS_VOICE
|
||||
lang = VOICE_LANGUAGES.get(chosen_voice, DEFAULT_LANG)
|
||||
|
||||
try:
|
||||
await ctx.send("Generating speech...")
|
||||
result = await asyncio.to_thread(
|
||||
engine.generate_audio,
|
||||
message,
|
||||
voice=chosen_voice,
|
||||
speed=TTS_SPEED,
|
||||
lang=lang,
|
||||
)
|
||||
if result.partial:
|
||||
await ctx.send("Some audio chunks failed; audio may be incomplete.")
|
||||
|
||||
audio_file = self._make_file(result.audio, "speech.mp3")
|
||||
await ctx.send(file=audio_file)
|
||||
except Exception:
|
||||
logger.exception("Error in speak command")
|
||||
await ctx.send("Error generating speech.")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Shared helpers for wiring tests (command invocation, sent-text asserts)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
|
||||
def invoke(bot: commands.Bot, name: str, *args: Any, **kwargs: Any) -> None:
|
||||
"""Invoke a registered command's callback directly with the given args."""
|
||||
cmd = bot.get_command(name)
|
||||
assert cmd is not None
|
||||
callback = cast("Callable[..., Any]", cmd.callback)
|
||||
asyncio.run(callback(*args, **kwargs))
|
||||
|
||||
|
||||
def sent_texts(ctx: MagicMock) -> list[str]:
|
||||
"""All positional text messages sent through ctx.send."""
|
||||
return [c.args[0] for c in ctx.send.call_args_list if c.args]
|
||||
+77
-148
@@ -5,12 +5,16 @@ from __future__ import annotations
|
||||
import tempfile
|
||||
import warnings
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.app import App, build_bot
|
||||
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
@@ -21,41 +25,79 @@ if TYPE_CHECKING:
|
||||
from vibe_bot.database import ChatDatabase, CustomBotManager
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppMocks:
|
||||
"""The App under test plus its mock dependencies, for setup and asserts."""
|
||||
|
||||
app: App
|
||||
db: MagicMock
|
||||
manager: MagicMock
|
||||
registry: MagicMock
|
||||
chat: MagicMock
|
||||
image: MagicMock
|
||||
speech: MagicMock
|
||||
conversation: MagicMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_env_vars() -> Generator[None]:
|
||||
"""Provide minimal env vars for config loading."""
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"DISCORD_TOKEN": "test-token",
|
||||
"CHAT_ENDPOINT": "https://chat.example.com/v1",
|
||||
"COMPLETION_ENDPOINT": "https://completion.example.com/v1",
|
||||
"IMAGE_GEN_ENDPOINT": "https://image.example.com/v1",
|
||||
"IMAGE_EDIT_ENDPOINT": "https://image-edit.example.com/v1",
|
||||
"EMBEDDING_ENDPOINT": "https://embedding.example.com/v1",
|
||||
"CHAT_MODEL": "test-chat-model",
|
||||
"COMPLETION_MODEL": "test-completion-model",
|
||||
"IMAGE_GEN_MODEL": "test-image-model",
|
||||
"IMAGE_EDIT_MODEL": "test-image-edit-model",
|
||||
"EMBEDDING_MODEL": "test-embedding-model",
|
||||
"CHAT_ENDPOINT_KEY": "test-key",
|
||||
"COMPLETION_ENDPOINT_KEY": "test-completion-key",
|
||||
"IMAGE_GEN_ENDPOINT_KEY": "test-image-key",
|
||||
"IMAGE_EDIT_ENDPOINT_KEY": "test-image-edit-key",
|
||||
"EMBEDDING_ENDPOINT_KEY": "test-embedding-key",
|
||||
"MAX_COMPLETION_TOKENS": "1000",
|
||||
"MAX_HISTORY_MESSAGES": "1000",
|
||||
"SIMILARITY_THRESHOLD": "0.7",
|
||||
"TOP_K_RESULTS": "5",
|
||||
"TTS_MODEL_PATH": "/tmp/test-model.onnx",
|
||||
"TTS_VOICES_PATH": "/tmp/test-voices.bin",
|
||||
"TTS_VOICE": "af_sarah",
|
||||
"TTS_SPEED": "1.0",
|
||||
"DB_PATH": ":memory:",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
yield
|
||||
def mock_ctx() -> MagicMock:
|
||||
"""Create a mock Discord command context."""
|
||||
ctx = MagicMock()
|
||||
ctx.author.name = "testuser"
|
||||
ctx.author.id = "12345"
|
||||
ctx.author.global_name = "Test User"
|
||||
ctx.author.nick = "tester"
|
||||
ctx.author.top_role.name = "@everyone"
|
||||
ctx.author.activities = []
|
||||
ctx.author.joined_at = None
|
||||
ctx.author.created_at = None
|
||||
ctx.channel.id = "channel-1"
|
||||
ctx.guild.id = "guild-1"
|
||||
ctx.message.id = "msg-1"
|
||||
ctx.message.attachments = []
|
||||
ctx.bot.user = MagicMock()
|
||||
ctx.bot.user.name = "test-bot"
|
||||
ctx.bot.user.id = "bot-123"
|
||||
ctx.send = AsyncMock()
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_mocks() -> AppMocks:
|
||||
"""An App built entirely from mocks, with alfred in the bot cache."""
|
||||
db = MagicMock()
|
||||
manager = MagicMock()
|
||||
manager.list_custom_bots.return_value = [
|
||||
("alfred", "british butler", "user123"),
|
||||
]
|
||||
registry = MagicMock()
|
||||
chat = MagicMock()
|
||||
chat.handle = AsyncMock()
|
||||
image = MagicMock()
|
||||
image.generate = AsyncMock()
|
||||
image.edit = AsyncMock()
|
||||
speech = MagicMock()
|
||||
speech.speak = AsyncMock()
|
||||
conversation = MagicMock()
|
||||
conversation.run = AsyncMock()
|
||||
app = App(
|
||||
db=db,
|
||||
manager=manager,
|
||||
registry=registry,
|
||||
tts=MagicMock(),
|
||||
chat=chat,
|
||||
image=image,
|
||||
speech=speech,
|
||||
conversation=conversation,
|
||||
bot_cache={"alfred": ("british butler", "user123")},
|
||||
)
|
||||
return AppMocks(app, db, manager, registry, chat, image, speech, conversation)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bot(app_mocks: AppMocks) -> commands.Bot:
|
||||
"""A real Bot built from the mock App (never connected)."""
|
||||
return build_bot(app_mocks.app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -71,22 +113,13 @@ def temp_db_path() -> Generator[str]:
|
||||
def mock_embedding() -> Generator[MagicMock]:
|
||||
"""Provide a mock embedding function returning a fixed vector."""
|
||||
vector: list[float] = [0.1] * 2048
|
||||
with patch("vibe_bot.llama_wrapper.embedding", return_value=vector) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_client() -> Generator[MagicMock]:
|
||||
"""Provide a mock OpenAI client."""
|
||||
mock_client = MagicMock()
|
||||
with patch("vibe_bot.database.OpenAI", return_value=mock_client) as mock:
|
||||
with patch("vibe_bot.llm_client.embedding", return_value=vector) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat_db(
|
||||
temp_db_path: str,
|
||||
mock_openai_client: MagicMock,
|
||||
mock_embedding: MagicMock,
|
||||
) -> Generator[ChatDatabase]:
|
||||
"""Provide a ChatDatabase instance with a temp database."""
|
||||
@@ -94,7 +127,6 @@ def chat_db(
|
||||
|
||||
db = ChatDatabase(db_path=temp_db_path)
|
||||
yield db
|
||||
db.client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -133,106 +165,3 @@ def mock_kokoro_tts() -> Generator[dict[str, Any]]:
|
||||
"mock_samples": mock_samples,
|
||||
"mock_sr": 24000,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_discord() -> Generator[dict[str, MagicMock]]:
|
||||
"""Mock discord module components."""
|
||||
mock_intents = MagicMock()
|
||||
mock_intents.default.return_value = MagicMock()
|
||||
mock_intents.default.return_value.message_content = True
|
||||
|
||||
mock_bot_class = MagicMock()
|
||||
mock_bot_instance = MagicMock()
|
||||
mock_bot_instance.user = MagicMock()
|
||||
mock_bot_instance.user.name = "test-bot"
|
||||
mock_bot_instance.user.id = "123456789"
|
||||
|
||||
with (
|
||||
patch("vibe_bot.main.discord") as mock_discord_module,
|
||||
patch("vibe_bot.main.commands", MagicMock()),
|
||||
patch("vibe_bot.main.commands.Bot", mock_bot_class),
|
||||
):
|
||||
mock_bot_class.return_value = mock_bot_instance
|
||||
mock_discord_module.Intents = mock_intents
|
||||
mock_discord_module.Message = MagicMock
|
||||
mock_discord_module.File = MagicMock
|
||||
yield {
|
||||
"Intents": mock_intents,
|
||||
"Bot": mock_bot_class,
|
||||
"bot_instance": mock_bot_instance,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tts_engine() -> Generator[MagicMock]:
|
||||
"""Provide a mock TTSEngine."""
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.generate_audio.return_value = MagicMock()
|
||||
with (
|
||||
patch("vibe_bot.main.tts_engine", mock_engine),
|
||||
patch("vibe_bot.main.tts.TTSEngine", return_value=mock_engine),
|
||||
):
|
||||
yield mock_engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_requests() -> Generator[MagicMock]:
|
||||
"""Provide mock requests module."""
|
||||
with patch("vibe_bot.main.requests") as mock_requests_module:
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = b"fake image data"
|
||||
mock_requests_module.get.return_value = mock_response
|
||||
yield mock_requests_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_base64() -> Generator[MagicMock]:
|
||||
"""Provide mock base64 module."""
|
||||
with patch("vibe_bot.main.base64") as mock_base64_module:
|
||||
mock_base64_module.b64decode.return_value = b"fake image data"
|
||||
yield mock_base64_module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llama_wrapper() -> Generator[MagicMock]:
|
||||
"""Provide mock llama_wrapper module."""
|
||||
with patch("vibe_bot.main.llama_wrapper") as mock_wrapper:
|
||||
mock_wrapper.chat_completion_with_history.return_value = "Bot response"
|
||||
mock_wrapper.chat_completion_with_tools = AsyncMock(return_value="Bot response")
|
||||
mock_wrapper.chat_completion_instruct.return_value = "image prompt"
|
||||
mock_wrapper.image_generation.return_value = ""
|
||||
mock_wrapper.image_edit.return_value = ""
|
||||
mock_wrapper.embedding.return_value = [0.1] * 2048
|
||||
yield mock_wrapper
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_database() -> Generator[MagicMock]:
|
||||
"""Provide mock database module."""
|
||||
with patch("vibe_bot.main.get_database") as mock_get_db:
|
||||
mock_db = MagicMock()
|
||||
mock_db.get_conversation_context.return_value = []
|
||||
mock_db.add_message.return_value = True
|
||||
mock_get_db.return_value = mock_db
|
||||
yield mock_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_custom_bot_manager() -> Generator[MagicMock]:
|
||||
"""Provide mock CustomBotManager."""
|
||||
with patch("vibe_bot.main.CustomBotManager") as mock_manager_class:
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.create_custom_bot.return_value = True
|
||||
mock_manager.get_custom_bot.return_value = (
|
||||
"alfred",
|
||||
"british butler personality",
|
||||
"user123",
|
||||
"2024-01-01",
|
||||
)
|
||||
mock_manager.list_custom_bots.return_value = [
|
||||
("alfred", "british butler personality", "user123"),
|
||||
]
|
||||
mock_manager.delete_custom_bot.return_value = True
|
||||
mock_manager_class.return_value = mock_manager
|
||||
yield mock_manager
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Tests for the app composition root (singletons, services, bot handlers)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.app import (
|
||||
configure_logging,
|
||||
create_app,
|
||||
invalidate_bot_cache,
|
||||
)
|
||||
from vibe_bot.commands import speech as speech_commands
|
||||
from vibe_bot.config import TTS_MODEL_PATH, TTS_VOICES_PATH
|
||||
from vibe_bot.services.chat_service import ChatService
|
||||
from vibe_bot.services.conversation_service import ConversationService
|
||||
from vibe_bot.services.image_service import ImageService
|
||||
from vibe_bot.services.speech_service import SpeechService
|
||||
from vibe_bot.tests._helpers import sent_texts
|
||||
from vibe_bot.tests.conftest import AppMocks
|
||||
|
||||
|
||||
def test_create_app_wires_singletons(
|
||||
chat_db: Any,
|
||||
custom_bot_manager: Any,
|
||||
) -> None:
|
||||
"""create_app shares one db/manager with the services and seeds the cache."""
|
||||
engine = MagicMock()
|
||||
with (
|
||||
patch("vibe_bot.app.ChatDatabase", return_value=chat_db),
|
||||
patch("vibe_bot.app.CustomBotManager", return_value=custom_bot_manager),
|
||||
patch("vibe_bot.app.TTSEngine", return_value=engine) as mock_tts,
|
||||
patch(
|
||||
"vibe_bot.llm_client.get_tool_registry",
|
||||
return_value=MagicMock(),
|
||||
) as mock_registry,
|
||||
):
|
||||
app = create_app()
|
||||
|
||||
mock_tts.assert_called_once_with(TTS_MODEL_PATH, TTS_VOICES_PATH)
|
||||
assert app.db is chat_db
|
||||
assert app.manager is custom_bot_manager
|
||||
assert app.tts is engine
|
||||
assert app.registry is mock_registry.return_value
|
||||
assert isinstance(app.chat, ChatService)
|
||||
assert isinstance(app.image, ImageService)
|
||||
assert isinstance(app.speech, SpeechService)
|
||||
assert isinstance(app.conversation, ConversationService)
|
||||
assert app.chat._db is chat_db
|
||||
assert app.chat._registry is mock_registry.return_value
|
||||
assert app.image._db is chat_db
|
||||
assert app.speech._db is chat_db
|
||||
assert app.speech._manager is custom_bot_manager
|
||||
assert app.speech._tts is engine
|
||||
assert app.conversation._manager is custom_bot_manager
|
||||
assert app.bot_cache == {}
|
||||
|
||||
|
||||
def test_create_app_tts_failure_tolerant(
|
||||
chat_db: Any,
|
||||
custom_bot_manager: Any,
|
||||
) -> None:
|
||||
"""A failing TTS engine degrades to None instead of crashing startup."""
|
||||
with (
|
||||
patch("vibe_bot.app.ChatDatabase", return_value=chat_db),
|
||||
patch("vibe_bot.app.CustomBotManager", return_value=custom_bot_manager),
|
||||
patch("vibe_bot.app.TTSEngine", side_effect=OSError("no model file")),
|
||||
):
|
||||
app = create_app()
|
||||
|
||||
assert app.tts is None
|
||||
|
||||
|
||||
def test_invalidate_bot_cache_rebuilds(app_mocks: AppMocks) -> None:
|
||||
"""invalidate_bot_cache rebuilds the cache from the manager."""
|
||||
app_mocks.manager.list_custom_bots.return_value = [
|
||||
("newbot", "a personality", "user999"),
|
||||
("alfred", "british butler", "user123"),
|
||||
]
|
||||
invalidate_bot_cache(app_mocks.app)
|
||||
assert app_mocks.app.bot_cache == {
|
||||
"newbot": ("a personality", "user999"),
|
||||
"alfred": ("british butler", "user123"),
|
||||
}
|
||||
|
||||
|
||||
def test_configure_logging_configures_root() -> None:
|
||||
"""configure_logging is the sole basicConfig: it adds a root handler."""
|
||||
root = logging.getLogger()
|
||||
original = root.handlers
|
||||
root.handlers.clear()
|
||||
try:
|
||||
configure_logging()
|
||||
assert len(root.handlers) == 1
|
||||
handler = root.handlers[0]
|
||||
assert handler.formatter is not None
|
||||
assert handler.formatter._fmt == (
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
finally:
|
||||
root.handlers = original
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_bot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_bot_registers_commands(bot: commands.Bot) -> None:
|
||||
"""build_bot returns a commands.Bot with every command registered."""
|
||||
assert isinstance(bot, commands.Bot)
|
||||
for name in (
|
||||
"custom-bot",
|
||||
"list-custom-bots",
|
||||
"delete-custom-bot",
|
||||
"lobotomize",
|
||||
"debug",
|
||||
"voices",
|
||||
"speak",
|
||||
"doodlebob",
|
||||
"retcon",
|
||||
"history",
|
||||
"talkforme",
|
||||
):
|
||||
assert bot.get_command(name) is not None
|
||||
for event in ("on_ready", "on_message", "on_command_error"):
|
||||
assert event in bot.__dict__
|
||||
|
||||
|
||||
def test_build_bot_intents(bot: commands.Bot) -> None:
|
||||
"""message_content, members, and presences intents are enabled."""
|
||||
assert bot.intents.message_content is True
|
||||
assert bot.intents.members is True
|
||||
assert bot.intents.presences is True
|
||||
|
||||
|
||||
def test_handlers_refuse_to_run_before_build_bot() -> None:
|
||||
"""Event and command handlers raise if invoked before the wiring exists."""
|
||||
import vibe_bot.app as app_module
|
||||
from vibe_bot.commands import _state as commands_state
|
||||
|
||||
saved_app, saved_bot = app_module._app, app_module._bot
|
||||
saved_commands_app = commands_state._app
|
||||
app_module._app = None
|
||||
app_module._bot = None
|
||||
commands_state._app = None
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="App is not initialized"):
|
||||
asyncio.run(app_module.on_message(MagicMock()))
|
||||
with pytest.raises(RuntimeError, match="Bot is not initialized"):
|
||||
asyncio.run(app_module.on_ready())
|
||||
with pytest.raises(RuntimeError, match="App is not initialized"):
|
||||
asyncio.run(speech_commands.speak(MagicMock(), message="hello"))
|
||||
finally:
|
||||
app_module._app = saved_app
|
||||
app_module._bot = saved_bot
|
||||
commands_state._app = saved_commands_app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# on_message guards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_on_message_ignores_non_bang_messages(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
) -> None:
|
||||
"""A non-! message returns before any service or DB touch."""
|
||||
message = MagicMock()
|
||||
message.author = MagicMock()
|
||||
message.content = "hello there"
|
||||
with patch.object(bot, "process_commands", new=AsyncMock()) as mock_process:
|
||||
asyncio.run(bot.on_message(message))
|
||||
|
||||
mock_process.assert_not_called()
|
||||
app_mocks.chat.handle.assert_not_awaited()
|
||||
app_mocks.speech.speak.assert_not_awaited()
|
||||
app_mocks.image.generate.assert_not_awaited()
|
||||
app_mocks.image.edit.assert_not_awaited()
|
||||
app_mocks.conversation.run.assert_not_awaited()
|
||||
assert app_mocks.manager.mock_calls == []
|
||||
assert app_mocks.db.mock_calls == []
|
||||
|
||||
|
||||
def test_on_message_skips_bot_authors(app_mocks: AppMocks, bot: commands.Bot) -> None:
|
||||
"""A bot-authored message returns without calling any service or DB."""
|
||||
message = MagicMock()
|
||||
message.author = bot.user
|
||||
message.content = "!alfred hi"
|
||||
with patch.object(bot, "process_commands", new=AsyncMock()) as mock_process:
|
||||
asyncio.run(bot.on_message(message))
|
||||
|
||||
mock_process.assert_not_called()
|
||||
app_mocks.chat.handle.assert_not_awaited()
|
||||
assert app_mocks.manager.mock_calls == []
|
||||
assert app_mocks.db.mock_calls == []
|
||||
|
||||
|
||||
def test_on_message_routes_custom_bot(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!alfred hi dispatches to the chat service with the parsed args."""
|
||||
message = MagicMock()
|
||||
message.author = MagicMock()
|
||||
message.author.name = "testuser"
|
||||
message.content = "!alfred hi"
|
||||
with (
|
||||
patch.object(bot, "get_context", new=AsyncMock(return_value=mock_ctx)),
|
||||
patch.object(bot, "process_commands", new=AsyncMock()) as mock_process,
|
||||
):
|
||||
asyncio.run(bot.on_message(message))
|
||||
|
||||
app_mocks.chat.handle.assert_awaited_once_with(
|
||||
ctx=mock_ctx,
|
||||
bot_name="alfred",
|
||||
message="hi",
|
||||
system_prompt="british butler",
|
||||
response_prefix="alfred response",
|
||||
)
|
||||
mock_process.assert_not_called()
|
||||
|
||||
|
||||
def test_on_message_falls_through_to_commands(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
) -> None:
|
||||
"""An unmatched ! message falls through to process_commands."""
|
||||
message = MagicMock()
|
||||
message.author = MagicMock()
|
||||
message.author.name = "testuser"
|
||||
message.content = "!unknown hi"
|
||||
with patch.object(bot, "process_commands", new=AsyncMock()) as mock_process:
|
||||
asyncio.run(bot.on_message(message))
|
||||
|
||||
mock_process.assert_awaited_once_with(message)
|
||||
app_mocks.chat.handle.assert_not_awaited()
|
||||
|
||||
|
||||
def test_on_message_uses_cache_not_manager(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
) -> None:
|
||||
"""Bot-name matching reads bot_cache, never list_custom_bots()."""
|
||||
message = MagicMock()
|
||||
message.author = MagicMock()
|
||||
message.author.name = "testuser"
|
||||
message.content = "!alfred hi"
|
||||
with patch.object(bot, "get_context", new=AsyncMock(return_value=MagicMock())):
|
||||
asyncio.run(bot.on_message(message))
|
||||
|
||||
app_mocks.manager.list_custom_bots.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# on_ready / on_command_error
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_on_ready(bot: commands.Bot) -> None:
|
||||
"""on_ready logs startup without raising."""
|
||||
handler = bot.__dict__["on_ready"]
|
||||
asyncio.run(handler())
|
||||
|
||||
|
||||
def test_on_command_error_cooldown(
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""A cooldown error becomes a friendly message."""
|
||||
from discord.ext.commands.cooldowns import Cooldown
|
||||
|
||||
error = commands.CommandOnCooldown(
|
||||
Cooldown(1.0, 60.0), 0.5, commands.BucketType.user
|
||||
)
|
||||
asyncio.run(bot.on_command_error(mock_ctx, error))
|
||||
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("too quickly" in t for t in texts)
|
||||
|
||||
|
||||
def test_on_command_error_generic(
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""Any other command error is logged and not re-raised."""
|
||||
error = commands.CommandError("boom")
|
||||
asyncio.run(bot.on_command_error(mock_ctx, error))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main.py entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_main_builds_and_runs_bot() -> None:
|
||||
"""main() validates config, configures logging, and runs the built bot."""
|
||||
from vibe_bot import main as main_module
|
||||
|
||||
with (
|
||||
patch.object(main_module, "validate_config") as mock_validate,
|
||||
patch.object(main_module, "configure_logging") as mock_logging,
|
||||
patch.object(
|
||||
main_module, "create_app", return_value=MagicMock()
|
||||
) as mock_create,
|
||||
patch.object(main_module, "build_bot", return_value=MagicMock()) as mock_build,
|
||||
patch.object(main_module, "DISCORD_TOKEN", "test-token"),
|
||||
):
|
||||
main_module.main()
|
||||
|
||||
mock_validate.assert_called_once_with()
|
||||
mock_logging.assert_called_once_with()
|
||||
mock_create.assert_called_once_with()
|
||||
mock_build.assert_called_once()
|
||||
mock_build.return_value.run.assert_called_once_with("test-token")
|
||||
@@ -0,0 +1,643 @@
|
||||
"""Wiring tests for the commands package: every handler reaches its service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from discord.ext import commands
|
||||
|
||||
from vibe_bot.commands.custom_bots import MAX_PERSONALITY_LENGTH
|
||||
from vibe_bot.tests._helpers import invoke, sent_texts
|
||||
from vibe_bot.tests.conftest import AppMocks
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# custom_bots: create / list / delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_custom_bot_success_updates_cache(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""A successful create invalidates the cache with the new bot."""
|
||||
app_mocks.manager.create_custom_bot.return_value = "created"
|
||||
app_mocks.manager.list_custom_bots.return_value = [
|
||||
("newbot", "a personality", "user123"),
|
||||
]
|
||||
|
||||
invoke(
|
||||
bot, "custom-bot", mock_ctx, "newbot", personality="you are a british butler"
|
||||
)
|
||||
|
||||
app_mocks.manager.create_custom_bot.assert_called_once_with(
|
||||
bot_name="newbot",
|
||||
system_prompt="you are a british butler",
|
||||
created_by="12345",
|
||||
)
|
||||
assert app_mocks.app.bot_cache == {"newbot": ("a personality", "user123")}
|
||||
assert "alfred" not in app_mocks.app.bot_cache
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("has been created" in t for t in texts)
|
||||
assert any("You can now use this bot" in t for t in texts)
|
||||
|
||||
|
||||
def test_custom_bot_replaced_message(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""When the name already exists, the command reports a replace, not a create."""
|
||||
app_mocks.manager.create_custom_bot.return_value = "replaced"
|
||||
|
||||
invoke(
|
||||
bot, "custom-bot", mock_ctx, "alfred", personality="you are a british butler"
|
||||
)
|
||||
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("already existed" in t and "replaced" in t for t in texts)
|
||||
|
||||
|
||||
def test_custom_bot_invalid_name_too_short(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""A one-character name is rejected before any manager call."""
|
||||
invoke(bot, "custom-bot", mock_ctx, "a", personality="this is a valid personality")
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("Invalid bot name" in t for t in texts)
|
||||
app_mocks.manager.create_custom_bot.assert_not_called()
|
||||
|
||||
|
||||
def test_custom_bot_invalid_name_empty(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""An empty name is rejected before any manager call."""
|
||||
invoke(bot, "custom-bot", mock_ctx, "", personality="this is a valid personality")
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("Invalid bot name" in t for t in texts)
|
||||
app_mocks.manager.create_custom_bot.assert_not_called()
|
||||
|
||||
|
||||
def test_custom_bot_invalid_personality(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""A personality under 10 characters is rejected."""
|
||||
invoke(bot, "custom-bot", mock_ctx, "testbot", personality="short")
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("Invalid personality" in t for t in texts)
|
||||
app_mocks.manager.create_custom_bot.assert_not_called()
|
||||
|
||||
|
||||
def test_custom_bot_personality_too_long(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""A personality over MAX_PERSONALITY_LENGTH is rejected before any DB call."""
|
||||
invoke(
|
||||
bot,
|
||||
"custom-bot",
|
||||
mock_ctx,
|
||||
"testbot",
|
||||
personality="a" * (MAX_PERSONALITY_LENGTH + 1),
|
||||
)
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("Personality too long" in t for t in texts)
|
||||
app_mocks.manager.create_custom_bot.assert_not_called()
|
||||
|
||||
|
||||
def test_custom_bot_create_fails(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""A failed create reports an error and does not invalidate the cache."""
|
||||
app_mocks.manager.create_custom_bot.return_value = False
|
||||
|
||||
invoke(
|
||||
bot, "custom-bot", mock_ctx, "alfred", personality="you are a british butler"
|
||||
)
|
||||
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("Failed to create custom bot" in t for t in texts)
|
||||
app_mocks.manager.list_custom_bots.assert_not_called()
|
||||
assert app_mocks.app.bot_cache == {"alfred": ("british butler", "user123")}
|
||||
|
||||
|
||||
def test_list_custom_bots_empty(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""Listing with no bots suggests creating one."""
|
||||
app_mocks.manager.list_custom_bots.return_value = []
|
||||
|
||||
invoke(bot, "list-custom-bots", mock_ctx)
|
||||
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("No custom bots" in t for t in texts)
|
||||
|
||||
|
||||
def test_list_custom_bots_with_bots(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""Listing shows every bot name."""
|
||||
app_mocks.manager.list_custom_bots.return_value = [
|
||||
("alfred", "british butler", "user-1"),
|
||||
("jarvis", "ai assistant", "user-2"),
|
||||
]
|
||||
|
||||
invoke(bot, "list-custom-bots", mock_ctx)
|
||||
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("Available Custom Bots" in t for t in texts)
|
||||
assert any("* alfred" in t and "* jarvis" in t for t in texts)
|
||||
|
||||
|
||||
def test_delete_custom_bot_success(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""The creator can delete their bot; the cache is invalidated."""
|
||||
app_mocks.manager.get_custom_bot.return_value = (
|
||||
"alfred",
|
||||
"prompt",
|
||||
"12345",
|
||||
"2024-01-01",
|
||||
)
|
||||
app_mocks.manager.delete_custom_bot.return_value = True
|
||||
app_mocks.manager.list_custom_bots.return_value = []
|
||||
|
||||
invoke(bot, "delete-custom-bot", mock_ctx, "alfred")
|
||||
|
||||
app_mocks.manager.delete_custom_bot.assert_called_once_with("alfred")
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("has been deleted" in t for t in texts)
|
||||
assert app_mocks.app.bot_cache == {}
|
||||
|
||||
|
||||
def test_delete_custom_bot_not_found(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""Deleting a non-existent bot reports not found."""
|
||||
app_mocks.manager.get_custom_bot.return_value = None
|
||||
|
||||
invoke(bot, "delete-custom-bot", mock_ctx, "nonexistent")
|
||||
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("not found" in t for t in texts)
|
||||
app_mocks.manager.delete_custom_bot.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_custom_bot_not_owner(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""A non-owner cannot delete the bot."""
|
||||
app_mocks.manager.get_custom_bot.return_value = (
|
||||
"alfred",
|
||||
"prompt",
|
||||
"other-user-id",
|
||||
"2024-01-01",
|
||||
)
|
||||
|
||||
invoke(bot, "delete-custom-bot", mock_ctx, "alfred")
|
||||
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("You can only delete your own" in t for t in texts)
|
||||
app_mocks.manager.delete_custom_bot.assert_not_called()
|
||||
|
||||
|
||||
def test_delete_custom_bot_delete_fails(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""A failed delete reports an error and keeps the cache."""
|
||||
app_mocks.manager.get_custom_bot.return_value = (
|
||||
"alfred",
|
||||
"prompt",
|
||||
"12345",
|
||||
"2024-01-01",
|
||||
)
|
||||
app_mocks.manager.delete_custom_bot.return_value = False
|
||||
|
||||
invoke(bot, "delete-custom-bot", mock_ctx, "alfred")
|
||||
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("Failed to delete" in t for t in texts)
|
||||
app_mocks.manager.list_custom_bots.assert_not_called()
|
||||
assert app_mocks.app.bot_cache == {"alfred": ("british butler", "user123")}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# speech: speak / voices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_speak_delegates_to_service(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!speak forwards to SpeechService.speak."""
|
||||
invoke(bot, "speak", mock_ctx, message="hello world")
|
||||
|
||||
app_mocks.speech.speak.assert_awaited_once_with(mock_ctx, message="hello world")
|
||||
|
||||
|
||||
def test_speak_cooldown_blocks_third_invocation(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""speak allows 3 per 30s; the 4th immediate call is rate-limited."""
|
||||
cmd = bot.get_command("speak")
|
||||
assert cmd is not None
|
||||
assert cmd.cooldown is not None
|
||||
|
||||
mock_ctx.message.edited_at = None
|
||||
mock_ctx.message.created_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
for _ in range(3):
|
||||
cmd._prepare_cooldowns(mock_ctx)
|
||||
with pytest.raises(commands.CommandOnCooldown) as exc_info:
|
||||
cmd._prepare_cooldowns(mock_ctx)
|
||||
|
||||
asyncio.run(bot.on_command_error(mock_ctx, exc_info.value))
|
||||
|
||||
app_mocks.speech.speak.assert_not_awaited()
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("too quickly" in t for t in texts)
|
||||
|
||||
|
||||
def test_voices(
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!voices lists the voice catalog."""
|
||||
invoke(bot, "voices", mock_ctx)
|
||||
|
||||
full = "\n".join(sent_texts(mock_ctx))
|
||||
assert "Available Voices" in full
|
||||
assert "af_sarah" in full
|
||||
assert "Use `!speak" in full
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# images: doodlebob / retcon
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_doodlebob_delegates_to_service(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!doodlebob forwards to ImageService.generate."""
|
||||
invoke(bot, "doodlebob", mock_ctx, message="a centaur")
|
||||
|
||||
app_mocks.image.generate.assert_awaited_once_with(mock_ctx, message="a centaur")
|
||||
|
||||
|
||||
def test_retcon_delegates_to_service(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!retcon forwards to ImageService.edit."""
|
||||
invoke(bot, "retcon", mock_ctx, message="make it blue")
|
||||
|
||||
app_mocks.image.edit.assert_awaited_once_with(mock_ctx, message="make it blue")
|
||||
|
||||
|
||||
def test_doodlebob_cooldown_blocks_second_invocation(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""A second immediate doodlebob invocation is rate-limited before the LLM."""
|
||||
cmd = bot.get_command("doodlebob")
|
||||
assert cmd is not None
|
||||
assert cmd.cooldown is not None
|
||||
|
||||
mock_ctx.message.edited_at = None
|
||||
mock_ctx.message.created_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
# First invocation consumes the single token (no error).
|
||||
cmd._prepare_cooldowns(mock_ctx)
|
||||
# Second immediate invocation is rejected before the service is called.
|
||||
with pytest.raises(commands.CommandOnCooldown) as exc_info:
|
||||
cmd._prepare_cooldowns(mock_ctx)
|
||||
|
||||
# The error handler turns it into a friendly message.
|
||||
asyncio.run(bot.on_command_error(mock_ctx, exc_info.value))
|
||||
|
||||
app_mocks.image.generate.assert_not_awaited()
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("too quickly" in t for t in texts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# conversation: talkforme
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_talkforme_delegates_to_service(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!talkforme parses its args, then forwards to ConversationService.run."""
|
||||
invoke(bot, "talkforme", mock_ctx, message="a b 3 talking cats")
|
||||
|
||||
app_mocks.conversation.run.assert_awaited_once_with(
|
||||
mock_ctx, "a", "b", "3", "talking cats"
|
||||
)
|
||||
|
||||
|
||||
def test_talkforme_invalid_args(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!talkforme with too few parts shows usage and calls no service."""
|
||||
invoke(bot, "talkforme", mock_ctx, message="bot1 bot2")
|
||||
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("Usage" in t for t in texts)
|
||||
app_mocks.conversation.run.assert_not_awaited()
|
||||
|
||||
|
||||
def test_talkforme_cooldown_blocks_second_invocation(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""A second immediate talkforme invocation is rate-limited before the LLM."""
|
||||
cmd = bot.get_command("talkforme")
|
||||
assert cmd is not None
|
||||
assert cmd.cooldown is not None
|
||||
|
||||
mock_ctx.message.edited_at = None
|
||||
mock_ctx.message.created_at = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
cmd._prepare_cooldowns(mock_ctx)
|
||||
with pytest.raises(commands.CommandOnCooldown) as exc_info:
|
||||
cmd._prepare_cooldowns(mock_ctx)
|
||||
|
||||
asyncio.run(bot.on_command_error(mock_ctx, exc_info.value))
|
||||
|
||||
app_mocks.conversation.run.assert_not_awaited()
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("too quickly" in t for t in texts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# admin: lobotomize / debug / history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_lobotomize(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!lobotomize clears all messages on the shared db."""
|
||||
invoke(bot, "lobotomize", mock_ctx)
|
||||
|
||||
app_mocks.db.clear_all_messages.assert_called_once_with()
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("cleared" in t for t in texts)
|
||||
|
||||
|
||||
def test_history_bot_not_found(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!history on an unknown bot reports not found."""
|
||||
app_mocks.manager.get_custom_bot.return_value = None
|
||||
|
||||
invoke(bot, "history", mock_ctx, "nonexistent")
|
||||
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("not found" in t for t in texts)
|
||||
app_mocks.db.get_bot_history.assert_not_called()
|
||||
|
||||
|
||||
def test_history_no_history(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!history on a bot with no messages says so."""
|
||||
app_mocks.manager.get_custom_bot.return_value = (
|
||||
"alfred",
|
||||
"british butler",
|
||||
"user-123",
|
||||
"2024-01-01",
|
||||
)
|
||||
app_mocks.db.get_bot_history.return_value = []
|
||||
|
||||
invoke(bot, "history", mock_ctx, "alfred")
|
||||
|
||||
texts = sent_texts(mock_ctx)
|
||||
assert any("No chat history" in t and "**alfred**" in t for t in texts)
|
||||
|
||||
|
||||
def test_history_with_data(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!history formats the stored exchange, newest last."""
|
||||
app_mocks.manager.get_custom_bot.return_value = (
|
||||
"alfred",
|
||||
"british butler",
|
||||
"user-123",
|
||||
"2024-01-01",
|
||||
)
|
||||
app_mocks.db.get_bot_history.return_value = [
|
||||
("hello", "yes master?"),
|
||||
("what time is it", "it is currently 3pm"),
|
||||
]
|
||||
|
||||
invoke(bot, "history", mock_ctx, "alfred")
|
||||
|
||||
full = "\n".join(sent_texts(mock_ctx))
|
||||
assert "Chat History for **alfred**" in full
|
||||
assert "what time is it" in full
|
||||
assert "alfred: it is currently 3pm" in full
|
||||
|
||||
|
||||
def test_history_long_response_chunked(
|
||||
app_mocks: AppMocks,
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""Long history payloads are split into multiple sends."""
|
||||
app_mocks.manager.get_custom_bot.return_value = (
|
||||
"alfred",
|
||||
"british butler",
|
||||
"user-123",
|
||||
"2024-01-01",
|
||||
)
|
||||
app_mocks.db.get_bot_history.return_value = [
|
||||
("x" * 2000, "y" * 2000),
|
||||
]
|
||||
|
||||
invoke(bot, "history", mock_ctx, "alfred")
|
||||
|
||||
assert mock_ctx.send.call_count >= 2
|
||||
|
||||
|
||||
def test_debug_no_subcommand(
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!debug without a subcommand shows the menu."""
|
||||
invoke(bot, "debug", mock_ctx, subcommand=None)
|
||||
|
||||
mock_ctx.send.assert_called_once()
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Debug Menu" in call_args
|
||||
assert "members" in call_args
|
||||
|
||||
|
||||
def test_debug_members_no_guild(
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!debug members on a channel without guild members says so."""
|
||||
mock_ctx.channel.guild = None
|
||||
|
||||
invoke(bot, "debug", mock_ctx, subcommand="members")
|
||||
|
||||
mock_ctx.send.assert_called_once()
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "No members found in this channel." in call_args
|
||||
|
||||
|
||||
def test_debug_members_with_members(
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!debug members lists the guild members."""
|
||||
mock_member = MagicMock()
|
||||
mock_member.display_name = "Alice"
|
||||
mock_member.name = "alice"
|
||||
mock_member.nick = None
|
||||
mock_member.global_name = None
|
||||
mock_member.status = MagicMock(value="online")
|
||||
mock_ctx.channel.guild.members = [mock_member]
|
||||
|
||||
invoke(bot, "debug", mock_ctx, subcommand="members")
|
||||
|
||||
assert mock_ctx.send.called
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Alice" in call_args
|
||||
assert "1 total" in call_args
|
||||
|
||||
|
||||
def test_debug_unknown_subcommand(
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!debug with an unknown subcommand explains the usage."""
|
||||
invoke(bot, "debug", mock_ctx, subcommand="unknown")
|
||||
|
||||
mock_ctx.send.assert_called_once()
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Unknown debug sub-command" in call_args
|
||||
|
||||
|
||||
def test_debug_members_many_chunks(
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!debug members with many members exceeds the chunk limit."""
|
||||
mock_members = []
|
||||
for i in range(50):
|
||||
mock_member = MagicMock()
|
||||
mock_member.display_name = f"User{i}_with_a_very_long_display_name"
|
||||
mock_member.name = f"user{i}"
|
||||
mock_member.nick = None
|
||||
mock_member.global_name = None
|
||||
mock_member.status = MagicMock(value="online")
|
||||
mock_members.append(mock_member)
|
||||
mock_ctx.channel.guild.members = mock_members
|
||||
|
||||
invoke(bot, "debug", mock_ctx, subcommand="members")
|
||||
|
||||
assert mock_ctx.send.call_count >= 2
|
||||
first_chunk = mock_ctx.send.call_args_list[0][0][0]
|
||||
assert "Members in this channel (50 total)" in first_chunk
|
||||
|
||||
|
||||
def test_debug_whoami(
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!debug whoami shows user info."""
|
||||
invoke(bot, "debug", mock_ctx, subcommand="whoami")
|
||||
|
||||
mock_ctx.send.assert_called_once()
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Username: testuser" in call_args
|
||||
assert "User ID: 12345" in call_args
|
||||
assert "Global Name: Test User" in call_args
|
||||
assert "Nickname: tester" in call_args
|
||||
|
||||
|
||||
def test_debug_whoami_minimal(
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!debug whoami omits fields the user does not have."""
|
||||
mock_ctx.author.global_name = None
|
||||
mock_ctx.author.nick = None
|
||||
mock_ctx.author.top_role.name = "@everyone"
|
||||
mock_ctx.author.activities = []
|
||||
mock_ctx.author.joined_at = None
|
||||
mock_ctx.author.created_at = None
|
||||
|
||||
invoke(bot, "debug", mock_ctx, subcommand="whoami")
|
||||
|
||||
mock_ctx.send.assert_called_once()
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Username: testuser" in call_args
|
||||
assert "User ID: 12345" in call_args
|
||||
assert "Global Name" not in call_args
|
||||
assert "Nickname" not in call_args
|
||||
assert "Activities" not in call_args
|
||||
assert "Joined" not in call_args
|
||||
|
||||
|
||||
def test_debug_tools(
|
||||
bot: commands.Bot,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!debug tools shows the LLM tool schema."""
|
||||
invoke(bot, "debug", mock_ctx, subcommand="tools")
|
||||
|
||||
mock_ctx.send.assert_called_once()
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "LLM Tools" in call_args
|
||||
assert "get_channel_members" in call_args
|
||||
assert "members" in call_args.lower()
|
||||
@@ -4,6 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_config_defaults() -> None:
|
||||
@@ -12,17 +15,14 @@ def test_config_defaults() -> None:
|
||||
for k, v in {
|
||||
"DISCORD_TOKEN": "test-token",
|
||||
"CHAT_ENDPOINT": "https://chat.example.com/v1",
|
||||
"COMPLETION_ENDPOINT": "https://completion.example.com/v1",
|
||||
"IMAGE_GEN_ENDPOINT": "https://image.example.com/v1",
|
||||
"IMAGE_EDIT_ENDPOINT": "https://image-edit.example.com/v1",
|
||||
"EMBEDDING_ENDPOINT": "https://embedding.example.com/v1",
|
||||
"CHAT_MODEL": "test-chat-model",
|
||||
"COMPLETION_MODEL": "test-completion-model",
|
||||
"IMAGE_GEN_MODEL": "test-image-model",
|
||||
"IMAGE_EDIT_MODEL": "test-image-edit-model",
|
||||
"EMBEDDING_MODEL": "test-embedding-model",
|
||||
"CHAT_ENDPOINT_KEY": "test-key",
|
||||
"COMPLETION_ENDPOINT_KEY": "test-completion-key",
|
||||
"IMAGE_GEN_ENDPOINT_KEY": "test-image-key",
|
||||
"IMAGE_EDIT_ENDPOINT_KEY": "test-image-edit-key",
|
||||
"EMBEDDING_ENDPOINT_KEY": "test-embedding-key",
|
||||
@@ -39,8 +39,6 @@ def test_config_defaults() -> None:
|
||||
env_str += f'os.environ["{k}"] = "{v}"\n'
|
||||
|
||||
code = f"""
|
||||
import sys
|
||||
sys.path.insert(0, "/var/home/ducoterra/Projects/vibe_discord_bots")
|
||||
import os
|
||||
os.environ.clear()
|
||||
os.environ["PATH"] = "/usr/bin:/bin"
|
||||
@@ -48,12 +46,10 @@ os.environ["PATH"] = "/usr/bin:/bin"
|
||||
import vibe_bot.config
|
||||
assert vibe_bot.config.DISCORD_TOKEN == "test-token"
|
||||
assert vibe_bot.config.CHAT_ENDPOINT == "https://chat.example.com/v1"
|
||||
assert vibe_bot.config.COMPLETION_ENDPOINT == "https://completion.example.com/v1"
|
||||
assert vibe_bot.config.IMAGE_GEN_ENDPOINT == "https://image.example.com/v1"
|
||||
assert vibe_bot.config.IMAGE_EDIT_ENDPOINT == "https://image-edit.example.com/v1"
|
||||
assert vibe_bot.config.EMBEDDING_ENDPOINT == "https://embedding.example.com/v1"
|
||||
assert vibe_bot.config.CHAT_MODEL == "test-chat-model"
|
||||
assert vibe_bot.config.COMPLETION_MODEL == "test-completion-model"
|
||||
assert vibe_bot.config.IMAGE_GEN_MODEL == "test-image-model"
|
||||
assert vibe_bot.config.IMAGE_EDIT_MODEL == "test-image-edit-model"
|
||||
assert vibe_bot.config.EMBEDDING_MODEL == "test-embedding-model"
|
||||
@@ -78,20 +74,23 @@ print("OK")
|
||||
|
||||
|
||||
def _run_config_check(env_vars: dict[str, str], expected_error: str) -> None:
|
||||
"""Run a subprocess that imports config and checks for expected RuntimeError."""
|
||||
"""Run a subprocess that imports config and calls validate_config().
|
||||
|
||||
The import itself must never raise; only validate_config() may raise the
|
||||
expected RuntimeError for the missing required setting.
|
||||
"""
|
||||
env_str = ""
|
||||
for k, v in env_vars.items():
|
||||
env_str += f'os.environ["{k}"] = "{v}"\n'
|
||||
|
||||
code = f"""
|
||||
import sys
|
||||
sys.path.insert(0, "/var/home/ducoterra/Projects/vibe_discord_bots")
|
||||
import os
|
||||
os.environ.clear()
|
||||
os.environ["PATH"] = "/usr/bin:/bin"
|
||||
{env_str}
|
||||
try:
|
||||
import vibe_bot.config
|
||||
vibe_bot.config.validate_config()
|
||||
print("NO_ERROR")
|
||||
except RuntimeError as e:
|
||||
print(f"ERROR: {{e}}")
|
||||
@@ -116,12 +115,10 @@ def test_config_missing_discord_token() -> None:
|
||||
env: dict[str, str] = {
|
||||
"DISCORD_TOKEN": "",
|
||||
"CHAT_ENDPOINT": "https://chat.example.com/v1",
|
||||
"COMPLETION_ENDPOINT": "https://completion.example.com/v1",
|
||||
"IMAGE_GEN_ENDPOINT": "https://image.example.com/v1",
|
||||
"IMAGE_EDIT_ENDPOINT": "https://image-edit.example.com/v1",
|
||||
"EMBEDDING_ENDPOINT": "https://embedding.example.com/v1",
|
||||
"CHAT_MODEL": "test-chat-model",
|
||||
"COMPLETION_MODEL": "test-completion-model",
|
||||
"IMAGE_GEN_MODEL": "test-image-model",
|
||||
"IMAGE_EDIT_MODEL": "test-image-edit-model",
|
||||
"EMBEDDING_MODEL": "test-embedding-model",
|
||||
@@ -134,12 +131,10 @@ def test_config_missing_chat_endpoint() -> None:
|
||||
env: dict[str, str] = {
|
||||
"DISCORD_TOKEN": "test-token",
|
||||
"CHAT_ENDPOINT": "",
|
||||
"COMPLETION_ENDPOINT": "https://completion.example.com/v1",
|
||||
"IMAGE_GEN_ENDPOINT": "https://image.example.com/v1",
|
||||
"IMAGE_EDIT_ENDPOINT": "https://image-edit.example.com/v1",
|
||||
"EMBEDDING_ENDPOINT": "https://embedding.example.com/v1",
|
||||
"CHAT_MODEL": "test-chat-model",
|
||||
"COMPLETION_MODEL": "test-completion-model",
|
||||
"IMAGE_GEN_MODEL": "test-image-model",
|
||||
"IMAGE_EDIT_MODEL": "test-image-edit-model",
|
||||
"EMBEDDING_MODEL": "test-embedding-model",
|
||||
@@ -147,35 +142,15 @@ def test_config_missing_chat_endpoint() -> None:
|
||||
_run_config_check(env, "CHAT_ENDPOINT required")
|
||||
|
||||
|
||||
def test_config_missing_completion_endpoint() -> None:
|
||||
"""Test that RuntimeError is raised when COMPLETION_ENDPOINT is missing."""
|
||||
env: dict[str, str] = {
|
||||
"DISCORD_TOKEN": "test-token",
|
||||
"CHAT_ENDPOINT": "https://chat.example.com/v1",
|
||||
"COMPLETION_ENDPOINT": "",
|
||||
"IMAGE_GEN_ENDPOINT": "https://image.example.com/v1",
|
||||
"IMAGE_EDIT_ENDPOINT": "https://image-edit.example.com/v1",
|
||||
"EMBEDDING_ENDPOINT": "https://embedding.example.com/v1",
|
||||
"CHAT_MODEL": "test-chat-model",
|
||||
"COMPLETION_MODEL": "test-completion-model",
|
||||
"IMAGE_GEN_MODEL": "test-image-model",
|
||||
"IMAGE_EDIT_MODEL": "test-image-edit-model",
|
||||
"EMBEDDING_MODEL": "test-embedding-model",
|
||||
}
|
||||
_run_config_check(env, "COMPLETION_ENDPOINT required")
|
||||
|
||||
|
||||
def test_config_missing_image_gen_endpoint() -> None:
|
||||
"""Test that RuntimeError is raised when IMAGE_GEN_ENDPOINT is missing."""
|
||||
env: dict[str, str] = {
|
||||
"DISCORD_TOKEN": "test-token",
|
||||
"CHAT_ENDPOINT": "https://chat.example.com/v1",
|
||||
"COMPLETION_ENDPOINT": "https://completion.example.com/v1",
|
||||
"IMAGE_GEN_ENDPOINT": "",
|
||||
"IMAGE_EDIT_ENDPOINT": "https://image-edit.example.com/v1",
|
||||
"EMBEDDING_ENDPOINT": "https://embedding.example.com/v1",
|
||||
"CHAT_MODEL": "test-chat-model",
|
||||
"COMPLETION_MODEL": "test-completion-model",
|
||||
"IMAGE_GEN_MODEL": "test-image-model",
|
||||
"IMAGE_EDIT_MODEL": "test-image-edit-model",
|
||||
"EMBEDDING_MODEL": "test-embedding-model",
|
||||
@@ -188,12 +163,10 @@ def test_config_missing_image_edit_endpoint() -> None:
|
||||
env: dict[str, str] = {
|
||||
"DISCORD_TOKEN": "test-token",
|
||||
"CHAT_ENDPOINT": "https://chat.example.com/v1",
|
||||
"COMPLETION_ENDPOINT": "https://completion.example.com/v1",
|
||||
"IMAGE_GEN_ENDPOINT": "https://image.example.com/v1",
|
||||
"IMAGE_EDIT_ENDPOINT": "",
|
||||
"EMBEDDING_ENDPOINT": "https://embedding.example.com/v1",
|
||||
"CHAT_MODEL": "test-chat-model",
|
||||
"COMPLETION_MODEL": "test-completion-model",
|
||||
"IMAGE_GEN_MODEL": "test-image-model",
|
||||
"IMAGE_EDIT_MODEL": "test-image-edit-model",
|
||||
"EMBEDDING_MODEL": "test-embedding-model",
|
||||
@@ -206,12 +179,10 @@ def test_config_missing_embedding_endpoint() -> None:
|
||||
env: dict[str, str] = {
|
||||
"DISCORD_TOKEN": "test-token",
|
||||
"CHAT_ENDPOINT": "https://chat.example.com/v1",
|
||||
"COMPLETION_ENDPOINT": "https://completion.example.com/v1",
|
||||
"IMAGE_GEN_ENDPOINT": "https://image.example.com/v1",
|
||||
"IMAGE_EDIT_ENDPOINT": "https://image-edit.example.com/v1",
|
||||
"EMBEDDING_ENDPOINT": "",
|
||||
"CHAT_MODEL": "test-chat-model",
|
||||
"COMPLETION_MODEL": "test-completion-model",
|
||||
"IMAGE_GEN_MODEL": "test-image-model",
|
||||
"IMAGE_EDIT_MODEL": "test-image-edit-model",
|
||||
"EMBEDDING_MODEL": "test-embedding-model",
|
||||
@@ -224,12 +195,10 @@ def test_config_missing_chat_model() -> None:
|
||||
env: dict[str, str] = {
|
||||
"DISCORD_TOKEN": "test-token",
|
||||
"CHAT_ENDPOINT": "https://chat.example.com/v1",
|
||||
"COMPLETION_ENDPOINT": "https://completion.example.com/v1",
|
||||
"IMAGE_GEN_ENDPOINT": "https://image.example.com/v1",
|
||||
"IMAGE_EDIT_ENDPOINT": "https://image-edit.example.com/v1",
|
||||
"EMBEDDING_ENDPOINT": "https://embedding.example.com/v1",
|
||||
"CHAT_MODEL": "",
|
||||
"COMPLETION_MODEL": "test-completion-model",
|
||||
"IMAGE_GEN_MODEL": "test-image-model",
|
||||
"IMAGE_EDIT_MODEL": "test-image-edit-model",
|
||||
"EMBEDDING_MODEL": "test-embedding-model",
|
||||
@@ -237,35 +206,15 @@ def test_config_missing_chat_model() -> None:
|
||||
_run_config_check(env, "CHAT_MODEL required")
|
||||
|
||||
|
||||
def test_config_missing_completion_model() -> None:
|
||||
"""Test that RuntimeError is raised when COMPLETION_MODEL is missing."""
|
||||
env: dict[str, str] = {
|
||||
"DISCORD_TOKEN": "test-token",
|
||||
"CHAT_ENDPOINT": "https://chat.example.com/v1",
|
||||
"COMPLETION_ENDPOINT": "https://completion.example.com/v1",
|
||||
"IMAGE_GEN_ENDPOINT": "https://image.example.com/v1",
|
||||
"IMAGE_EDIT_ENDPOINT": "https://image-edit.example.com/v1",
|
||||
"EMBEDDING_ENDPOINT": "https://embedding.example.com/v1",
|
||||
"CHAT_MODEL": "test-chat-model",
|
||||
"COMPLETION_MODEL": "",
|
||||
"IMAGE_GEN_MODEL": "test-image-model",
|
||||
"IMAGE_EDIT_MODEL": "test-image-edit-model",
|
||||
"EMBEDDING_MODEL": "test-embedding-model",
|
||||
}
|
||||
_run_config_check(env, "COMPLETION_MODEL required")
|
||||
|
||||
|
||||
def test_config_missing_image_gen_model() -> None:
|
||||
"""Test that RuntimeError is raised when IMAGE_GEN_MODEL is missing."""
|
||||
env: dict[str, str] = {
|
||||
"DISCORD_TOKEN": "test-token",
|
||||
"CHAT_ENDPOINT": "https://chat.example.com/v1",
|
||||
"COMPLETION_ENDPOINT": "https://completion.example.com/v1",
|
||||
"IMAGE_GEN_ENDPOINT": "https://image.example.com/v1",
|
||||
"IMAGE_EDIT_ENDPOINT": "https://image-edit.example.com/v1",
|
||||
"EMBEDDING_ENDPOINT": "https://embedding.example.com/v1",
|
||||
"CHAT_MODEL": "test-chat-model",
|
||||
"COMPLETION_MODEL": "test-completion-model",
|
||||
"IMAGE_GEN_MODEL": "",
|
||||
"IMAGE_EDIT_MODEL": "test-image-edit-model",
|
||||
"EMBEDDING_MODEL": "test-embedding-model",
|
||||
@@ -278,12 +227,10 @@ def test_config_missing_image_edit_model() -> None:
|
||||
env: dict[str, str] = {
|
||||
"DISCORD_TOKEN": "test-token",
|
||||
"CHAT_ENDPOINT": "https://chat.example.com/v1",
|
||||
"COMPLETION_ENDPOINT": "https://completion.example.com/v1",
|
||||
"IMAGE_GEN_ENDPOINT": "https://image.example.com/v1",
|
||||
"IMAGE_EDIT_ENDPOINT": "https://image-edit.example.com/v1",
|
||||
"EMBEDDING_ENDPOINT": "https://embedding.example.com/v1",
|
||||
"CHAT_MODEL": "test-chat-model",
|
||||
"COMPLETION_MODEL": "test-completion-model",
|
||||
"IMAGE_GEN_MODEL": "test-image-model",
|
||||
"IMAGE_EDIT_MODEL": "",
|
||||
"EMBEDDING_MODEL": "test-embedding-model",
|
||||
@@ -296,12 +243,10 @@ def test_config_missing_embedding_model() -> None:
|
||||
env: dict[str, str] = {
|
||||
"DISCORD_TOKEN": "test-token",
|
||||
"CHAT_ENDPOINT": "https://chat.example.com/v1",
|
||||
"COMPLETION_ENDPOINT": "https://completion.example.com/v1",
|
||||
"IMAGE_GEN_ENDPOINT": "https://image.example.com/v1",
|
||||
"IMAGE_EDIT_ENDPOINT": "https://image-edit.example.com/v1",
|
||||
"EMBEDDING_ENDPOINT": "https://embedding.example.com/v1",
|
||||
"CHAT_MODEL": "test-chat-model",
|
||||
"COMPLETION_MODEL": "test-completion-model",
|
||||
"IMAGE_GEN_MODEL": "test-image-model",
|
||||
"IMAGE_EDIT_MODEL": "test-image-edit-model",
|
||||
"EMBEDDING_MODEL": "",
|
||||
@@ -309,16 +254,64 @@ def test_config_missing_embedding_model() -> None:
|
||||
_run_config_check(env, "EMBEDDING_MODEL required")
|
||||
|
||||
|
||||
REQUIRED_VARS = (
|
||||
"DISCORD_TOKEN",
|
||||
"CHAT_ENDPOINT",
|
||||
"IMAGE_GEN_ENDPOINT",
|
||||
"IMAGE_EDIT_ENDPOINT",
|
||||
"EMBEDDING_ENDPOINT",
|
||||
"CHAT_MODEL",
|
||||
"IMAGE_GEN_MODEL",
|
||||
"IMAGE_EDIT_MODEL",
|
||||
"EMBEDDING_MODEL",
|
||||
)
|
||||
|
||||
|
||||
def test_validate_config_passes_with_full_env() -> None:
|
||||
"""With all required settings present, validate_config() is a no-op."""
|
||||
from vibe_bot import config
|
||||
|
||||
config.validate_config()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("var_name", REQUIRED_VARS)
|
||||
def test_validate_config_missing_var(var_name: str) -> None:
|
||||
"""Blanking any single required setting makes validate_config() raise."""
|
||||
from vibe_bot import config
|
||||
|
||||
with (
|
||||
patch.object(config, var_name, ""),
|
||||
pytest.raises(RuntimeError, match=f"{var_name} required"),
|
||||
):
|
||||
config.validate_config()
|
||||
|
||||
|
||||
def test_import_config_empty_env_no_raise_no_logging() -> None:
|
||||
"""In an empty env the import succeeds and leaves the root logger unconfigured."""
|
||||
code = """
|
||||
import logging
|
||||
import os
|
||||
os.environ.clear()
|
||||
os.environ["PATH"] = "/usr/bin:/bin"
|
||||
import vibe_bot.config
|
||||
handlers = logging.getLogger().handlers
|
||||
assert handlers == [], f"config configured logging: {handlers}"
|
||||
print("OK")
|
||||
"""
|
||||
|
||||
result = subprocess.run( # noqa: PLW1510
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, f"Subprocess failed: {result.stderr}"
|
||||
assert "OK" in result.stdout
|
||||
|
||||
|
||||
def test_config_logging_exists() -> None:
|
||||
"""Test that logging is configured in config module."""
|
||||
from vibe_bot.config import logger
|
||||
|
||||
assert logger is not None
|
||||
assert logger.name == "vibe_bot.config"
|
||||
|
||||
|
||||
def test_config_embedding_dimension() -> None:
|
||||
"""Test that EMBEDDING_DIMENSION has expected default value."""
|
||||
from vibe_bot.config import EMBEDDING_DIMENSION
|
||||
|
||||
assert EMBEDDING_DIMENSION == 2048
|
||||
|
||||
+774
-44
@@ -2,17 +2,40 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
from vibe_bot.database import ChatDatabase
|
||||
|
||||
|
||||
def _recent_messages(
|
||||
db_path: str,
|
||||
limit: int,
|
||||
) -> list[tuple[str, str, str, datetime]]:
|
||||
"""Read the newest rows straight from the database (test-side helper)."""
|
||||
from vibe_bot.db.connection import connect
|
||||
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT message_id, username, content, timestamp "
|
||||
"FROM chat_messages ORDER BY timestamp DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [
|
||||
(str(row[0]), str(row[1]), str(row[2]), cast(datetime, row[3])) for row in rows
|
||||
]
|
||||
|
||||
|
||||
def test_vector_to_bytes(chat_db: ChatDatabase) -> None:
|
||||
"""Test converting a vector to bytes and back."""
|
||||
vector: list[float] = [0.1, 0.2, 0.3, 0.4]
|
||||
@@ -55,6 +78,13 @@ def test_calculate_similarity_negative(chat_db: ChatDatabase) -> None:
|
||||
assert similarity == pytest.approx(-1.0, abs=1e-6)
|
||||
|
||||
|
||||
def test_calculate_similarity_zero_norm(chat_db: ChatDatabase) -> None:
|
||||
"""A zero vector has no direction, so its similarity is 0."""
|
||||
zero = np.zeros(3, dtype=np.float32)
|
||||
other = np.array([1.0, 0.0, 0.0], dtype=np.float32)
|
||||
assert chat_db._calculate_similarity(zero, other) == 0.0
|
||||
|
||||
|
||||
def test_add_message(chat_db: ChatDatabase, mock_embedding: MagicMock) -> None:
|
||||
"""Test adding a message to the database."""
|
||||
result = chat_db.add_message(
|
||||
@@ -67,7 +97,7 @@ def test_add_message(chat_db: ChatDatabase, mock_embedding: MagicMock) -> None:
|
||||
)
|
||||
assert result is True
|
||||
|
||||
messages = chat_db.get_recent_messages(limit=10)
|
||||
messages = _recent_messages(chat_db.db_path, 10)
|
||||
assert len(messages) == 1
|
||||
assert messages[0][0] == "msg-1"
|
||||
assert messages[0][1] == "testuser"
|
||||
@@ -76,7 +106,7 @@ def test_add_message(chat_db: ChatDatabase, mock_embedding: MagicMock) -> None:
|
||||
|
||||
def test_add_message_no_embedding(chat_db: ChatDatabase) -> None:
|
||||
"""Test adding a message when embedding generation fails."""
|
||||
with patch("vibe_bot.llama_wrapper.embedding", return_value=None):
|
||||
with patch("vibe_bot.llm_client.embedding", return_value=None):
|
||||
result = chat_db.add_message(
|
||||
message_id="msg-no-embed",
|
||||
user_id="user-1",
|
||||
@@ -106,14 +136,14 @@ def test_add_message_duplicate(
|
||||
content="Second content",
|
||||
)
|
||||
|
||||
messages = chat_db.get_recent_messages(limit=10)
|
||||
messages = _recent_messages(chat_db.db_path, 10)
|
||||
assert len(messages) == 1
|
||||
assert messages[0][2] == "Second content"
|
||||
|
||||
|
||||
def test_add_message_failure(chat_db: ChatDatabase) -> None:
|
||||
"""Test that add_message returns False on database error."""
|
||||
with patch.object(chat_db, "_vector_to_bytes", side_effect=Exception("fail")):
|
||||
with patch("vibe_bot.db.messages.connect", return_value=_broken_connection()):
|
||||
result = chat_db.add_message(
|
||||
message_id="msg-fail",
|
||||
user_id="user-1",
|
||||
@@ -123,11 +153,304 @@ def test_add_message_failure(chat_db: ChatDatabase) -> None:
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_get_recent_messages(
|
||||
def _embedding_row_count(db_path: str) -> int:
|
||||
"""Count the rows stored in message_embeddings."""
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
count = conn.execute("SELECT COUNT(*) FROM message_embeddings").fetchone()[0]
|
||||
conn.close()
|
||||
return int(count)
|
||||
|
||||
|
||||
def test_add_message_embed_false_skips_embedding(chat_db: ChatDatabase) -> None:
|
||||
"""embed=False neither calls the embedding API nor stores a row."""
|
||||
with patch("vibe_bot.llm_client.embedding") as mock_embedding:
|
||||
result = chat_db.add_message(
|
||||
message_id="msg-assist",
|
||||
user_id="bot-1",
|
||||
username="some-bot",
|
||||
content="assistant reply",
|
||||
role="assistant",
|
||||
embed=False,
|
||||
)
|
||||
assert result is True
|
||||
mock_embedding.assert_not_called()
|
||||
assert _embedding_row_count(chat_db.db_path) == 0
|
||||
|
||||
|
||||
def test_add_message_stores_embedding_by_default(
|
||||
chat_db: ChatDatabase,
|
||||
mock_embedding: MagicMock,
|
||||
) -> None:
|
||||
"""Test retrieving recent messages."""
|
||||
"""The default embed=True still stores exactly one embedding row."""
|
||||
assert chat_db.add_message(
|
||||
message_id="msg-embed",
|
||||
user_id="user-1",
|
||||
username="testuser",
|
||||
content="default embed",
|
||||
)
|
||||
assert _embedding_row_count(chat_db.db_path) == 1
|
||||
|
||||
|
||||
def test_add_message_stores_embedding_norm(chat_db: ChatDatabase) -> None:
|
||||
"""add_message stores the L2 norm of the float32-encoded embedding."""
|
||||
import sqlite3
|
||||
|
||||
vector: list[float] = [0.6, 0.8]
|
||||
with patch("vibe_bot.llm_client.embedding", return_value=vector):
|
||||
assert chat_db.add_message(
|
||||
message_id="norm-1",
|
||||
user_id="u1",
|
||||
username="alice",
|
||||
content="normed message",
|
||||
)
|
||||
|
||||
conn = sqlite3.connect(chat_db.db_path)
|
||||
blob, norm = conn.execute(
|
||||
"SELECT embedding, norm FROM message_embeddings WHERE message_id = 'norm-1'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
stored = np.frombuffer(blob, dtype=np.float32)
|
||||
assert float(norm) == pytest.approx(float(np.linalg.norm(stored)), abs=1e-6)
|
||||
|
||||
|
||||
def test_cleanup_old_messages_no_orphaned_embeddings(chat_db: ChatDatabase) -> None:
|
||||
"""Deleting the oldest rows must delete their embeddings, not the next ones.
|
||||
|
||||
Regression test for the bug where the embedding cleanup re-queried
|
||||
``chat_messages`` *after* the message delete, so it stripped embeddings
|
||||
from the next-oldest live rows while leaving the deleted rows' embeddings
|
||||
behind as orphans.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
with patch("vibe_bot.db.messages.MAX_HISTORY_MESSAGES", 5):
|
||||
# Seed 7 rows with distinct ascending timestamps and an embedding for
|
||||
# each, so the "oldest" ordering is deterministic.
|
||||
conn = sqlite3.connect(chat_db.db_path)
|
||||
cursor = conn.cursor()
|
||||
for i in range(1, 8):
|
||||
ts = f"2024-01-0{i} 00:00:00"
|
||||
cursor.execute(
|
||||
"INSERT INTO chat_messages "
|
||||
"(message_id, user_id, username, content, bot_name, timestamp) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(f"m{i}", "u1", "alice", f"content {i}", "bot", ts),
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO message_embeddings (message_id, embedding) "
|
||||
"VALUES (?, ?)",
|
||||
(f"m{i}", chat_db._vector_to_bytes([0.1, 0.2, 0.3])),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# The 8th insert pushes the count to 8 (> 5), so add_message's
|
||||
# cleanup must delete exactly the 3 oldest rows and their embeddings.
|
||||
assert chat_db.add_message(
|
||||
message_id="m8",
|
||||
user_id="u1",
|
||||
username="alice",
|
||||
content="content 8",
|
||||
)
|
||||
|
||||
conn = sqlite3.connect(chat_db.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT message_id FROM chat_messages")
|
||||
live = {row[0] for row in cursor.fetchall()}
|
||||
cursor.execute("SELECT message_id FROM message_embeddings")
|
||||
embedded = {row[0] for row in cursor.fetchall()}
|
||||
conn.close()
|
||||
|
||||
# The three oldest rows are gone; the rest (incl. the new one) remain.
|
||||
assert live == {"m4", "m5", "m6", "m7", "m8"}
|
||||
# No orphaned embeddings and no stripped survivors: the embedding set
|
||||
# exactly matches the live message rows.
|
||||
assert embedded == live
|
||||
|
||||
|
||||
def test_role_scopes_history_and_search(chat_db: ChatDatabase) -> None:
|
||||
"""get_user_history excludes responses; search matches only user rows."""
|
||||
chat_db.add_message(
|
||||
message_id="r-1",
|
||||
user_id="u1",
|
||||
username="alice",
|
||||
content="User asks about the weather",
|
||||
role="user",
|
||||
)
|
||||
chat_db.add_message(
|
||||
message_id="r-1_response",
|
||||
user_id="bot",
|
||||
username="some-bot",
|
||||
content="Bot answers the weather",
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
# get_user_history returns only the user row (paired with its response).
|
||||
conversations = chat_db.get_user_history("u1")
|
||||
assert len(conversations) == 1
|
||||
assert conversations[0][0] == "User asks about the weather"
|
||||
assert conversations[0][1] == "Bot answers the weather"
|
||||
|
||||
# search_similar_messages only considers user rows, never responses.
|
||||
results = chat_db.search_similar_messages(
|
||||
"User asks about the weather", top_k=5, min_similarity=0.0
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0][0] == "User asks about the weather"
|
||||
assert results[0][1] == "Bot answers the weather"
|
||||
|
||||
|
||||
def test_role_migration_backfills_legacy_rows(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""Legacy rows (no role column) are backfilled when ChatDatabase inits."""
|
||||
import sqlite3
|
||||
|
||||
from vibe_bot.database import ChatDatabase
|
||||
|
||||
# Create the legacy schema (no role column) with a user row and its
|
||||
# response row inserted directly.
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"CREATE TABLE chat_messages ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
"message_id TEXT UNIQUE, user_id TEXT, username TEXT, content TEXT,"
|
||||
"bot_name TEXT, timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
|
||||
"channel_id TEXT, guild_id TEXT)"
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO chat_messages (message_id, user_id, username, content) "
|
||||
"VALUES ('legacy-1', 'u1', 'alice', 'old question')"
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO chat_messages (message_id, user_id, username, content) "
|
||||
"VALUES ('legacy-1_response', 'bot', 'old-bot', 'old answer')"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Initializing ChatDatabase should add and backfill the role column.
|
||||
ChatDatabase(db_path=temp_db_path)
|
||||
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT message_id, role FROM chat_messages ORDER BY message_id")
|
||||
roles = {row[0]: row[1] for row in cursor.fetchall()}
|
||||
conn.close()
|
||||
|
||||
assert roles["legacy-1"] == "user"
|
||||
assert roles["legacy-1_response"] == "assistant"
|
||||
|
||||
|
||||
def test_bot_name_migration_adds_column(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A pre-bot_name schema gets the column added when ChatDatabase inits."""
|
||||
import sqlite3
|
||||
|
||||
from vibe_bot.database import ChatDatabase
|
||||
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
conn.execute(
|
||||
"CREATE TABLE chat_messages ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
"message_id TEXT UNIQUE, user_id TEXT, username TEXT, content TEXT,"
|
||||
"timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
|
||||
"channel_id TEXT, guild_id TEXT, role TEXT)"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
ChatDatabase(db_path=temp_db_path)
|
||||
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA table_info(chat_messages)")
|
||||
columns = {row[1] for row in cursor.fetchall()}
|
||||
conn.close()
|
||||
|
||||
assert "bot_name" in columns
|
||||
|
||||
|
||||
def _seed_pre_norm_db(db_path: str) -> list[tuple[str, str, list[float]]]:
|
||||
"""Create a pre-norm-schema database (no norm column) with seeded rows."""
|
||||
import sqlite3
|
||||
|
||||
rows: list[tuple[str, str, list[float]]] = [
|
||||
("n-1", "ask about the sky", [0.9, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
("n-2", "ask about the sea", [0.7, 0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
("n-3", "ask about the sun", [0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
("n-4", "ask about the sand", [0.2, 0.8, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
("n-5", "ask about nothing", [0.0] * 8),
|
||||
]
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"CREATE TABLE chat_messages ("
|
||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
"message_id TEXT UNIQUE, user_id TEXT, username TEXT, content TEXT,"
|
||||
"bot_name TEXT, timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
|
||||
"channel_id TEXT, guild_id TEXT, role TEXT)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE TABLE message_embeddings ("
|
||||
"message_id TEXT PRIMARY KEY, embedding BLOB,"
|
||||
"FOREIGN KEY (message_id) REFERENCES chat_messages(message_id))"
|
||||
)
|
||||
for i, (message_id, content, vector) in enumerate(rows):
|
||||
cursor.execute(
|
||||
"INSERT INTO chat_messages "
|
||||
"(message_id, user_id, username, content, role, timestamp) "
|
||||
"VALUES (?, 'u1', 'alice', ?, 'user', ?)",
|
||||
(message_id, content, f"2024-01-0{i + 1} 00:00:00"),
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO chat_messages "
|
||||
"(message_id, user_id, username, content, role, timestamp) "
|
||||
"VALUES (?, 'bot-1', 'some-bot', ?, 'assistant', ?)",
|
||||
(
|
||||
f"{message_id}_response",
|
||||
f"response {i + 1}",
|
||||
f"2024-01-0{i + 1} 00:00:01",
|
||||
),
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO message_embeddings (message_id, embedding) VALUES (?, ?)",
|
||||
(message_id, np.array(vector, dtype=np.float32).tobytes()),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
|
||||
def test_norm_migration_backfills_stored_norms(temp_db_path: str) -> None:
|
||||
"""A pre-norm database gets the norm column added and backfilled on init."""
|
||||
import sqlite3
|
||||
|
||||
from vibe_bot.database import ChatDatabase
|
||||
|
||||
rows = _seed_pre_norm_db(temp_db_path)
|
||||
ChatDatabase(db_path=temp_db_path)
|
||||
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
stored = dict(conn.execute("SELECT message_id, norm FROM message_embeddings"))
|
||||
conn.close()
|
||||
|
||||
for message_id, _content, vector in rows:
|
||||
expected = float(np.linalg.norm(np.array(vector, dtype=np.float32)))
|
||||
assert stored[message_id] == pytest.approx(expected, abs=1e-5)
|
||||
|
||||
|
||||
def test_recent_messages_desc_order(
|
||||
chat_db: ChatDatabase,
|
||||
mock_embedding: MagicMock,
|
||||
) -> None:
|
||||
"""Newest-first ordering of stored messages."""
|
||||
chat_db.add_message(
|
||||
message_id="msg-1",
|
||||
user_id="u1",
|
||||
@@ -147,17 +470,17 @@ def test_get_recent_messages(
|
||||
content="Third",
|
||||
)
|
||||
|
||||
messages = chat_db.get_recent_messages(limit=2)
|
||||
messages = _recent_messages(chat_db.db_path, 2)
|
||||
assert len(messages) == 2
|
||||
assert messages[0][2] == "Third"
|
||||
assert messages[1][2] == "Second"
|
||||
|
||||
|
||||
def test_get_recent_messages_limit(
|
||||
def test_recent_messages_limit(
|
||||
chat_db: ChatDatabase,
|
||||
mock_embedding: MagicMock,
|
||||
) -> None:
|
||||
"""Test that get_recent_messages respects the limit."""
|
||||
"""The newest-rows query respects the limit."""
|
||||
for i in range(5):
|
||||
chat_db.add_message(
|
||||
message_id=f"msg-{i}",
|
||||
@@ -166,10 +489,25 @@ def test_get_recent_messages_limit(
|
||||
content=f"Message {i}",
|
||||
)
|
||||
|
||||
messages = chat_db.get_recent_messages(limit=3)
|
||||
messages = _recent_messages(chat_db.db_path, 3)
|
||||
assert len(messages) == 3
|
||||
|
||||
|
||||
def test_recent_messages_returns_datetime(
|
||||
chat_db: ChatDatabase,
|
||||
mock_embedding: MagicMock,
|
||||
) -> None:
|
||||
"""The timestamp column comes back as a real datetime, not a string."""
|
||||
chat_db.add_message(
|
||||
message_id="dt-1",
|
||||
user_id="u1",
|
||||
username="alice",
|
||||
content="fresh message",
|
||||
)
|
||||
messages = _recent_messages(chat_db.db_path, 1)
|
||||
assert isinstance(messages[0][3], datetime)
|
||||
|
||||
|
||||
def test_clear_all_messages(
|
||||
chat_db: ChatDatabase,
|
||||
mock_embedding: MagicMock,
|
||||
@@ -190,7 +528,7 @@ def test_clear_all_messages(
|
||||
|
||||
chat_db.clear_all_messages()
|
||||
|
||||
messages = chat_db.get_recent_messages(limit=10)
|
||||
messages = _recent_messages(chat_db.db_path, 10)
|
||||
assert len(messages) == 0
|
||||
|
||||
|
||||
@@ -264,6 +602,25 @@ def test_image_generation_estimate_after_capping(
|
||||
assert estimate == pytest.approx(99.5)
|
||||
|
||||
|
||||
def _broken_connection() -> MagicMock:
|
||||
"""A mock connection whose first cursor.execute raises."""
|
||||
fake_conn = MagicMock()
|
||||
fake_conn.cursor.return_value.execute.side_effect = Exception("db error")
|
||||
return fake_conn
|
||||
|
||||
|
||||
def test_record_image_generation_time_failure(chat_db: ChatDatabase) -> None:
|
||||
"""A database error while recording yields False, not an exception."""
|
||||
with patch("vibe_bot.db.timing.connect", return_value=_broken_connection()):
|
||||
assert chat_db.record_image_generation_time(1.0) is False
|
||||
|
||||
|
||||
def test_image_generation_estimate_failure(chat_db: ChatDatabase) -> None:
|
||||
"""A database error while reading yields None, not an exception."""
|
||||
with patch("vibe_bot.db.timing.connect", return_value=_broken_connection()):
|
||||
assert chat_db.get_image_generation_time_estimate() is None
|
||||
|
||||
|
||||
def test_get_user_history(
|
||||
chat_db: ChatDatabase,
|
||||
mock_embedding: MagicMock,
|
||||
@@ -320,6 +677,39 @@ def test_get_user_history_excludes_bot(
|
||||
assert len(conversations) == 0
|
||||
|
||||
|
||||
def test_get_bot_history(
|
||||
chat_db: ChatDatabase,
|
||||
mock_embedding: MagicMock,
|
||||
) -> None:
|
||||
"""get_bot_history pairs user messages with responses for one bot."""
|
||||
chat_db.add_message(
|
||||
message_id="bh-1",
|
||||
user_id="u1",
|
||||
username="alice",
|
||||
content="bot question",
|
||||
bot_name="alfred",
|
||||
)
|
||||
chat_db.add_message(
|
||||
message_id="bh-1_response",
|
||||
user_id="bot-1",
|
||||
username="some-bot",
|
||||
content="bot answer",
|
||||
bot_name="alfred",
|
||||
role="assistant",
|
||||
embed=False,
|
||||
)
|
||||
chat_db.add_message(
|
||||
message_id="bh-2",
|
||||
user_id="u1",
|
||||
username="alice",
|
||||
content="unanswered question",
|
||||
bot_name="alfred",
|
||||
)
|
||||
|
||||
history = chat_db.get_bot_history("alfred")
|
||||
assert history == [("bot question", "bot answer")]
|
||||
|
||||
|
||||
def test_get_conversation_context(
|
||||
chat_db: ChatDatabase,
|
||||
mock_embedding: MagicMock,
|
||||
@@ -349,21 +739,333 @@ def test_get_conversation_context_empty(chat_db: ChatDatabase) -> None:
|
||||
assert context == []
|
||||
|
||||
|
||||
def _query_vector() -> list[float]:
|
||||
"""A 8-dim query vector along the first axis."""
|
||||
return [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
|
||||
|
||||
|
||||
def _seed_search_rows(chat_db: ChatDatabase) -> list[tuple[str, str, list[float]]]:
|
||||
"""Seed user/response rows with known embeddings (plus exclusion traps)."""
|
||||
import sqlite3
|
||||
|
||||
rows: list[tuple[str, str, list[float]]] = [
|
||||
("s-1", "ask about the sky", [0.9, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
("s-2", "ask about the sea", [0.7, 0.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
("s-3", "ask about the sun", [0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
("s-4", "ask about the sand", [0.2, 0.8, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
|
||||
]
|
||||
|
||||
def stored_norm(vector: list[float]) -> float:
|
||||
return float(np.linalg.norm(np.array(vector, dtype=np.float32)))
|
||||
|
||||
conn = sqlite3.connect(chat_db.db_path)
|
||||
cursor = conn.cursor()
|
||||
for i, (message_id, content, vector) in enumerate(rows):
|
||||
timestamp = f"2024-01-0{i + 1} 00:00:00"
|
||||
cursor.execute(
|
||||
"INSERT INTO chat_messages "
|
||||
"(message_id, user_id, username, content, role, timestamp) "
|
||||
"VALUES (?, ?, ?, ?, 'user', ?)",
|
||||
(message_id, "u1", "alice", content, timestamp),
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO chat_messages "
|
||||
"(message_id, user_id, username, content, role, timestamp) "
|
||||
"VALUES (?, ?, ?, ?, 'assistant', ?)",
|
||||
(
|
||||
f"{message_id}_response",
|
||||
"bot-1",
|
||||
"some-bot",
|
||||
f"response {i + 1}",
|
||||
f"2024-01-0{i + 1} 00:00:01",
|
||||
),
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO message_embeddings (message_id, embedding, norm) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(message_id, chat_db._vector_to_bytes(vector), stored_norm(vector)),
|
||||
)
|
||||
|
||||
# A user row with the top possible similarity but no response row: the
|
||||
# JOIN must exclude it instead of returning a NULL response.
|
||||
cursor.execute(
|
||||
"INSERT INTO chat_messages "
|
||||
"(message_id, user_id, username, content, role, timestamp) "
|
||||
"VALUES ('s-5', 'u1', 'alice', 'orphan question', 'user', "
|
||||
"'2024-01-05 00:00:00')",
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO message_embeddings (message_id, embedding, norm) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(
|
||||
"s-5",
|
||||
chat_db._vector_to_bytes(_query_vector()),
|
||||
stored_norm(_query_vector()),
|
||||
),
|
||||
)
|
||||
|
||||
# An assistant row carrying an embedding: the role filter must skip it.
|
||||
cursor.execute(
|
||||
"INSERT INTO chat_messages "
|
||||
"(message_id, user_id, username, content, role, timestamp) "
|
||||
"VALUES ('s-6', 'bot-1', 'some-bot', 'assistant noise', 'assistant', "
|
||||
"'2024-01-06 00:00:00')",
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO message_embeddings (message_id, embedding, norm) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(
|
||||
"s-6",
|
||||
chat_db._vector_to_bytes(_query_vector()),
|
||||
stored_norm(_query_vector()),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def test_search_matches_reference_topk_and_ordering(
|
||||
chat_db: ChatDatabase,
|
||||
) -> None:
|
||||
"""The JOINed search matches the per-row reference: same top-k and order."""
|
||||
rows = _seed_search_rows(chat_db)
|
||||
query = _query_vector()
|
||||
|
||||
expected: list[tuple[str, str, float]] = []
|
||||
query_arr = np.array(query, dtype=np.float32)
|
||||
for i, (_message_id, content, vector) in enumerate(rows):
|
||||
stored = np.frombuffer(chat_db._vector_to_bytes(vector), dtype=np.float32)
|
||||
similarity = float(
|
||||
np.dot(query_arr, stored)
|
||||
/ (np.linalg.norm(query_arr) * np.linalg.norm(stored))
|
||||
)
|
||||
expected.append((content, f"response {i + 1}", similarity))
|
||||
expected.sort(key=lambda item: item[2], reverse=True)
|
||||
expected_top = expected[:3]
|
||||
|
||||
with patch("vibe_bot.llm_client.embedding", return_value=query):
|
||||
results = chat_db.search_similar_messages(
|
||||
"query text", top_k=3, min_similarity=0.0
|
||||
)
|
||||
|
||||
assert len(results) == 3
|
||||
for (content, response, actual), (_ec, er, reference) in zip(
|
||||
results, expected_top, strict=True
|
||||
):
|
||||
assert content == _ec
|
||||
assert response == er
|
||||
assert actual == pytest.approx(reference, abs=1e-5)
|
||||
|
||||
|
||||
def test_search_with_stored_norms_matches_pre_norm_reference(
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""Stored-norm search is identical to per-vector renormalization.
|
||||
|
||||
Seeds a pre-norm database, migrates it, then asserts the stored-norm
|
||||
search (top-k + ordering + similarities) matches a reference that
|
||||
renormalizes every candidate vector inline — the pre-optimization
|
||||
algorithm.
|
||||
"""
|
||||
from vibe_bot.database import ChatDatabase
|
||||
|
||||
rows = _seed_pre_norm_db(temp_db_path)
|
||||
db = ChatDatabase(db_path=temp_db_path)
|
||||
|
||||
query = _query_vector()
|
||||
with patch("vibe_bot.llm_client.embedding", return_value=query):
|
||||
results = db.search_similar_messages("query text", top_k=3, min_similarity=0.0)
|
||||
|
||||
query_arr = np.array(query, dtype=np.float32)
|
||||
query_norm = float(np.linalg.norm(query_arr))
|
||||
expected: list[tuple[str, str, float]] = []
|
||||
for i, (_message_id, content, vector) in enumerate(rows):
|
||||
stored = np.array(vector, dtype=np.float32)
|
||||
stored_norm = float(np.linalg.norm(stored))
|
||||
similarity = (
|
||||
0.0
|
||||
if stored_norm == 0
|
||||
else float(np.dot(query_arr, stored) / (query_norm * stored_norm))
|
||||
)
|
||||
expected.append((content, f"response {i + 1}", similarity))
|
||||
expected.sort(key=lambda item: item[2], reverse=True)
|
||||
|
||||
assert len(results) == 3
|
||||
for (content, response, actual), (ec, er, reference) in zip(
|
||||
results, expected[:3], strict=True
|
||||
):
|
||||
assert content == ec
|
||||
assert response == er
|
||||
assert actual == pytest.approx(reference, abs=1e-6)
|
||||
|
||||
|
||||
def test_search_null_norm_row_scores_zero(temp_db_path: str) -> None:
|
||||
"""A row whose norm was never written scores 0 instead of crashing."""
|
||||
import sqlite3
|
||||
|
||||
from vibe_bot.database import ChatDatabase
|
||||
|
||||
_rows = _seed_pre_norm_db(temp_db_path)
|
||||
db = ChatDatabase(db_path=temp_db_path)
|
||||
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
conn.execute("UPDATE message_embeddings SET norm = NULL WHERE message_id = 'n-1'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
query = _query_vector()
|
||||
with patch("vibe_bot.llm_client.embedding", return_value=query):
|
||||
results = db.search_similar_messages("query text", top_k=10, min_similarity=0.0)
|
||||
|
||||
scores = {content: similarity for content, _response, similarity in results}
|
||||
assert scores["ask about the sky"] == 0.0
|
||||
# All four scored rows plus the zero-vector row (0.0 passes min_similarity=0.0).
|
||||
assert len(results) == 5
|
||||
|
||||
|
||||
def test_search_mixed_embedding_dims_falls_back_to_per_row(temp_db_path: str) -> None:
|
||||
"""Rows with different blob lengths (mid-life model change) don't crash."""
|
||||
import sqlite3
|
||||
|
||||
from vibe_bot.database import ChatDatabase
|
||||
|
||||
wide = [0.9, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
|
||||
narrow = [0.5, 0.5, 0.0, 0.0]
|
||||
|
||||
db = ChatDatabase(db_path=temp_db_path)
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
cursor = conn.cursor()
|
||||
for message_id, content, vector in (
|
||||
("m-1", "wide question", wide),
|
||||
("m-2", "narrow question", narrow),
|
||||
):
|
||||
blob = np.array(vector, dtype=np.float32).tobytes()
|
||||
stored_norm = float(np.linalg.norm(np.frombuffer(blob, dtype=np.float32)))
|
||||
cursor.execute(
|
||||
"INSERT INTO chat_messages "
|
||||
"(message_id, user_id, username, content, role, timestamp) "
|
||||
"VALUES (?, 'u1', 'alice', ?, 'user', '2024-01-01 00:00:00')",
|
||||
(message_id, content),
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO chat_messages "
|
||||
"(message_id, user_id, username, content, role, timestamp) "
|
||||
"VALUES (?, 'bot-1', 'some-bot', ?, 'assistant', '2024-01-01 00:00:01')",
|
||||
(f"{message_id}_response", f"{message_id} answer"),
|
||||
)
|
||||
cursor.execute(
|
||||
"INSERT INTO message_embeddings (message_id, embedding, norm) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(message_id, blob, stored_norm),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
query = _query_vector()
|
||||
with patch("vibe_bot.llm_client.embedding", return_value=query):
|
||||
results = db.search_similar_messages("query text", top_k=10, min_similarity=0.0)
|
||||
|
||||
scores = {content: similarity for content, _response, similarity in results}
|
||||
# Both rows are returned: the 8-dim row scores exactly, the 4-dim row is
|
||||
# zero-padded to the query dim and still scores sanely.
|
||||
assert set(scores) == {"wide question", "narrow question"}
|
||||
wide_norm = float(np.linalg.norm(np.array(wide, dtype=np.float32)))
|
||||
narrow_norm = float(np.linalg.norm(np.array(narrow, dtype=np.float32)))
|
||||
assert scores["wide question"] == pytest.approx(0.9 / wide_norm, abs=1e-5)
|
||||
assert scores["narrow question"] == pytest.approx(0.5 / narrow_norm, abs=1e-5)
|
||||
assert scores["wide question"] > scores["narrow question"]
|
||||
|
||||
|
||||
def test_search_issues_single_select_per_call(chat_db: ChatDatabase) -> None:
|
||||
"""search_similar_messages issues exactly one SELECT per call (no N+1)."""
|
||||
import vibe_bot.db.search as db_search
|
||||
from vibe_bot.db.connection import connect as realconnect
|
||||
|
||||
chat_db.add_message(
|
||||
message_id="n-1",
|
||||
user_id="u1",
|
||||
username="alice",
|
||||
content="one question",
|
||||
)
|
||||
chat_db.add_message(
|
||||
message_id="n-1_response",
|
||||
user_id="bot-1",
|
||||
username="some-bot",
|
||||
content="one answer",
|
||||
role="assistant",
|
||||
embed=False,
|
||||
)
|
||||
|
||||
select_statements: list[str] = []
|
||||
|
||||
class _TracingCursor:
|
||||
def __init__(self, cursor: sqlite3.Cursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def execute(self, sql: str, *args: Any) -> Any:
|
||||
if sql.strip().upper().startswith("SELECT"):
|
||||
select_statements.append(sql)
|
||||
return self._cursor.execute(sql, *args)
|
||||
|
||||
def fetchall(self) -> Any:
|
||||
return self._cursor.fetchall()
|
||||
|
||||
def fetchone(self) -> Any:
|
||||
return self._cursor.fetchone()
|
||||
|
||||
class _TracingConnection:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def cursor(self) -> _TracingCursor:
|
||||
return _TracingCursor(self._conn.cursor())
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
def tracingconnect(db_path: str) -> _TracingConnection:
|
||||
return _TracingConnection(realconnect(db_path))
|
||||
|
||||
with patch.object(db_search, "connect", side_effect=tracingconnect):
|
||||
results = chat_db.search_similar_messages(
|
||||
"one question", top_k=5, min_similarity=0.0
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0][0] == "one question"
|
||||
assert results[0][1] == "one answer"
|
||||
assert len(select_statements) == 1
|
||||
|
||||
|
||||
def test_search_empty_query_embedding_returns_empty(chat_db: ChatDatabase) -> None:
|
||||
"""A failed query embedding yields no results."""
|
||||
with patch("vibe_bot.llm_client.embedding", return_value=[]):
|
||||
assert chat_db.search_similar_messages("anything") == []
|
||||
|
||||
|
||||
def test_search_zero_query_vector_returns_empty(chat_db: ChatDatabase) -> None:
|
||||
"""A zero-norm query vector yields no results (no division by zero)."""
|
||||
with patch("vibe_bot.llm_client.embedding", return_value=[0.0] * 8):
|
||||
assert chat_db.search_similar_messages("anything") == []
|
||||
|
||||
|
||||
def test_custom_bot_create(custom_bot_manager: Any) -> None:
|
||||
"""Test creating a custom bot."""
|
||||
"""Test creating a custom bot returns "created" for a new name."""
|
||||
result = custom_bot_manager.create_custom_bot(
|
||||
bot_name="alfred",
|
||||
system_prompt="You are a british butler",
|
||||
created_by="user-123",
|
||||
)
|
||||
assert result is True
|
||||
assert result == "created"
|
||||
|
||||
|
||||
def test_custom_bot_create_duplicate(
|
||||
custom_bot_manager: Any,
|
||||
) -> None:
|
||||
"""Test creating a duplicate custom bot replaces the old one."""
|
||||
custom_bot_manager.create_custom_bot(
|
||||
first = custom_bot_manager.create_custom_bot(
|
||||
bot_name="alfred",
|
||||
system_prompt="First personality",
|
||||
created_by="user-1",
|
||||
@@ -373,13 +1075,25 @@ def test_custom_bot_create_duplicate(
|
||||
system_prompt="Second personality",
|
||||
created_by="user-1",
|
||||
)
|
||||
assert result is True
|
||||
assert first == "created"
|
||||
assert result == "replaced"
|
||||
|
||||
bot = custom_bot_manager.get_custom_bot("alfred")
|
||||
assert bot is not None
|
||||
assert bot[1] == "Second personality"
|
||||
|
||||
|
||||
def test_custom_bot_create_failure(custom_bot_manager: Any) -> None:
|
||||
"""A database error while creating yields False, not an exception."""
|
||||
with patch("vibe_bot.db.bots.connect", return_value=_broken_connection()):
|
||||
result = custom_bot_manager.create_custom_bot(
|
||||
bot_name="failbot",
|
||||
system_prompt="a long enough personality",
|
||||
created_by="user-1",
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_custom_bot_create_case_insensitive(
|
||||
custom_bot_manager: Any,
|
||||
) -> None:
|
||||
@@ -399,6 +1113,18 @@ def test_custom_bot_get_not_found(custom_bot_manager: Any) -> None:
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_custom_bot_get_returns_datetime(custom_bot_manager: Any) -> None:
|
||||
"""created_at comes back as a real datetime, not a string."""
|
||||
custom_bot_manager.create_custom_bot(
|
||||
bot_name="dtbot",
|
||||
system_prompt="a long enough personality",
|
||||
created_by="user-1",
|
||||
)
|
||||
result = custom_bot_manager.get_custom_bot("dtbot")
|
||||
assert result is not None
|
||||
assert isinstance(result[3], datetime)
|
||||
|
||||
|
||||
def test_custom_bot_get_returns_correct_data(
|
||||
custom_bot_manager: Any,
|
||||
) -> None:
|
||||
@@ -413,8 +1139,7 @@ def test_custom_bot_get_returns_correct_data(
|
||||
assert result[0] == "testbot"
|
||||
assert result[1] == "test prompt"
|
||||
assert result[2] == "creator-1"
|
||||
assert result[3] is not None
|
||||
assert "20" in result[3]
|
||||
assert isinstance(result[3], datetime)
|
||||
|
||||
|
||||
def test_custom_bot_list_empty(custom_bot_manager: Any) -> None:
|
||||
@@ -440,6 +1165,23 @@ def test_custom_bot_list(custom_bot_manager: Any) -> None:
|
||||
assert len(bots) == 2
|
||||
|
||||
|
||||
def test_custom_bot_list_by_creator(custom_bot_manager: Any) -> None:
|
||||
"""list_custom_bots filters by creator when user_id is given."""
|
||||
custom_bot_manager.create_custom_bot(
|
||||
bot_name="bot-x",
|
||||
system_prompt="prompt x",
|
||||
created_by="user-1",
|
||||
)
|
||||
custom_bot_manager.create_custom_bot(
|
||||
bot_name="bot-y",
|
||||
system_prompt="prompt y",
|
||||
created_by="user-2",
|
||||
)
|
||||
|
||||
bots = custom_bot_manager.list_custom_bots(user_id="user-1")
|
||||
assert [bot[0] for bot in bots] == ["bot-x"]
|
||||
|
||||
|
||||
def test_custom_bot_delete(custom_bot_manager: Any) -> None:
|
||||
"""Test deleting a custom bot."""
|
||||
custom_bot_manager.create_custom_bot(
|
||||
@@ -462,32 +1204,18 @@ def test_custom_bot_delete_nonexistent(
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_custom_bot_deactivate(custom_bot_manager: Any) -> None:
|
||||
"""Test deactivating a custom bot."""
|
||||
custom_bot_manager.create_custom_bot(
|
||||
bot_name="inactive-bot",
|
||||
system_prompt="will be deactivated",
|
||||
created_by="user-1",
|
||||
)
|
||||
result = custom_bot_manager.deactivate_custom_bot("inactive-bot")
|
||||
assert result is True
|
||||
|
||||
bot = custom_bot_manager.get_custom_bot("inactive-bot")
|
||||
assert bot is None
|
||||
|
||||
|
||||
def test_custom_bot_deactivate_nonexistent(
|
||||
custom_bot_manager: Any,
|
||||
) -> None:
|
||||
"""Test deactivating a non-existent bot returns False."""
|
||||
result = custom_bot_manager.deactivate_custom_bot("nonexistent")
|
||||
assert result is False
|
||||
def test_custom_bot_delete_failure(custom_bot_manager: Any) -> None:
|
||||
"""A database error while deleting yields False, not an exception."""
|
||||
with patch("vibe_bot.db.bots.connect", return_value=_broken_connection()):
|
||||
assert custom_bot_manager.delete_custom_bot("whatever") is False
|
||||
|
||||
|
||||
def test_custom_bot_list_excludes_inactive(
|
||||
custom_bot_manager: Any,
|
||||
) -> None:
|
||||
"""Test that list_custom_bots excludes deactivated bots."""
|
||||
"""Test that list_custom_bots excludes bots with is_active = 0."""
|
||||
import sqlite3
|
||||
|
||||
custom_bot_manager.create_custom_bot(
|
||||
bot_name="active-bot",
|
||||
system_prompt="stays active",
|
||||
@@ -498,7 +1226,12 @@ def test_custom_bot_list_excludes_inactive(
|
||||
system_prompt="should not appear",
|
||||
created_by="user-1",
|
||||
)
|
||||
custom_bot_manager.deactivate_custom_bot("deactivated-bot")
|
||||
conn = sqlite3.connect(custom_bot_manager.db_path)
|
||||
conn.execute(
|
||||
"UPDATE custom_bots SET is_active = 0 WHERE bot_name = 'deactivated-bot'"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
bots = custom_bot_manager.list_custom_bots()
|
||||
assert len(bots) == 1
|
||||
@@ -532,16 +1265,13 @@ def test_database_get_database_singleton(temp_db_path: str) -> None:
|
||||
db2 = get_database()
|
||||
assert db1 is db2
|
||||
|
||||
db1.client.close()
|
||||
|
||||
|
||||
def test_database_init_creates_tables(temp_db_path: str) -> None:
|
||||
"""Test that database initialization creates the expected tables."""
|
||||
from vibe_bot.database import ChatDatabase, CustomBotManager
|
||||
|
||||
db = ChatDatabase(db_path=temp_db_path)
|
||||
ChatDatabase(db_path=temp_db_path)
|
||||
CustomBotManager(db_path=temp_db_path)
|
||||
db.client.close()
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Docs-as-tests: README.md stays in sync with the real commands and file tree."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
README = REPO_ROOT / "README.md"
|
||||
|
||||
BINARY_SUFFIXES = {
|
||||
".bin",
|
||||
".db",
|
||||
".gif",
|
||||
".ico",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".mp3",
|
||||
".onnx",
|
||||
".png",
|
||||
".wav",
|
||||
}
|
||||
|
||||
|
||||
def _git(args: list[str]) -> list[str]:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.splitlines()
|
||||
|
||||
|
||||
def _committed_files() -> set[str]:
|
||||
"""Files that make up the current tree: index minus deletions, plus untracked."""
|
||||
deleted: set[str] = set()
|
||||
for line in _git(["status", "--porcelain"]):
|
||||
if "D" in line[:2]:
|
||||
deleted.add(line[3:])
|
||||
tracked = set(_git(["ls-files"])) - deleted
|
||||
untracked = set(_git(["ls-files", "--others", "--exclude-standard"]))
|
||||
files: set[str] = set()
|
||||
for path in tracked | untracked:
|
||||
parts = Path(path).parts
|
||||
if any(part.startswith(".") for part in parts):
|
||||
continue
|
||||
if Path(path).suffix.lower() in BINARY_SUFFIXES:
|
||||
continue
|
||||
files.add(path)
|
||||
return files
|
||||
|
||||
|
||||
def _readme_tree_paths() -> set[str]:
|
||||
"""Reconstruct the relative paths (and directory entries) from the README tree."""
|
||||
text = README.read_text(encoding="utf-8")
|
||||
match = re.search(r"## File Structure\s*```text\n(.*?)```", text, re.DOTALL)
|
||||
assert match is not None, "File Structure tree block not found in README"
|
||||
lines = match.group(1).splitlines()
|
||||
assert lines, "File Structure tree block is empty"
|
||||
|
||||
root = lines[0].split("#")[0].strip().rstrip("/")
|
||||
assert root == REPO_ROOT.name, f"Tree root {root!r} != repo dir {REPO_ROOT.name!r}"
|
||||
stack: list[str] = []
|
||||
paths: set[str] = set()
|
||||
entry_re = re.compile(
|
||||
r"^(?P<prefix>(?:[│ ] )*)(?:├── |└── )(?P<name>\S.*?)(?:\s+#.*)?$"
|
||||
)
|
||||
for line in lines[1:]:
|
||||
m = entry_re.match(line)
|
||||
assert m is not None, f"Unparseable tree line: {line!r}"
|
||||
depth = len(m.group("prefix")) // 4
|
||||
name = m.group("name").strip()
|
||||
if name.endswith("/"):
|
||||
stack = stack[:depth] + [name.rstrip("/")]
|
||||
paths.add("/".join(stack) + "/")
|
||||
else:
|
||||
paths.add("/".join(stack[:depth] + [name]))
|
||||
return paths
|
||||
|
||||
|
||||
def test_readme_documents_every_registered_command(bot: commands.Bot) -> None:
|
||||
"""Every command registered on the bot is documented in README.md."""
|
||||
readme = README.read_text(encoding="utf-8")
|
||||
assert len(bot.commands) >= 11
|
||||
missing = [cmd.name for cmd in bot.commands if cmd.name not in readme]
|
||||
assert missing == [], f"Commands missing from README: {missing}"
|
||||
|
||||
|
||||
def test_readme_file_tree_matches_actual_tree() -> None:
|
||||
"""The README tree covers every committed non-dotfile, non-binary file."""
|
||||
actual = _committed_files()
|
||||
readme_paths = _readme_tree_paths()
|
||||
|
||||
missing_in_readme = actual - readme_paths
|
||||
assert (
|
||||
missing_in_readme == set()
|
||||
), f"Files missing from the README tree: {sorted(missing_in_readme)}"
|
||||
|
||||
for path in readme_paths:
|
||||
target = REPO_ROOT / path
|
||||
if path.endswith("/"):
|
||||
assert target.is_dir(), f"README tree lists missing directory: {path}"
|
||||
else:
|
||||
assert target.is_file(), f"README tree lists missing file: {path}"
|
||||
@@ -1,150 +0,0 @@
|
||||
"""Tests for the llama_wrapper module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import tempfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vibe_bot.config import (
|
||||
CHAT_ENDPOINT,
|
||||
CHAT_ENDPOINT_KEY,
|
||||
CHAT_MODEL,
|
||||
EMBEDDING_ENDPOINT,
|
||||
EMBEDDING_ENDPOINT_KEY,
|
||||
IMAGE_EDIT_ENDPOINT,
|
||||
IMAGE_EDIT_ENDPOINT_KEY,
|
||||
IMAGE_GEN_ENDPOINT,
|
||||
IMAGE_GEN_ENDPOINT_KEY,
|
||||
)
|
||||
from vibe_bot.llama_wrapper import (
|
||||
chat_completion,
|
||||
chat_completion_instruct,
|
||||
embedding,
|
||||
image_edit,
|
||||
image_generation,
|
||||
)
|
||||
|
||||
TEMPDIR = Path(tempfile.mkdtemp())
|
||||
|
||||
|
||||
def test_chat_completion_think() -> None:
|
||||
"""Test chat completion with think model."""
|
||||
chat_completion(
|
||||
system_prompt="You are a helpful assistant.",
|
||||
user_prompt="Tell me about Everquest",
|
||||
openai_url=CHAT_ENDPOINT,
|
||||
openai_api_key=CHAT_ENDPOINT_KEY,
|
||||
model=CHAT_MODEL,
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
|
||||
def test_chat_completion_instruct() -> None:
|
||||
"""Test chat completion with instruct model."""
|
||||
chat_completion_instruct(
|
||||
system_prompt="You are a helpful assistant.",
|
||||
user_prompt="Tell me about Everquest",
|
||||
openai_url=CHAT_ENDPOINT,
|
||||
openai_api_key=CHAT_ENDPOINT_KEY,
|
||||
model=CHAT_MODEL,
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
|
||||
def test_image_generation() -> None:
|
||||
"""Test image generation endpoint."""
|
||||
with patch("vibe_bot.llama_wrapper.openai.OpenAI") as mock_openai:
|
||||
mock_response = MagicMock()
|
||||
mock_data = MagicMock()
|
||||
mock_data.b64_json = base64.b64encode(b"fake image data").decode()
|
||||
mock_response.data = [mock_data]
|
||||
mock_openai.return_value.images.generate.return_value = mock_response
|
||||
result = image_generation(
|
||||
prompt="Generate an image of a horse",
|
||||
openai_url=IMAGE_GEN_ENDPOINT,
|
||||
openai_api_key=IMAGE_GEN_ENDPOINT_KEY,
|
||||
)
|
||||
assert result == base64.b64encode(b"fake image data").decode()
|
||||
|
||||
|
||||
def test_image_edit() -> None:
|
||||
"""Test image edit endpoint."""
|
||||
with patch("vibe_bot.llama_wrapper.openai.OpenAI") as mock_openai:
|
||||
mock_response = MagicMock()
|
||||
mock_data = MagicMock()
|
||||
mock_data.b64_json = base64.b64encode(b"fake edited image data").decode()
|
||||
mock_response.data = [mock_data]
|
||||
mock_openai.return_value.images.edit.return_value = mock_response
|
||||
result = image_edit(
|
||||
image=BytesIO(b"fake image"),
|
||||
prompt="Paint the words 'horse' on the horse.",
|
||||
openai_url=IMAGE_EDIT_ENDPOINT,
|
||||
openai_api_key=IMAGE_EDIT_ENDPOINT_KEY,
|
||||
)
|
||||
assert result == base64.b64encode(b"fake edited image data").decode()
|
||||
|
||||
|
||||
def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
||||
"""Calculate cosine similarity between two arrays.
|
||||
|
||||
Returns a value close to 1 for similar vectors,
|
||||
close to 0 for orthogonal vectors,
|
||||
and close to -1 for opposite vectors.
|
||||
"""
|
||||
a_arr, b_arr = np.array(a), np.array(b)
|
||||
return float(np.dot(a_arr, b_arr) / (np.linalg.norm(a_arr) * np.linalg.norm(b_arr)))
|
||||
|
||||
|
||||
EMBEDDING_SIMILARITY_HIGH = 0.9
|
||||
EMBEDDING_SIMILARITY_LOW = 0.5
|
||||
|
||||
|
||||
def test_embeddings() -> None:
|
||||
"""Test embedding similarity for similar and different texts."""
|
||||
mock_horse_vec = [0.8] * 1024 + [0.6] * 1024
|
||||
mock_horse_also_vec = [0.79] * 1024 + [0.61] * 1024
|
||||
mock_donkey_vec = [-0.8] * 1024 + [-0.6] * 1024
|
||||
|
||||
def mock_post(*args: Any, **kwargs: Any) -> MagicMock:
|
||||
json_data = kwargs.get("json", {})
|
||||
text = json_data["input"][0]
|
||||
if "horse" in text and "donkey" not in text and "also" not in text:
|
||||
embedding_data = mock_horse_vec
|
||||
elif "also" in text:
|
||||
embedding_data = mock_horse_also_vec
|
||||
else:
|
||||
embedding_data = mock_donkey_vec
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"embedding": embedding_data}]}
|
||||
return mock_resp
|
||||
|
||||
with patch("vibe_bot.llama_wrapper.requests.post", side_effect=mock_post):
|
||||
result1 = embedding(
|
||||
"this is a horse",
|
||||
openai_url=EMBEDDING_ENDPOINT,
|
||||
openai_api_key=EMBEDDING_ENDPOINT_KEY,
|
||||
model="embed",
|
||||
)
|
||||
result2 = embedding(
|
||||
"this is a horse also",
|
||||
openai_url=EMBEDDING_ENDPOINT,
|
||||
openai_api_key=EMBEDDING_ENDPOINT_KEY,
|
||||
model="embed",
|
||||
)
|
||||
result3 = embedding(
|
||||
"this is a donkey",
|
||||
openai_url=EMBEDDING_ENDPOINT,
|
||||
openai_api_key=EMBEDDING_ENDPOINT_KEY,
|
||||
model="embed",
|
||||
)
|
||||
similarity_1 = _cosine_similarity(np.array(result1), np.array(result2))
|
||||
assert similarity_1 > EMBEDDING_SIMILARITY_HIGH
|
||||
|
||||
similarity_2 = _cosine_similarity(np.array(result1), np.array(result3))
|
||||
assert similarity_2 < EMBEDDING_SIMILARITY_LOW
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Tests for the llm_client module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vibe_bot.config import (
|
||||
CHAT_MODEL,
|
||||
EMBEDDING_ENDPOINT,
|
||||
EMBEDDING_ENDPOINT_KEY,
|
||||
)
|
||||
from vibe_bot.llm_client import (
|
||||
chat_complete,
|
||||
chat_completion_instruct,
|
||||
embedding,
|
||||
image_edit,
|
||||
image_generation,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
def test_chat_complete_live() -> None:
|
||||
"""Live call to the chat endpoint via the core async ``chat_complete``.
|
||||
|
||||
Unmocked: requires network access to the configured chat API. Ported from
|
||||
the former ``test_chat_completion_think`` (its sync ``chat_completion``
|
||||
wrapper was deleted).
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
result = asyncio.run(
|
||||
chat_complete(
|
||||
[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Tell me about Everquest"},
|
||||
],
|
||||
model=CHAT_MODEL,
|
||||
max_tokens=100,
|
||||
)
|
||||
)
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
def test_chat_completion_instruct_live() -> None:
|
||||
"""Live call to the chat endpoint via the async instruct adapter.
|
||||
|
||||
Unmocked: requires network access to the configured chat API.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
result = asyncio.run(
|
||||
chat_completion_instruct(
|
||||
system_prompt="You are a helpful assistant.",
|
||||
user_prompt="Tell me about Everquest",
|
||||
model=CHAT_MODEL,
|
||||
max_tokens=100,
|
||||
)
|
||||
)
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
def test_image_generation() -> None:
|
||||
"""Image generation returns the first b64 payload from the API."""
|
||||
import asyncio
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_data = MagicMock()
|
||||
mock_data.b64_json = base64.b64encode(b"fake image data").decode()
|
||||
mock_response = MagicMock()
|
||||
mock_response.data = [mock_data]
|
||||
mock_client.images.generate = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("vibe_bot.llm.images.get_image_gen_client", return_value=mock_client):
|
||||
result = asyncio.run(
|
||||
image_generation(
|
||||
prompt="Generate an image of a horse",
|
||||
model="test-image-model",
|
||||
)
|
||||
)
|
||||
assert result == base64.b64encode(b"fake image data").decode()
|
||||
|
||||
|
||||
def test_image_generation_api_error_returns_empty() -> None:
|
||||
"""A 4xx/5xx (APIStatusError) from the image API returns "" without raising."""
|
||||
import asyncio
|
||||
|
||||
import openai
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.images.generate = AsyncMock(
|
||||
side_effect=openai.APIStatusError(
|
||||
"boom",
|
||||
response=MagicMock(),
|
||||
body=None,
|
||||
)
|
||||
)
|
||||
|
||||
with patch("vibe_bot.llm.images.get_image_gen_client", return_value=mock_client):
|
||||
result = asyncio.run(
|
||||
image_generation(
|
||||
prompt="Generate an image of a horse",
|
||||
model="test-image-model",
|
||||
)
|
||||
)
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_image_edit() -> None:
|
||||
"""Image edit returns the first b64 payload from the API."""
|
||||
import asyncio
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_data = MagicMock()
|
||||
mock_data.b64_json = base64.b64encode(b"fake edited image data").decode()
|
||||
mock_response = MagicMock()
|
||||
mock_response.data = [mock_data]
|
||||
mock_client.images.edit = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch("vibe_bot.llm.images.get_image_edit_client", return_value=mock_client):
|
||||
result = asyncio.run(
|
||||
image_edit(
|
||||
image=BytesIO(b"fake image"),
|
||||
prompt="Paint the words 'horse' on the horse.",
|
||||
model="test-image-edit-model",
|
||||
)
|
||||
)
|
||||
assert result == base64.b64encode(b"fake edited image data").decode()
|
||||
|
||||
|
||||
def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
||||
"""Calculate cosine similarity between two arrays.
|
||||
|
||||
Returns a value close to 1 for similar vectors,
|
||||
close to 0 for orthogonal vectors,
|
||||
and close to -1 for opposite vectors.
|
||||
"""
|
||||
a_arr, b_arr = np.array(a), np.array(b)
|
||||
return float(np.dot(a_arr, b_arr) / (np.linalg.norm(a_arr) * np.linalg.norm(b_arr)))
|
||||
|
||||
|
||||
EMBEDDING_SIMILARITY_HIGH = 0.9
|
||||
EMBEDDING_SIMILARITY_LOW = 0.5
|
||||
|
||||
|
||||
def _mock_embedding_session(
|
||||
post: MagicMock,
|
||||
) -> MagicMock:
|
||||
"""Build a mock requests.Session whose .post is ``post``."""
|
||||
session = MagicMock()
|
||||
session.post = post
|
||||
return session
|
||||
|
||||
|
||||
def test_embeddings() -> None:
|
||||
"""Embedding similarity for similar and different texts."""
|
||||
mock_horse_vec = [0.8] * 1024 + [0.6] * 1024
|
||||
mock_horse_also_vec = [0.79] * 1024 + [0.61] * 1024
|
||||
mock_donkey_vec = [-0.8] * 1024 + [-0.6] * 1024
|
||||
|
||||
def mock_post(*args: Any, **kwargs: Any) -> MagicMock:
|
||||
json_data = kwargs.get("json", {})
|
||||
text = json_data["input"][0]
|
||||
if "horse" in text and "donkey" not in text and "also" not in text:
|
||||
embedding_data = mock_horse_vec
|
||||
elif "also" in text:
|
||||
embedding_data = mock_horse_also_vec
|
||||
else:
|
||||
embedding_data = mock_donkey_vec
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"embedding": embedding_data}]}
|
||||
return mock_resp
|
||||
|
||||
session = _mock_embedding_session(MagicMock(side_effect=mock_post))
|
||||
with patch("vibe_bot.llm_client.get_embedding_session", return_value=session):
|
||||
result1 = embedding(
|
||||
"this is a horse",
|
||||
url=EMBEDDING_ENDPOINT,
|
||||
api_key=EMBEDDING_ENDPOINT_KEY,
|
||||
model="embed",
|
||||
)
|
||||
result2 = embedding(
|
||||
"this is a horse also",
|
||||
url=EMBEDDING_ENDPOINT,
|
||||
api_key=EMBEDDING_ENDPOINT_KEY,
|
||||
model="embed",
|
||||
)
|
||||
result3 = embedding(
|
||||
"this is a donkey",
|
||||
url=EMBEDDING_ENDPOINT,
|
||||
api_key=EMBEDDING_ENDPOINT_KEY,
|
||||
model="embed",
|
||||
)
|
||||
similarity_1 = _cosine_similarity(np.array(result1), np.array(result2))
|
||||
assert similarity_1 > EMBEDDING_SIMILARITY_HIGH
|
||||
|
||||
similarity_2 = _cosine_similarity(np.array(result1), np.array(result3))
|
||||
assert similarity_2 < EMBEDDING_SIMILARITY_LOW
|
||||
|
||||
|
||||
def test_embedding_non_json_2xx_returns_empty() -> None:
|
||||
"""A 2xx response with a non-JSON body must return [] without raising.
|
||||
|
||||
Regression test for ``resp.json()`` sitting outside the try block, so an
|
||||
HTML error page (or any non-JSON 2xx body) raised JSONDecodeError out of
|
||||
``embedding`` and, through it, out of ``get_conversation_context``.
|
||||
"""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
mock_resp.json.side_effect = ValueError("<html>rate limited</html>")
|
||||
|
||||
session = _mock_embedding_session(MagicMock(return_value=mock_resp))
|
||||
with patch("vibe_bot.llm_client.get_embedding_session", return_value=session):
|
||||
result = embedding(
|
||||
"this is a horse",
|
||||
url=EMBEDDING_ENDPOINT,
|
||||
api_key=EMBEDDING_ENDPOINT_KEY,
|
||||
model="embed",
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_chat_client_singleton_identity() -> None:
|
||||
"""The shared chat client is built once and reused across calls."""
|
||||
from vibe_bot import llm_client
|
||||
|
||||
client1 = llm_client.get_chat_client()
|
||||
client2 = llm_client.get_chat_client()
|
||||
assert client1 is client2
|
||||
|
||||
|
||||
def test_image_gen_client_singleton_identity() -> None:
|
||||
"""The shared image-generation client is built once, from a cold start."""
|
||||
import vibe_bot.llm.images as images_mod
|
||||
|
||||
saved = images_mod._image_gen_client
|
||||
images_mod._image_gen_client = None
|
||||
try:
|
||||
client1 = images_mod.get_image_gen_client()
|
||||
client2 = images_mod.get_image_gen_client()
|
||||
assert client1 is client2
|
||||
finally:
|
||||
images_mod._image_gen_client = saved
|
||||
|
||||
|
||||
def test_image_edit_client_singleton_identity() -> None:
|
||||
"""The shared image-edit client is built once, from a cold start."""
|
||||
import vibe_bot.llm.images as images_mod
|
||||
|
||||
saved = images_mod._image_edit_client
|
||||
images_mod._image_edit_client = None
|
||||
try:
|
||||
client1 = images_mod.get_image_edit_client()
|
||||
client2 = images_mod.get_image_edit_client()
|
||||
assert client1 is client2
|
||||
finally:
|
||||
images_mod._image_edit_client = saved
|
||||
|
||||
|
||||
def test_flows_build_no_new_clients_or_sessions(
|
||||
mock_ctx: MagicMock,
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""A full !doodlebob + chat turn constructs no new clients or sessions.
|
||||
|
||||
Every shared client and the embedding session are built once at
|
||||
"startup"; running the whole image flow and a chat turn through the real
|
||||
singletons (with only HTTP mocked) must not construct another
|
||||
AsyncOpenAI client or requests.Session.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import openai
|
||||
import requests
|
||||
|
||||
from vibe_bot import llm_client
|
||||
from vibe_bot.database import ChatDatabase
|
||||
from vibe_bot.services.chat_service import ChatService
|
||||
from vibe_bot.services.image_service import ImageService
|
||||
|
||||
# Startup: build every shared client and the embedding session once.
|
||||
chat_client = llm_client.get_chat_client()
|
||||
gen_client = llm_client.get_image_gen_client()
|
||||
edit_client = llm_client.get_image_edit_client()
|
||||
llm_client.get_embedding_session()
|
||||
|
||||
counts = {"async_openai": 0, "session": 0}
|
||||
real_session = requests.Session
|
||||
|
||||
class CountingAsyncOpenAI(openai.AsyncOpenAI):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
counts["async_openai"] += 1
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def counting_session() -> requests.Session:
|
||||
counts["session"] += 1
|
||||
return real_session()
|
||||
|
||||
# layout, image prompt, verify verdict, chat reply — in call order.
|
||||
completions_create = AsyncMock(
|
||||
side_effect=[
|
||||
_make_response("square", None),
|
||||
_make_response("a detailed prompt", None),
|
||||
_make_response("PASS", None),
|
||||
_make_response("a chat reply", None),
|
||||
]
|
||||
)
|
||||
image_response = MagicMock()
|
||||
image_response.data = [MagicMock(b64_json=base64.b64encode(b"fake image").decode())]
|
||||
images_generate = AsyncMock(return_value=image_response)
|
||||
|
||||
registry = MagicMock()
|
||||
registry.to_openai_tools.return_value = []
|
||||
|
||||
db = ChatDatabase(db_path=temp_db_path)
|
||||
|
||||
with (
|
||||
patch.object(openai, "AsyncOpenAI", CountingAsyncOpenAI),
|
||||
patch.object(requests, "Session", counting_session),
|
||||
patch.object(chat_client.chat.completions, "create", completions_create),
|
||||
patch.object(gen_client.images, "generate", images_generate),
|
||||
patch("vibe_bot.llm_client.embedding", return_value=[0.25] * 32),
|
||||
):
|
||||
asyncio.run(
|
||||
ImageService(db, MagicMock()).generate(mock_ctx, message="a centaur")
|
||||
)
|
||||
asyncio.run(
|
||||
ChatService(db, registry).handle(
|
||||
mock_ctx,
|
||||
bot_name="alfred",
|
||||
message="hello",
|
||||
system_prompt="you are a butler",
|
||||
response_prefix="alfred response",
|
||||
)
|
||||
)
|
||||
|
||||
assert counts["async_openai"] == 0
|
||||
assert counts["session"] == 0
|
||||
assert llm_client.get_chat_client() is chat_client
|
||||
assert llm_client.get_image_gen_client() is gen_client
|
||||
assert llm_client.get_image_edit_client() is edit_client
|
||||
|
||||
|
||||
def _make_response(content: str | None, tool_calls: list[object] | None) -> MagicMock:
|
||||
"""Build a mock chat completion response with the given message fields."""
|
||||
message = MagicMock()
|
||||
message.content = content
|
||||
message.tool_calls = tool_calls
|
||||
return MagicMock(choices=[MagicMock(message=message)])
|
||||
|
||||
|
||||
def test_chat_complete_skips_non_function_tool_call() -> None:
|
||||
"""A tool call that is not of type 'function' is skipped, not executed."""
|
||||
import asyncio
|
||||
|
||||
from vibe_bot.llm_client import chat_complete
|
||||
|
||||
called = {"n": 0}
|
||||
|
||||
def tool_executor(name: str, args: dict[str, str]) -> str:
|
||||
called["n"] += 1
|
||||
return f"executed:{name}"
|
||||
|
||||
custom_tool_call = MagicMock()
|
||||
custom_tool_call.type = "custom"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create = AsyncMock(
|
||||
side_effect=[
|
||||
_make_response(content=None, tool_calls=[custom_tool_call]),
|
||||
_make_response(content="final answer", tool_calls=None),
|
||||
]
|
||||
)
|
||||
|
||||
with patch("vibe_bot.llm.chat.get_chat_client", return_value=mock_client):
|
||||
result = asyncio.run(
|
||||
chat_complete(
|
||||
[{"role": "user", "content": "hi"}],
|
||||
model="m",
|
||||
max_tokens=10,
|
||||
tool_executor=tool_executor,
|
||||
)
|
||||
)
|
||||
|
||||
assert result == "final answer"
|
||||
assert called["n"] == 0
|
||||
|
||||
|
||||
def test_tool_registry_dispatch_and_unknown_tool() -> None:
|
||||
"""The registry renders schemas, dispatches known tools, and names unknowns."""
|
||||
from vibe_bot.llm_client import ToolRegistry
|
||||
|
||||
def echo_tool(name: str, args: dict[str, str], **kwargs: object) -> str:
|
||||
return f"echo:{args.get('text', '')}"
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(
|
||||
"echo",
|
||||
"Echoes the text argument back.",
|
||||
{"type": "object", "properties": {"text": {"type": "string"}}},
|
||||
echo_tool,
|
||||
)
|
||||
|
||||
tools = registry.to_openai_tools()
|
||||
first = tools[0]
|
||||
assert first["type"] == "function"
|
||||
function_def = cast("dict[str, object]", first["function"])
|
||||
assert function_def["name"] == "echo"
|
||||
assert function_def["description"] == "Echoes the text argument back."
|
||||
|
||||
assert registry.execute("echo", {"text": "hi"}) == "echo:hi"
|
||||
assert registry.execute("does_not_exist", {}) == "Unknown tool: does_not_exist"
|
||||
@@ -0,0 +1,186 @@
|
||||
"""No-content logging tests.
|
||||
|
||||
A recognizable secret string is seeded into message content, image prompts,
|
||||
and bot personalities; the logs captured at DEBUG must never contain it,
|
||||
while metadata (bot name, user id, message id, counts) must.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe_bot.services.chat_service import ChatService
|
||||
from vibe_bot.services.image_service import ImageService
|
||||
from vibe_bot.tests._helpers import invoke
|
||||
|
||||
SECRET = "SECRET-DO-NOT-LOG-12345"
|
||||
|
||||
|
||||
def _registry() -> MagicMock:
|
||||
"""A mock ToolRegistry."""
|
||||
reg = MagicMock()
|
||||
reg.to_openai_tools.return_value = []
|
||||
reg.execute.return_value = "tool result"
|
||||
return reg
|
||||
|
||||
|
||||
def _assert_no_secret(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
*metadata: str,
|
||||
) -> None:
|
||||
"""The secret must be absent from every record; the metadata must be present."""
|
||||
for record in caplog.records:
|
||||
assert (
|
||||
SECRET not in record.getMessage()
|
||||
), f"Secret leaked in log record: {record.getMessage()!r}"
|
||||
assert SECRET not in caplog.text
|
||||
for token in metadata:
|
||||
assert token in caplog.text, f"Expected metadata {token!r} missing from logs"
|
||||
|
||||
|
||||
def test_chat_service_logs_metadata_not_content(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
chat_db: Any,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""A full chat turn (RAG + persist + reply) never logs the message content."""
|
||||
caplog.set_level(logging.DEBUG)
|
||||
svc = ChatService(chat_db, _registry())
|
||||
|
||||
with patch(
|
||||
"vibe_bot.llm_client.chat_completion_with_tools",
|
||||
new=AsyncMock(return_value="A perfectly fine response."),
|
||||
):
|
||||
asyncio.run(
|
||||
svc.handle(
|
||||
mock_ctx,
|
||||
bot_name="alfred",
|
||||
message=f"please remember {SECRET} forever",
|
||||
system_prompt="you are a butler",
|
||||
response_prefix="alfred response",
|
||||
)
|
||||
)
|
||||
|
||||
_assert_no_secret(caplog, "alfred", "12345")
|
||||
|
||||
|
||||
def test_add_message_logs_metadata_not_content(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
chat_db: Any,
|
||||
) -> None:
|
||||
"""add_message (with embedding) never logs the stored content."""
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
assert chat_db.add_message(
|
||||
message_id="msg-1",
|
||||
user_id="12345",
|
||||
username="testuser",
|
||||
content=f"User: tell me about {SECRET}",
|
||||
bot_name="alfred",
|
||||
channel_id="channel-1",
|
||||
guild_id="guild-1",
|
||||
)
|
||||
assert chat_db.add_message(
|
||||
message_id="msg-1_response",
|
||||
user_id="bot-123",
|
||||
username="test-bot",
|
||||
content=f"the bot knew {SECRET}",
|
||||
bot_name="alfred",
|
||||
channel_id="channel-1",
|
||||
guild_id="guild-1",
|
||||
role="assistant",
|
||||
embed=False,
|
||||
)
|
||||
|
||||
_assert_no_secret(caplog, "msg-1", "12345")
|
||||
|
||||
|
||||
def test_history_lookups_log_metadata_not_content(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
chat_db: Any,
|
||||
) -> None:
|
||||
"""get_user_history / get_bot_history never log message or response content."""
|
||||
caplog.set_level(logging.DEBUG)
|
||||
chat_db.add_message(
|
||||
message_id="msg-2",
|
||||
user_id="12345",
|
||||
username="testuser",
|
||||
content=f"User: what is {SECRET}",
|
||||
bot_name="alfred",
|
||||
channel_id="channel-1",
|
||||
guild_id="guild-1",
|
||||
)
|
||||
chat_db.add_message(
|
||||
message_id="msg-2_response",
|
||||
user_id="bot-123",
|
||||
username="test-bot",
|
||||
content=f"it is {SECRET}, clearly",
|
||||
bot_name="alfred",
|
||||
channel_id="channel-1",
|
||||
guild_id="guild-1",
|
||||
role="assistant",
|
||||
embed=False,
|
||||
)
|
||||
|
||||
user_history = chat_db.get_user_history("12345", limit=5)
|
||||
bot_history = chat_db.get_bot_history("alfred", limit=5)
|
||||
|
||||
assert len(user_history) == 1
|
||||
assert len(bot_history) == 1
|
||||
_assert_no_secret(caplog, "msg-2", "alfred")
|
||||
|
||||
|
||||
def test_doodlebob_logs_metadata_not_content(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""A doodlebob generation never logs the prompt or the derived image prompt."""
|
||||
caplog.set_level(logging.DEBUG)
|
||||
db = MagicMock()
|
||||
db.get_image_generation_time_estimate.return_value = 12.0
|
||||
svc = ImageService(db, MagicMock())
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe_bot.llm_client.chat_completion_instruct",
|
||||
new=AsyncMock(
|
||||
side_effect=[
|
||||
"portrait",
|
||||
f"a very detailed painting of {SECRET}",
|
||||
"pass",
|
||||
]
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"vibe_bot.llm_client.image_generation",
|
||||
new=AsyncMock(return_value=base64.b64encode(b"img").decode()),
|
||||
),
|
||||
):
|
||||
asyncio.run(svc.generate(mock_ctx, message=f"draw {SECRET} on the moon"))
|
||||
|
||||
_assert_no_secret(caplog, "Doodlebob", "12345")
|
||||
|
||||
|
||||
def test_custom_bot_creation_logs_metadata_not_personality(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
bot: Any,
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""!custom-bot never logs the personality text."""
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
invoke(
|
||||
bot,
|
||||
"custom-bot",
|
||||
mock_ctx,
|
||||
"secretbot",
|
||||
personality=f"a butler who knows {SECRET}",
|
||||
)
|
||||
|
||||
_assert_no_secret(caplog, "secretbot", "12345")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
"""Tests for the prompts module (prompt constants, layout parsing, user info)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe_bot.config import (
|
||||
IMAGE_GEN_SIZE_LANDSCAPE,
|
||||
IMAGE_GEN_SIZE_PORTRAIT,
|
||||
IMAGE_GEN_SIZE_SQUARE,
|
||||
)
|
||||
from vibe_bot.prompts import (
|
||||
IMAGE_PROMPT_SYSTEM_PROMPT_TEMPLATE,
|
||||
LAYOUT_SIZES,
|
||||
RESPONSE_LENGTH_HINT,
|
||||
build_system_prompt,
|
||||
get_user_info,
|
||||
parse_image_layout,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ctx() -> MagicMock:
|
||||
"""A minimal Discord user for get_user_info."""
|
||||
author = MagicMock()
|
||||
author.name = "testuser"
|
||||
author.id = "12345"
|
||||
author.global_name = "Test User"
|
||||
author.nick = "tester"
|
||||
author.top_role.name = "@everyone"
|
||||
author.activities = []
|
||||
author.joined_at = None
|
||||
author.created_at = None
|
||||
return author
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_author_with_member_data() -> MagicMock:
|
||||
"""A Discord user with full member data (role + activity + timestamps)."""
|
||||
author = MagicMock()
|
||||
author.name = "testuser"
|
||||
author.id = "12345"
|
||||
author.global_name = "Test User"
|
||||
author.nick = "tester"
|
||||
author.top_role.name = "Admin"
|
||||
activity = MagicMock()
|
||||
activity.name = "Chess"
|
||||
author.activities = [activity]
|
||||
author.joined_at = datetime(2024, 1, 15, tzinfo=UTC)
|
||||
author.created_at = datetime(2023, 6, 1, tzinfo=UTC)
|
||||
return author
|
||||
|
||||
|
||||
def test_build_system_prompt_contains_personality_hint_and_user_info() -> None:
|
||||
"""build_system_prompt assembles personality + length hint + user info block."""
|
||||
result = build_system_prompt("you are a butler", "Username: alice")
|
||||
assert result.startswith("you are a butler")
|
||||
assert RESPONSE_LENGTH_HINT in result
|
||||
assert "User Information:\nUsername: alice" in result
|
||||
|
||||
|
||||
def test_layout_sizes_map_to_config() -> None:
|
||||
"""LAYOUT_SIZES maps each layout to its configured canvas size."""
|
||||
assert LAYOUT_SIZES["portrait"] == IMAGE_GEN_SIZE_PORTRAIT
|
||||
assert LAYOUT_SIZES["landscape"] == IMAGE_GEN_SIZE_LANDSCAPE
|
||||
assert LAYOUT_SIZES["square"] == IMAGE_GEN_SIZE_SQUARE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("response", "expected"),
|
||||
[
|
||||
("portrait", "portrait"),
|
||||
("landscape", "landscape"),
|
||||
("square", "square"),
|
||||
(" Portrait ", "portrait"),
|
||||
("LANDSCAPE", "landscape"),
|
||||
("square.", "square"),
|
||||
("I would use portrait.", "portrait"),
|
||||
("This scene is best as landscape.", "landscape"),
|
||||
("A square composition works here.", "square"),
|
||||
],
|
||||
)
|
||||
def test_parse_image_layout_valid(response: str, expected: str) -> None:
|
||||
"""Valid LLM layout responses parse to the right layout."""
|
||||
assert parse_image_layout(response) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response",
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
"banana",
|
||||
"1024x1024",
|
||||
"tall and wide",
|
||||
"squareness", # word boundary should prevent a match on "square"
|
||||
],
|
||||
)
|
||||
def test_parse_image_layout_defaults_to_square(response: str) -> None:
|
||||
"""Empty or malformed responses fall back to square."""
|
||||
assert parse_image_layout(response) == "square"
|
||||
|
||||
|
||||
def test_image_prompt_system_prompt_covers_key_details() -> None:
|
||||
"""The prompt-rewrite system prompt forces explicit detail on all aspects."""
|
||||
prompt = IMAGE_PROMPT_SYSTEM_PROMPT_TEMPLATE.format(layout="square")
|
||||
lowered = prompt.lower()
|
||||
assert "square" in prompt
|
||||
assert "exact text" in lowered
|
||||
assert "composition" in lowered
|
||||
assert "style" in lowered
|
||||
assert "canada goose" in lowered
|
||||
assert "only the image generation prompt" in lowered
|
||||
assert "exactly as written" in lowered
|
||||
assert "fountain pen wearing pants" in lowered
|
||||
assert "centaur" in lowered
|
||||
assert "not a person riding a horse" in lowered
|
||||
|
||||
|
||||
def test_get_user_info_minimal(mock_ctx: MagicMock) -> None:
|
||||
"""get_user_info with minimal member data includes the core identity lines."""
|
||||
result = get_user_info(mock_ctx)
|
||||
assert "Username: testuser" in result
|
||||
assert "User ID: 12345" in result
|
||||
assert "Global Name: Test User" in result
|
||||
assert "Nickname: tester" in result
|
||||
|
||||
|
||||
def test_get_user_info_with_member_data(
|
||||
mock_author_with_member_data: MagicMock,
|
||||
) -> None:
|
||||
"""get_user_info with full member data includes roles, activity, timestamps."""
|
||||
result = get_user_info(mock_author_with_member_data)
|
||||
assert "Global Name: Test User" in result
|
||||
assert "Nickname: tester" in result
|
||||
assert "Username: testuser" in result
|
||||
assert "User ID: 12345" in result
|
||||
assert "Top Role: Admin" in result
|
||||
assert "Activities: Chess" in result
|
||||
assert "Joined: 2024-01-15" in result
|
||||
assert "Account Created: 2023-06-01" in result
|
||||
|
||||
|
||||
def test_get_user_info_no_global_name(mock_ctx: MagicMock) -> None:
|
||||
"""Optional fields are omitted when they are empty."""
|
||||
mock_ctx.global_name = None
|
||||
mock_ctx.nick = None
|
||||
mock_ctx.top_role.name = "@everyone"
|
||||
mock_ctx.activities = []
|
||||
|
||||
result = get_user_info(mock_ctx)
|
||||
|
||||
assert "Global Name:" not in result
|
||||
assert "Nickname:" not in result
|
||||
assert "Top Role:" not in result
|
||||
assert "Activities:" not in result
|
||||
assert "Username: testuser" in result
|
||||
assert "User ID: 12345" in result
|
||||
|
||||
|
||||
def test_get_user_info_with_top_role_not_everyone(
|
||||
mock_author_with_member_data: MagicMock,
|
||||
) -> None:
|
||||
"""Top role is included when it is not @everyone."""
|
||||
result = get_user_info(mock_author_with_member_data)
|
||||
assert "Top Role: Admin" in result
|
||||
|
||||
|
||||
def test_get_user_info_no_activities(mock_ctx: MagicMock) -> None:
|
||||
"""The activities line is omitted when there are none."""
|
||||
mock_ctx.activities = []
|
||||
result = get_user_info(mock_ctx)
|
||||
assert "Activities:" not in result
|
||||
@@ -0,0 +1,953 @@
|
||||
"""Service-layer tests: chat, image, speech, and conversation services.
|
||||
|
||||
These exercise the LLM-backed logic directly (constructing each service with
|
||||
mock dependencies) rather than going through the thin Discord command wrappers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from vibe_bot.config import TTS_VOICE
|
||||
from vibe_bot.services.chat_service import ChatService
|
||||
from vibe_bot.services.conversation_service import (
|
||||
MAX_TOPIC_LENGTH,
|
||||
ConversationService,
|
||||
flip_counter,
|
||||
)
|
||||
from vibe_bot.services.image_service import (
|
||||
MAX_IMAGE_DOWNLOAD_BYTES,
|
||||
MAX_IMAGE_PROMPT_LENGTH,
|
||||
ImageService,
|
||||
_allowed_image_url,
|
||||
_download_image_bytes,
|
||||
select_image_layout,
|
||||
verify_image_prompt,
|
||||
)
|
||||
from vibe_bot.services.speech_service import (
|
||||
MAX_SPEAK_LENGTH,
|
||||
SpeechService,
|
||||
parse_voice_flag,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ctx() -> MagicMock:
|
||||
"""A mock Discord command context."""
|
||||
ctx = MagicMock()
|
||||
ctx.author.name = "testuser"
|
||||
ctx.author.id = "12345"
|
||||
ctx.author.global_name = "Test User"
|
||||
ctx.author.nick = "tester"
|
||||
ctx.author.top_role.name = "@everyone"
|
||||
ctx.author.activities = []
|
||||
ctx.author.joined_at = None
|
||||
ctx.author.created_at = None
|
||||
ctx.channel.id = "channel-1"
|
||||
ctx.guild.id = "guild-1"
|
||||
ctx.message.id = "msg-1"
|
||||
ctx.message.attachments = []
|
||||
ctx.bot.user = MagicMock()
|
||||
ctx.bot.user.name = "test-bot"
|
||||
ctx.bot.user.id = "bot-123"
|
||||
ctx.send = AsyncMock()
|
||||
return ctx
|
||||
|
||||
|
||||
def _file_factory() -> MagicMock:
|
||||
"""A File factory that records (buffer, filename) as a tuple."""
|
||||
factory = MagicMock()
|
||||
|
||||
def make_file(buf: BytesIO, name: str) -> tuple[str, BytesIO, str]:
|
||||
return ("FILE", buf, name)
|
||||
|
||||
factory.side_effect = make_file
|
||||
return factory
|
||||
|
||||
|
||||
def _sent_texts(ctx: MagicMock) -> list[str]:
|
||||
"""All positional text messages sent through ctx.send."""
|
||||
return [c.args[0] for c in ctx.send.call_args_list if c.args]
|
||||
|
||||
|
||||
def _registry() -> MagicMock:
|
||||
"""A mock ToolRegistry."""
|
||||
reg = MagicMock()
|
||||
reg.to_openai_tools.return_value = []
|
||||
reg.execute.return_value = "tool result"
|
||||
return reg
|
||||
|
||||
|
||||
def _fake_bot(name: str) -> tuple[str, str, str, str]:
|
||||
"""A stand-in custom bot tuple for manager.get_custom_bot."""
|
||||
return (name, "a personality", "user-123", "2024-01-01")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChatService
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_success(mock_ctx: MagicMock) -> None:
|
||||
"""A normal turn persists the exchange and sends the reply."""
|
||||
db = MagicMock()
|
||||
db.get_conversation_context.return_value = []
|
||||
svc = ChatService(db, _registry())
|
||||
|
||||
with patch(
|
||||
"vibe_bot.llm_client.chat_completion_with_tools",
|
||||
new=AsyncMock(return_value="This is a bot response"),
|
||||
):
|
||||
asyncio.run(
|
||||
svc.handle(
|
||||
mock_ctx,
|
||||
bot_name="alfred",
|
||||
message="hello",
|
||||
system_prompt="you are a butler",
|
||||
response_prefix="alfred response",
|
||||
)
|
||||
)
|
||||
|
||||
db.add_message.assert_called()
|
||||
assert mock_ctx.send.call_count >= 2
|
||||
|
||||
|
||||
def test_chat_turn_embedding_budget(
|
||||
mock_ctx: MagicMock,
|
||||
temp_db_path: str,
|
||||
) -> None:
|
||||
"""One chat turn embeds exactly twice: the RAG query and the user row.
|
||||
|
||||
The assistant row is persisted with embed=False, so it costs no
|
||||
embedding call and stores no embedding row.
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
from vibe_bot.database import ChatDatabase
|
||||
|
||||
db = ChatDatabase(db_path=temp_db_path)
|
||||
svc = ChatService(db, _registry())
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe_bot.llm_client.embedding",
|
||||
return_value=[0.25] * 32,
|
||||
) as mock_embedding,
|
||||
patch(
|
||||
"vibe_bot.llm_client.chat_completion_with_tools",
|
||||
new=AsyncMock(return_value="This is a bot response"),
|
||||
),
|
||||
):
|
||||
asyncio.run(
|
||||
svc.handle(
|
||||
mock_ctx,
|
||||
bot_name="alfred",
|
||||
message="hello",
|
||||
system_prompt="you are a butler",
|
||||
response_prefix="alfred response",
|
||||
)
|
||||
)
|
||||
|
||||
assert mock_embedding.call_count == 2
|
||||
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
embedding_rows = conn.execute("SELECT COUNT(*) FROM message_embeddings").fetchone()
|
||||
conn.close()
|
||||
assert embedding_rows[0] == 1
|
||||
|
||||
|
||||
def test_chat_error(mock_ctx: MagicMock) -> None:
|
||||
"""An LLM error surfaces a friendly message."""
|
||||
db = MagicMock()
|
||||
db.get_conversation_context.return_value = []
|
||||
svc = ChatService(db, _registry())
|
||||
|
||||
with patch(
|
||||
"vibe_bot.llm_client.chat_completion_with_tools",
|
||||
new=AsyncMock(side_effect=Exception("API error")),
|
||||
):
|
||||
asyncio.run(
|
||||
svc.handle(
|
||||
mock_ctx,
|
||||
bot_name="alfred",
|
||||
message="hello",
|
||||
system_prompt="you are a butler",
|
||||
response_prefix="alfred response",
|
||||
)
|
||||
)
|
||||
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "error occurred" in call_args.lower()
|
||||
db.add_message.assert_not_called()
|
||||
|
||||
|
||||
def test_chat_long_response_chunked(mock_ctx: MagicMock) -> None:
|
||||
"""Long responses are split into multiple sends."""
|
||||
db = MagicMock()
|
||||
db.get_conversation_context.return_value = []
|
||||
svc = ChatService(db, _registry())
|
||||
|
||||
with patch(
|
||||
"vibe_bot.llm_client.chat_completion_with_tools",
|
||||
new=AsyncMock(return_value="x" * 2500),
|
||||
):
|
||||
asyncio.run(
|
||||
svc.handle(
|
||||
mock_ctx,
|
||||
bot_name="alfred",
|
||||
message="hello",
|
||||
system_prompt="you are a butler",
|
||||
response_prefix="alfred response",
|
||||
)
|
||||
)
|
||||
|
||||
assert mock_ctx.send.call_count >= 3
|
||||
|
||||
|
||||
def test_chat_includes_user_info(mock_ctx: MagicMock) -> None:
|
||||
"""The system prompt sent to the LLM includes the requester's info."""
|
||||
db = MagicMock()
|
||||
db.get_conversation_context.return_value = []
|
||||
svc = ChatService(db, _registry())
|
||||
|
||||
mock_llm = AsyncMock(return_value="resp")
|
||||
with patch("vibe_bot.llm_client.chat_completion_with_tools", new=mock_llm):
|
||||
asyncio.run(
|
||||
svc.handle(
|
||||
mock_ctx,
|
||||
bot_name="alfred",
|
||||
message="hello",
|
||||
system_prompt="you are a butler",
|
||||
response_prefix="alfred response",
|
||||
)
|
||||
)
|
||||
|
||||
system_prompt = mock_llm.call_args.kwargs["system_prompt"]
|
||||
assert "testuser" in system_prompt
|
||||
|
||||
|
||||
def test_chat_with_context_and_tools(mock_ctx: MagicMock) -> None:
|
||||
"""Prior RAG context is prepended and tool calls reach the registry."""
|
||||
db = MagicMock()
|
||||
db.get_conversation_context.return_value = [
|
||||
{"role": "user", "content": "old question"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
]
|
||||
registry = _registry()
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_llm(**kwargs: Any) -> str:
|
||||
captured.update(kwargs)
|
||||
kwargs["tool_executor"]("get_channel_members", {})
|
||||
await kwargs["tool_call_notifier"]("get_channel_members", {})
|
||||
return "resp"
|
||||
|
||||
with patch("vibe_bot.llm_client.chat_completion_with_tools", new=fake_llm):
|
||||
asyncio.run(
|
||||
ChatService(db, registry).handle(
|
||||
mock_ctx,
|
||||
bot_name="alfred",
|
||||
message="hello",
|
||||
system_prompt="you are a butler",
|
||||
response_prefix="alfred response",
|
||||
)
|
||||
)
|
||||
|
||||
prompts = captured["prompts"]
|
||||
assert prompts[0] == {"role": "user", "content": "old question"}
|
||||
assert prompts[-1] == {"role": "user", "content": "hello"}
|
||||
registry.execute.assert_called_once_with(
|
||||
"get_channel_members", {}, channel=mock_ctx.channel
|
||||
)
|
||||
assert any("looking at the channel members" in t for t in _sent_texts(mock_ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SpeechService
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _speech_service(
|
||||
tts: MagicMock | None,
|
||||
manager: MagicMock | None = None,
|
||||
make_file: MagicMock | None = None,
|
||||
) -> SpeechService:
|
||||
return SpeechService(
|
||||
MagicMock(), manager or MagicMock(), tts, make_file or _file_factory()
|
||||
)
|
||||
|
||||
|
||||
def test_speak_tts_not_initialized(mock_ctx: MagicMock) -> None:
|
||||
"""No TTS engine means a clear error, no LLM or TTS calls."""
|
||||
svc = _speech_service(None)
|
||||
asyncio.run(svc.speak(mock_ctx, message="hello world"))
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "TTS engine not initialized" in call_args
|
||||
|
||||
|
||||
def test_speak_empty_message(mock_ctx: MagicMock) -> None:
|
||||
"""Empty text is rejected before any TTS work."""
|
||||
svc = _speech_service(MagicMock())
|
||||
asyncio.run(svc.speak(mock_ctx, message=""))
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Please provide text" in call_args
|
||||
|
||||
|
||||
def test_speak_too_long(mock_ctx: MagicMock) -> None:
|
||||
"""Oversized text is rejected without calling the TTS engine."""
|
||||
tts = MagicMock()
|
||||
svc = _speech_service(tts)
|
||||
asyncio.run(svc.speak(mock_ctx, message="a" * (MAX_SPEAK_LENGTH + 1)))
|
||||
tts.generate_audio.assert_not_called()
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Text too long to speak" in call_args
|
||||
|
||||
|
||||
def test_speak_partial_audio_warns(mock_ctx: MagicMock) -> None:
|
||||
"""Partial audio triggers a warning line."""
|
||||
tts = MagicMock()
|
||||
tts.generate_audio.return_value = MagicMock(
|
||||
audio=MagicMock(), partial=True, failed_chunks=1
|
||||
)
|
||||
manager = MagicMock()
|
||||
manager.list_custom_bots.return_value = []
|
||||
svc = _speech_service(tts, manager=manager)
|
||||
asyncio.run(svc.speak(mock_ctx, message="hello world"))
|
||||
assert any("audio may be incomplete" in t for t in _sent_texts(mock_ctx))
|
||||
|
||||
|
||||
def test_speak_plain_text(mock_ctx: MagicMock) -> None:
|
||||
"""Plain text is spoken and the audio file is sent."""
|
||||
tts = MagicMock()
|
||||
tts.generate_audio.return_value = MagicMock(audio=MagicMock(), partial=False)
|
||||
manager = MagicMock()
|
||||
manager.list_custom_bots.return_value = []
|
||||
svc = _speech_service(tts, manager=manager)
|
||||
asyncio.run(svc.speak(mock_ctx, message="hello world"))
|
||||
tts.generate_audio.assert_called_once()
|
||||
assert mock_ctx.send.call_count >= 2
|
||||
|
||||
|
||||
def test_speak_with_custom_bot(mock_ctx: MagicMock) -> None:
|
||||
"""A bot prefix routes through the LLM, then speaks the response."""
|
||||
tts = MagicMock()
|
||||
tts.generate_audio.return_value = MagicMock(audio=MagicMock(), partial=False)
|
||||
manager = MagicMock()
|
||||
manager.list_custom_bots.return_value = [
|
||||
("alfred", "british butler", "user-123"),
|
||||
]
|
||||
manager.get_custom_bot.return_value = (
|
||||
"alfred",
|
||||
"british butler",
|
||||
"user-123",
|
||||
"2024-01-01",
|
||||
)
|
||||
svc = _speech_service(tts, manager=manager)
|
||||
|
||||
with patch(
|
||||
"vibe_bot.llm_client.chat_completion_with_tools",
|
||||
new=AsyncMock(return_value="The time is 3pm"),
|
||||
):
|
||||
asyncio.run(svc.speak(mock_ctx, message="alfred what time is it"))
|
||||
|
||||
tts.generate_audio.assert_called_once()
|
||||
assert any("**alfred**:" in t for t in _sent_texts(mock_ctx))
|
||||
|
||||
|
||||
def test_speak_uses_requested_voice(mock_ctx: MagicMock) -> None:
|
||||
"""A trailing --voice flag selects that voice for the TTS call."""
|
||||
tts = MagicMock()
|
||||
tts.generate_audio.return_value = MagicMock(audio=MagicMock(), partial=False)
|
||||
manager = MagicMock()
|
||||
manager.list_custom_bots.return_value = []
|
||||
svc = _speech_service(tts, manager=manager)
|
||||
asyncio.run(svc.speak(mock_ctx, message="hello world --voice af_bella"))
|
||||
assert tts.generate_audio.call_args.kwargs["voice"] == "af_bella"
|
||||
|
||||
|
||||
def test_speak_mid_text_voice_flag_spoken_verbatim(mock_ctx: MagicMock) -> None:
|
||||
"""A --voice mid-message is preserved as speech and the default voice is used."""
|
||||
tts = MagicMock()
|
||||
tts.generate_audio.return_value = MagicMock(audio=MagicMock(), partial=False)
|
||||
manager = MagicMock()
|
||||
manager.list_custom_bots.return_value = []
|
||||
svc = _speech_service(tts, manager=manager)
|
||||
message = "hello --voice af_bella world"
|
||||
asyncio.run(svc.speak(mock_ctx, message=message))
|
||||
assert tts.generate_audio.call_args.args[0] == message
|
||||
assert tts.generate_audio.call_args.kwargs["voice"] == TTS_VOICE
|
||||
|
||||
|
||||
def test_speak_unknown_voice(mock_ctx: MagicMock) -> None:
|
||||
"""An unknown voice is rejected before any TTS call."""
|
||||
tts = MagicMock()
|
||||
manager = MagicMock()
|
||||
manager.list_custom_bots.return_value = []
|
||||
svc = _speech_service(tts, manager=manager)
|
||||
asyncio.run(svc.speak(mock_ctx, message="hello --voice not_a_real_voice"))
|
||||
tts.generate_audio.assert_not_called()
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Unknown voice" in call_args
|
||||
|
||||
|
||||
def test_speak_language_lookup_uses_precomputed_dict(
|
||||
mock_ctx: MagicMock,
|
||||
) -> None:
|
||||
"""The speak hot path resolves the language via VOICE_LANGUAGES.get().
|
||||
|
||||
The dict is built once at import from VOICES_LIST (covering every
|
||||
catalog voice) and the per-speak lookup is a single dict get — no
|
||||
per-call scan of the category list.
|
||||
"""
|
||||
from vibe_bot.config import VOICES_LIST
|
||||
from vibe_bot.services import speech_service
|
||||
|
||||
class LanguageLookupSpy:
|
||||
"""Counts .get() lookups on the voice->language mapping."""
|
||||
|
||||
def __init__(self, data: dict[str, str]) -> None:
|
||||
self.data = data
|
||||
self.lookups = 0
|
||||
|
||||
def get(self, key: str, default: str | None = None) -> str | None:
|
||||
self.lookups += 1
|
||||
return self.data.get(key, default)
|
||||
|
||||
def __contains__(self, key: object) -> bool:
|
||||
return key in self.data
|
||||
|
||||
counting = LanguageLookupSpy(speech_service.VOICE_LANGUAGES)
|
||||
assert set(counting.data) == {
|
||||
voice for category in VOICES_LIST.values() for voice in category["voices"]
|
||||
}
|
||||
|
||||
tts = MagicMock()
|
||||
tts.generate_audio.return_value = MagicMock(audio=MagicMock(), partial=False)
|
||||
manager = MagicMock()
|
||||
manager.list_custom_bots.return_value = []
|
||||
svc = _speech_service(tts, manager=manager)
|
||||
|
||||
with patch.object(speech_service, "VOICE_LANGUAGES", counting):
|
||||
asyncio.run(svc.speak(mock_ctx, message="hello --voice bf_alice"))
|
||||
|
||||
assert counting.lookups == 1
|
||||
assert tts.generate_audio.call_args.kwargs["lang"] == "en-gb"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConversationService
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _conversation_service(
|
||||
manager: MagicMock | None = None,
|
||||
) -> ConversationService:
|
||||
return ConversationService(manager or MagicMock())
|
||||
|
||||
|
||||
def test_flip_counter() -> None:
|
||||
"""flip_counter toggles between 0 and 1."""
|
||||
assert flip_counter(0) == 1
|
||||
assert flip_counter(1) == 0
|
||||
|
||||
|
||||
def test_talkforme_topic_too_long(mock_ctx: MagicMock) -> None:
|
||||
"""Oversized topics are rejected before any LLM call."""
|
||||
svc = _conversation_service()
|
||||
asyncio.run(svc.run(mock_ctx, "a", "b", "3", "x" * (MAX_TOPIC_LENGTH + 1)))
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Topic too long" in call_args
|
||||
|
||||
|
||||
def test_talkforme_bot1_not_found(mock_ctx: MagicMock) -> None:
|
||||
"""A missing first bot is reported and the run stops."""
|
||||
manager = MagicMock()
|
||||
manager.get_custom_bot.return_value = None
|
||||
svc = _conversation_service(manager=manager)
|
||||
asyncio.run(svc.run(mock_ctx, "ghost", "alfred", "3", "cats"))
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "ghost is not a real bot" in call_args
|
||||
|
||||
|
||||
def test_talkforme_invalid_limit(mock_ctx: MagicMock) -> None:
|
||||
"""A non-integer limit is rejected after both bots are found."""
|
||||
manager = MagicMock()
|
||||
manager.get_custom_bot.side_effect = _fake_bot
|
||||
svc = _conversation_service(manager=manager)
|
||||
asyncio.run(svc.run(mock_ctx, "a", "b", "abc", "cats"))
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Message limit must be an integer" in call_args
|
||||
|
||||
|
||||
def test_talkforme_first_reply_chunked(mock_ctx: MagicMock) -> None:
|
||||
"""Long first replies are sent in multiple chunks."""
|
||||
manager = MagicMock()
|
||||
manager.get_custom_bot.side_effect = _fake_bot
|
||||
svc = _conversation_service(manager=manager)
|
||||
with patch(
|
||||
"vibe_bot.llm_client.chat_completion_with_history",
|
||||
new=AsyncMock(return_value="y" * 2500),
|
||||
):
|
||||
asyncio.run(svc.run(mock_ctx, "a", "b", "1", "cats"))
|
||||
assert mock_ctx.send.call_count >= 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ImageService (doodlebob / retcon)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _image_service(db: MagicMock | None = None) -> ImageService:
|
||||
return ImageService(db or MagicMock(), _file_factory())
|
||||
|
||||
|
||||
def test_doodlebob_prompt_too_long(mock_ctx: MagicMock) -> None:
|
||||
"""Oversized prompts are rejected before any LLM call."""
|
||||
svc = _image_service()
|
||||
asyncio.run(svc.generate(mock_ctx, message="a" * (MAX_IMAGE_PROMPT_LENGTH + 1)))
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Prompt too long" in call_args
|
||||
|
||||
|
||||
def test_doodlebob_generate_success(mock_ctx: MagicMock) -> None:
|
||||
"""A full generate flow ends with an image file and a completion line."""
|
||||
db = MagicMock()
|
||||
db.get_image_generation_time_estimate.return_value = None
|
||||
svc = _image_service(db)
|
||||
|
||||
b64 = base64.b64encode(b"fake image").decode()
|
||||
with (
|
||||
patch(
|
||||
"vibe_bot.llm_client.chat_completion_instruct",
|
||||
new=AsyncMock(side_effect=["square", "a detailed prompt", "PASS"]),
|
||||
),
|
||||
patch(
|
||||
"vibe_bot.llm_client.image_generation",
|
||||
new=AsyncMock(return_value=b64),
|
||||
),
|
||||
):
|
||||
asyncio.run(svc.generate(mock_ctx, message="a centaur in a field"))
|
||||
|
||||
assert db.record_image_generation_time.called
|
||||
assert any("Strike complete" in t for t in _sent_texts(mock_ctx))
|
||||
|
||||
|
||||
def test_doodlebob_failed_generation(mock_ctx: MagicMock) -> None:
|
||||
"""An empty image-generation response is reported as a failure."""
|
||||
db = MagicMock()
|
||||
db.get_image_generation_time_estimate.return_value = None
|
||||
svc = _image_service(db)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe_bot.llm_client.chat_completion_instruct",
|
||||
new=AsyncMock(side_effect=["square", "a detailed prompt", "PASS"]),
|
||||
),
|
||||
patch(
|
||||
"vibe_bot.llm_client.image_generation",
|
||||
new=AsyncMock(return_value=""),
|
||||
),
|
||||
):
|
||||
asyncio.run(svc.generate(mock_ctx, message="a centaur"))
|
||||
|
||||
assert any("Failed to generate image" in t for t in _sent_texts(mock_ctx))
|
||||
assert not db.record_image_generation_time.called
|
||||
|
||||
|
||||
def test_doodlebob_reports_estimate(mock_ctx: MagicMock) -> None:
|
||||
"""A prior-history estimate produces a Drone ETA line."""
|
||||
db = MagicMock()
|
||||
db.get_image_generation_time_estimate.return_value = 12.5
|
||||
svc = _image_service(db)
|
||||
|
||||
b64 = base64.b64encode(b"fake image").decode()
|
||||
with (
|
||||
patch(
|
||||
"vibe_bot.llm_client.chat_completion_instruct",
|
||||
new=AsyncMock(side_effect=["square", "a detailed prompt", "PASS"]),
|
||||
),
|
||||
patch(
|
||||
"vibe_bot.llm_client.image_generation",
|
||||
new=AsyncMock(return_value=b64),
|
||||
),
|
||||
):
|
||||
asyncio.run(svc.generate(mock_ctx, message="a centaur"))
|
||||
|
||||
assert any("Drone ETA" in t for t in _sent_texts(mock_ctx))
|
||||
|
||||
|
||||
def test_doodlebob_no_estimate_without_history(mock_ctx: MagicMock) -> None:
|
||||
"""No estimate means no Drone ETA line."""
|
||||
db = MagicMock()
|
||||
db.get_image_generation_time_estimate.return_value = None
|
||||
svc = _image_service(db)
|
||||
|
||||
b64 = base64.b64encode(b"fake image").decode()
|
||||
with (
|
||||
patch(
|
||||
"vibe_bot.llm_client.chat_completion_instruct",
|
||||
new=AsyncMock(side_effect=["square", "a detailed prompt", "PASS"]),
|
||||
),
|
||||
patch(
|
||||
"vibe_bot.llm_client.image_generation",
|
||||
new=AsyncMock(return_value=b64),
|
||||
),
|
||||
):
|
||||
asyncio.run(svc.generate(mock_ctx, message="a centaur"))
|
||||
|
||||
assert not any("Drone ETA" in t for t in _sent_texts(mock_ctx))
|
||||
|
||||
|
||||
def test_doodlebob_empty_prompt_stops(mock_ctx: MagicMock) -> None:
|
||||
"""An empty image-prompt response stops the flow without generating."""
|
||||
db = MagicMock()
|
||||
svc = _image_service(db)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe_bot.llm_client.chat_completion_instruct",
|
||||
new=AsyncMock(return_value=""),
|
||||
),
|
||||
patch("vibe_bot.llm_client.image_generation") as mock_gen,
|
||||
):
|
||||
asyncio.run(svc.generate(mock_ctx, message="a centaur"))
|
||||
|
||||
mock_gen.assert_not_called()
|
||||
assert not db.record_image_generation_time.called
|
||||
|
||||
|
||||
def test_doodlebob_decode_failure(mock_ctx: MagicMock) -> None:
|
||||
"""Invalid base64 from the generation API is reported as a failure."""
|
||||
db = MagicMock()
|
||||
db.get_image_generation_time_estimate.return_value = None
|
||||
svc = _image_service(db)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe_bot.llm_client.chat_completion_instruct",
|
||||
new=AsyncMock(side_effect=["square", "a detailed prompt", "PASS"]),
|
||||
),
|
||||
patch(
|
||||
"vibe_bot.llm_client.image_generation",
|
||||
new=AsyncMock(return_value="abcde!!!"),
|
||||
),
|
||||
):
|
||||
asyncio.run(svc.generate(mock_ctx, message="a centaur"))
|
||||
|
||||
assert any(
|
||||
"Failed to process the generated image" in t for t in _sent_texts(mock_ctx)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("response", "expected"),
|
||||
[
|
||||
("portrait", "portrait"),
|
||||
("landscape", "landscape"),
|
||||
("square", "square"),
|
||||
("PORTRAIT", "portrait"),
|
||||
("I think landscape", "landscape"),
|
||||
],
|
||||
)
|
||||
def test_select_image_layout_returns_parsed(
|
||||
response: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""select_image_layout parses the LLM's layout choice."""
|
||||
with patch(
|
||||
"vibe_bot.llm_client.chat_completion_instruct",
|
||||
new=AsyncMock(return_value=response),
|
||||
):
|
||||
result = asyncio.run(select_image_layout("a tall tree"))
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_select_image_layout_uses_minimal_token_budget() -> None:
|
||||
"""Layout selection is a one-word answer, so max_tokens is 2."""
|
||||
mock_llm = AsyncMock(return_value="square")
|
||||
with patch("vibe_bot.llm_client.chat_completion_instruct", new=mock_llm):
|
||||
assert asyncio.run(select_image_layout("a tall tree")) == "square"
|
||||
assert mock_llm.call_args.kwargs["max_tokens"] == 2
|
||||
|
||||
|
||||
def test_doodlebob_latency_within_budget(mock_ctx: MagicMock) -> None:
|
||||
"""End-to-end doodlebob latency with 50ms simulated per LLM/image call.
|
||||
|
||||
Hermetic latency figure: four mocked calls (layout, prompt, verify,
|
||||
generate) at 50ms each must dominate the wall time; the overhead on
|
||||
top of the simulated 200ms stays far below the budget.
|
||||
"""
|
||||
import time
|
||||
|
||||
db = MagicMock()
|
||||
db.get_image_generation_time_estimate.return_value = None
|
||||
svc = _image_service(db)
|
||||
|
||||
b64 = base64.b64encode(b"fake image").decode()
|
||||
responses = ["square", "a detailed prompt", "PASS"]
|
||||
|
||||
async def slow_instruct(**_kwargs: Any) -> str:
|
||||
await asyncio.sleep(0.05)
|
||||
return responses.pop(0)
|
||||
|
||||
async def slow_generate(**_kwargs: Any) -> str:
|
||||
await asyncio.sleep(0.05)
|
||||
return b64
|
||||
|
||||
with (
|
||||
patch("vibe_bot.llm_client.chat_completion_instruct", new=slow_instruct),
|
||||
patch("vibe_bot.llm_client.image_generation", new=slow_generate),
|
||||
):
|
||||
start = time.monotonic()
|
||||
asyncio.run(svc.generate(mock_ctx, message="a centaur in a field"))
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
assert elapsed >= 0.2
|
||||
assert elapsed < 5.0
|
||||
assert any("Strike complete" in t for t in _sent_texts(mock_ctx))
|
||||
|
||||
|
||||
def test_verify_image_prompt_pass_keeps_prompt() -> None:
|
||||
"""A PASS verdict keeps the original prompt unchanged."""
|
||||
with patch(
|
||||
"vibe_bot.llm_client.chat_completion_instruct",
|
||||
new=AsyncMock(return_value="PASS"),
|
||||
):
|
||||
result = asyncio.run(verify_image_prompt("a centaur", "a detailed prompt"))
|
||||
assert result == "a detailed prompt"
|
||||
|
||||
|
||||
def test_verify_image_prompt_correction_replaces() -> None:
|
||||
"""A non-passing verdict is used as the corrected prompt."""
|
||||
correction = "a rewritten prompt that is definitely long enough to be a fix"
|
||||
with patch(
|
||||
"vibe_bot.llm_client.chat_completion_instruct",
|
||||
new=AsyncMock(return_value=correction),
|
||||
):
|
||||
result = asyncio.run(verify_image_prompt("a centaur", "a detailed prompt"))
|
||||
assert result == correction
|
||||
|
||||
|
||||
def test_verify_image_prompt_empty_falls_back() -> None:
|
||||
"""An empty verdict falls back to the original prompt."""
|
||||
with patch(
|
||||
"vibe_bot.llm_client.chat_completion_instruct",
|
||||
new=AsyncMock(return_value=""),
|
||||
):
|
||||
result = asyncio.run(verify_image_prompt("a centaur", "a detailed prompt"))
|
||||
assert result == "a detailed prompt"
|
||||
|
||||
|
||||
def test_retcon_no_attachments(mock_ctx: MagicMock) -> None:
|
||||
"""retcon with no attachments asks the user to attach an image."""
|
||||
svc = _image_service()
|
||||
mock_ctx.message.attachments = []
|
||||
asyncio.run(svc.edit(mock_ctx, message="make it blue"))
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Please attach an image" in call_args
|
||||
|
||||
|
||||
def test_retcon_rejected_url_not_downloaded(mock_ctx: MagicMock) -> None:
|
||||
"""A non-Discord attachment URL is refused before any download happens."""
|
||||
svc = _image_service()
|
||||
attachment = MagicMock()
|
||||
attachment.url = "https://evil.example.com/img.png"
|
||||
mock_ctx.message.attachments = [attachment]
|
||||
|
||||
mock_edit = AsyncMock(return_value="")
|
||||
with (
|
||||
patch("vibe_bot.llm_client.image_edit", new=mock_edit),
|
||||
patch("requests.get") as mock_get,
|
||||
):
|
||||
asyncio.run(svc.edit(mock_ctx, message="make it blue"))
|
||||
|
||||
mock_get.assert_not_called()
|
||||
mock_edit.assert_not_called()
|
||||
assert any("Please attach an image" in t for t in _sent_texts(mock_ctx))
|
||||
|
||||
|
||||
def test_retcon_prompt_too_long(mock_ctx: MagicMock) -> None:
|
||||
"""Oversized retcon prompts are rejected before any download."""
|
||||
svc = _image_service()
|
||||
asyncio.run(svc.edit(mock_ctx, message="a" * (MAX_IMAGE_PROMPT_LENGTH + 1)))
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Prompt too long" in call_args
|
||||
|
||||
|
||||
def test_retcon_image_edit_empty(mock_ctx: MagicMock) -> None:
|
||||
"""An empty edit response is reported as a failure."""
|
||||
svc = _image_service()
|
||||
attachment = MagicMock()
|
||||
attachment.url = "https://cdn.discordapp.com/attachments/1/2/3/img.png"
|
||||
mock_ctx.message.attachments = [attachment]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe_bot.services.image_service._download_image_bytes",
|
||||
return_value=b"fake image bytes",
|
||||
),
|
||||
patch(
|
||||
"vibe_bot.llm_client.image_edit",
|
||||
new=AsyncMock(return_value=""),
|
||||
),
|
||||
):
|
||||
asyncio.run(svc.edit(mock_ctx, message="make it blue"))
|
||||
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Failed to edit the image" in call_args
|
||||
|
||||
|
||||
def test_retcon_success(mock_ctx: MagicMock) -> None:
|
||||
"""A successful edit sends the edited image file."""
|
||||
svc = _image_service()
|
||||
attachment = MagicMock()
|
||||
attachment.url = "https://cdn.discordapp.com/attachments/1/2/3/img.png"
|
||||
mock_ctx.message.attachments = [attachment]
|
||||
|
||||
b64 = base64.b64encode(b"edited").decode()
|
||||
with (
|
||||
patch(
|
||||
"vibe_bot.services.image_service._download_image_bytes",
|
||||
return_value=b"fake image bytes",
|
||||
),
|
||||
patch(
|
||||
"vibe_bot.llm_client.image_edit",
|
||||
new=AsyncMock(return_value=b64),
|
||||
),
|
||||
):
|
||||
asyncio.run(svc.edit(mock_ctx, message="make it blue"))
|
||||
|
||||
assert any("Rewriting history" in t for t in _sent_texts(mock_ctx))
|
||||
|
||||
|
||||
def test_retcon_edit_decode_failure(mock_ctx: MagicMock) -> None:
|
||||
"""Invalid base64 from the edit API is reported as a processing failure."""
|
||||
svc = _image_service()
|
||||
attachment = MagicMock()
|
||||
attachment.url = "https://cdn.discordapp.com/attachments/1/2/3/img.png"
|
||||
mock_ctx.message.attachments = [attachment]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe_bot.services.image_service._download_image_bytes",
|
||||
return_value=b"fake image bytes",
|
||||
),
|
||||
patch(
|
||||
"vibe_bot.llm_client.image_edit",
|
||||
new=AsyncMock(return_value="abcde!!!"),
|
||||
),
|
||||
):
|
||||
asyncio.run(svc.edit(mock_ctx, message="make it blue"))
|
||||
|
||||
call_args = mock_ctx.send.call_args[0][0]
|
||||
assert "Failed to process the edited image" in call_args
|
||||
|
||||
|
||||
def test_allowed_image_url() -> None:
|
||||
"""Only Discord CDN hosts are allowed for retcon downloads."""
|
||||
assert _allowed_image_url("https://cdn.discordapp.com/a/b/c.png")
|
||||
assert _allowed_image_url("https://media.discordapp.net/a/b/c.png")
|
||||
assert not _allowed_image_url("https://example.com/a/b/c.png")
|
||||
assert not _allowed_image_url("https://evilcdn.com/a/b/c.png")
|
||||
assert not _allowed_image_url("http://[::1")
|
||||
|
||||
|
||||
def test_download_image_bytes_success() -> None:
|
||||
"""An allowed Discord URL is downloaded and its chunks joined."""
|
||||
response = MagicMock()
|
||||
response.headers = {}
|
||||
response.iter_content.return_value = [b"abc", b"", b"def"]
|
||||
response.raise_for_status.return_value = None
|
||||
with patch(
|
||||
"vibe_bot.services.image_service.requests.get",
|
||||
return_value=response,
|
||||
) as mock_get:
|
||||
data = _download_image_bytes("https://cdn.discordapp.com/a/b/c.png")
|
||||
mock_get.assert_called_once()
|
||||
assert data == b"abcdef"
|
||||
|
||||
|
||||
def test_download_image_bytes_request_failure() -> None:
|
||||
"""A failing download returns None instead of raising."""
|
||||
with patch(
|
||||
"vibe_bot.services.image_service.requests.get",
|
||||
side_effect=requests.RequestException("boom"),
|
||||
):
|
||||
assert _download_image_bytes("https://cdn.discordapp.com/a/b/c.png") is None
|
||||
|
||||
|
||||
def test_download_image_bytes_content_length_cap() -> None:
|
||||
"""A declared Content-Length above the cap is refused before streaming."""
|
||||
response = MagicMock()
|
||||
response.headers = {"Content-Length": str(MAX_IMAGE_DOWNLOAD_BYTES + 1)}
|
||||
response.iter_content = MagicMock()
|
||||
with patch("vibe_bot.services.image_service.requests.get", return_value=response):
|
||||
assert _download_image_bytes("https://cdn.discordapp.com/a/b/c.png") is None
|
||||
response.iter_content.assert_not_called()
|
||||
|
||||
|
||||
def test_download_image_bytes_streaming_cap() -> None:
|
||||
"""Streaming past the size cap is refused even without a Content-Length."""
|
||||
response = MagicMock()
|
||||
response.headers = {}
|
||||
response.iter_content.return_value = [b"a" * (MAX_IMAGE_DOWNLOAD_BYTES + 1)]
|
||||
with patch("vibe_bot.services.image_service.requests.get", return_value=response):
|
||||
assert _download_image_bytes("https://cdn.discordapp.com/a/b/c.png") is None
|
||||
|
||||
|
||||
def test_download_image_bytes_bad_content_length() -> None:
|
||||
"""A non-numeric Content-Length is ignored and streaming proceeds."""
|
||||
response = MagicMock()
|
||||
response.headers = {"Content-Length": "not-a-number"}
|
||||
response.iter_content.return_value = [b"xyz"]
|
||||
response.raise_for_status.return_value = None
|
||||
with patch("vibe_bot.services.image_service.requests.get", return_value=response):
|
||||
assert _download_image_bytes("https://cdn.discordapp.com/a/b/c.png") == b"xyz"
|
||||
|
||||
|
||||
def test_download_image_bytes_stream_error() -> None:
|
||||
"""A mid-stream request error returns None instead of raising."""
|
||||
response = MagicMock()
|
||||
response.headers = {}
|
||||
response.iter_content.side_effect = requests.RequestException("stream died")
|
||||
with patch("vibe_bot.services.image_service.requests.get", return_value=response):
|
||||
assert _download_image_bytes("https://cdn.discordapp.com/a/b/c.png") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_voice_flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_voice_flag_no_flag() -> None:
|
||||
"""Without a trailing flag the message is returned unchanged."""
|
||||
assert parse_voice_flag("hello world") == ("hello world", None)
|
||||
|
||||
|
||||
def test_parse_voice_flag_trailing() -> None:
|
||||
"""A trailing --voice flag is split off."""
|
||||
assert parse_voice_flag("hello world --voice af_bella") == (
|
||||
"hello world",
|
||||
"af_bella",
|
||||
)
|
||||
|
||||
|
||||
def test_parse_voice_flag_mid_text_preserved() -> None:
|
||||
"""A --voice that is not at the end is treated as speech, not a flag."""
|
||||
assert parse_voice_flag("hello --voice af_bella world") == (
|
||||
"hello --voice af_bella world",
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_voice_flag_missing_value() -> None:
|
||||
"""A --voice with no value (or only trailing spaces) is not a flag."""
|
||||
assert parse_voice_flag("hello --voice") == ("hello --voice", None)
|
||||
assert parse_voice_flag("hello --voice ") == ("hello --voice ", None)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for the textutil module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe_bot.textutil import split_message
|
||||
|
||||
|
||||
def test_split_message_empty() -> None:
|
||||
assert split_message("") == []
|
||||
|
||||
|
||||
def test_split_message_short_text_single_chunk() -> None:
|
||||
assert split_message("hello") == ["hello"]
|
||||
|
||||
|
||||
def test_split_message_exact_limit() -> None:
|
||||
text = "a" * 1900
|
||||
chunks = split_message(text)
|
||||
assert chunks == [text]
|
||||
assert len(chunks) == 1
|
||||
|
||||
|
||||
def test_split_message_just_over_limit_no_newline() -> None:
|
||||
text = "a" * 1901
|
||||
chunks = split_message(text)
|
||||
assert "".join(chunks) == text
|
||||
assert all(len(c) <= 1900 for c in chunks)
|
||||
assert chunks == ["a" * 1900, "a"]
|
||||
|
||||
|
||||
def test_split_message_no_newlines_long() -> None:
|
||||
text = "x" * 5000
|
||||
chunks = split_message(text)
|
||||
assert "".join(chunks) == text
|
||||
assert all(len(c) <= 1900 for c in chunks)
|
||||
|
||||
|
||||
def test_split_message_respects_newlines() -> None:
|
||||
# Many short lines spanning several chunks: boundaries fall on newlines.
|
||||
text = "\n".join(f"line {i}" for i in range(1, 501))
|
||||
chunks = split_message(text)
|
||||
assert "".join(chunks) == text
|
||||
assert all(len(c) <= 1900 for c in chunks)
|
||||
assert len(chunks) > 1
|
||||
# Every chunk except the last ends on a newline (split at a line boundary).
|
||||
for chunk in chunks[:-1]:
|
||||
assert chunk.endswith("\n")
|
||||
|
||||
|
||||
def test_split_message_long_line_hard_split() -> None:
|
||||
# A single line longer than the limit must be hard-split.
|
||||
text = "a" * 5000 + "\n" + "short"
|
||||
chunks = split_message(text)
|
||||
assert "".join(chunks) == text
|
||||
assert all(len(c) <= 1900 for c in chunks)
|
||||
|
||||
|
||||
def test_split_message_emoji_round_trip() -> None:
|
||||
# Multi-codepoint emoji at split boundaries must not corrupt on join.
|
||||
text = "hello 🌍 world 👨👩👧👦 end " * 500
|
||||
for limit in (1, 10, 100, 1900):
|
||||
chunks = split_message(text, limit)
|
||||
assert "".join(chunks) == text
|
||||
assert all(len(c) <= limit for c in chunks)
|
||||
|
||||
|
||||
def test_split_message_property_round_trip_corpus() -> None:
|
||||
corpus = [
|
||||
"",
|
||||
"a",
|
||||
"a" * 1900,
|
||||
"a" * 1901,
|
||||
"line1\nline2\nline3",
|
||||
"para one\n\npara two\n\npara three",
|
||||
"no newline " * 1000,
|
||||
"emoji 🎉 " * 1000,
|
||||
"👨👩👧👦" * 2000,
|
||||
"mixed 🌍 text and 日本語 and emoji 🎊 here",
|
||||
# NFD combining marks (e + U+0301, a + U+0308) straddle split points.
|
||||
"cafe\u0301 " * 1000,
|
||||
"a\u0308\u0301 b\u0327\u0301 c\u0308 " * 1000,
|
||||
# Code spans with backticks and spaces.
|
||||
"`inline code` and `x = 1` spans " * 500,
|
||||
"``double backtick`` `single` " * 500,
|
||||
]
|
||||
for text in corpus:
|
||||
for limit in (1, 5, 100, 1900):
|
||||
chunks = split_message(text, limit)
|
||||
assert "".join(chunks) == text, f"round-trip failed for limit={limit}"
|
||||
assert all(len(c) <= limit for c in chunks)
|
||||
|
||||
|
||||
def test_split_message_exact_multiple_of_limit() -> None:
|
||||
"""Input that is an exact multiple of the limit splits into full chunks."""
|
||||
for limit in (1, 5, 100, 1900):
|
||||
text = "z" * (limit * 4)
|
||||
chunks = split_message(text, limit)
|
||||
assert "".join(chunks) == text
|
||||
assert len(chunks) == 4
|
||||
assert all(len(c) == limit for c in chunks)
|
||||
|
||||
|
||||
def test_split_message_limit_must_be_positive() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
split_message("hello", 0)
|
||||
with pytest.raises(ValueError):
|
||||
split_message("hello", -5)
|
||||
+21
-11
@@ -22,7 +22,7 @@ def test_tts_engine_init(mock_kokoro_tts: MagicMock) -> None:
|
||||
|
||||
|
||||
def test_generate_audio(mock_kokoro_tts: MagicMock) -> None:
|
||||
"""Test audio generation returns a BytesIO object."""
|
||||
"""Test audio generation returns a full (non-partial) AudioResult."""
|
||||
from io import BytesIO
|
||||
|
||||
from vibe_bot.tts import TTSEngine
|
||||
@@ -30,9 +30,11 @@ def test_generate_audio(mock_kokoro_tts: MagicMock) -> None:
|
||||
engine = TTSEngine("/tmp/test-model.onnx", "/tmp/test-voices.bin")
|
||||
result = engine.generate_audio("hello world this is a test")
|
||||
|
||||
assert isinstance(result, BytesIO)
|
||||
result.seek(0)
|
||||
data = result.read()
|
||||
assert isinstance(result.audio, BytesIO)
|
||||
assert result.partial is False
|
||||
assert result.failed_chunks == 0
|
||||
result.audio.seek(0)
|
||||
data = result.audio.read()
|
||||
assert len(data) > 0
|
||||
|
||||
|
||||
@@ -57,7 +59,8 @@ def test_generate_audio_single_chunk(mock_kokoro_tts: MagicMock) -> None:
|
||||
engine = TTSEngine("/tmp/test-model.onnx", "/tmp/test-voices.bin")
|
||||
result = engine.generate_audio("single chunk text")
|
||||
|
||||
assert isinstance(result, BytesIO)
|
||||
assert isinstance(result.audio, BytesIO)
|
||||
assert result.partial is False
|
||||
mock_kokoro_tts["process_chunk_sequential"].assert_called_once()
|
||||
|
||||
|
||||
@@ -77,7 +80,9 @@ def test_generate_audio_multiple_chunks(mock_kokoro_tts: MagicMock) -> None:
|
||||
"this text is long enough to be split into multiple chunks",
|
||||
)
|
||||
|
||||
assert isinstance(result, BytesIO)
|
||||
assert isinstance(result.audio, BytesIO)
|
||||
assert result.partial is False
|
||||
assert result.failed_chunks == 0
|
||||
assert mock_kokoro_tts["process_chunk_sequential"].call_count == 3
|
||||
|
||||
|
||||
@@ -108,7 +113,12 @@ def test_generate_audio_chunk_failure(mock_kokoro_tts: MagicMock) -> None:
|
||||
engine = TTSEngine("/tmp/test-model.onnx", "/tmp/test-voices.bin")
|
||||
result = engine.generate_audio("good chunk bad chunk another good")
|
||||
|
||||
assert isinstance(result, BytesIO)
|
||||
# Audio is still produced for the good chunks, but flagged as partial.
|
||||
assert isinstance(result.audio, BytesIO)
|
||||
assert result.partial is True
|
||||
assert result.failed_chunks == 1
|
||||
result.audio.seek(0)
|
||||
assert len(result.audio.read()) > 0
|
||||
|
||||
|
||||
def test_generate_audio_all_chunks_fail(mock_kokoro_tts: MagicMock) -> None:
|
||||
@@ -145,13 +155,13 @@ def test_generate_audio_returns_seekable(mock_kokoro_tts: MagicMock) -> None:
|
||||
engine = TTSEngine("/tmp/test-model.onnx", "/tmp/test-voices.bin")
|
||||
result = engine.generate_audio("hello world")
|
||||
|
||||
result.seek(0)
|
||||
data = result.read()
|
||||
result.audio.seek(0)
|
||||
data = result.audio.read()
|
||||
assert len(data) > 0
|
||||
|
||||
# Should be able to seek and read again
|
||||
result.seek(0)
|
||||
data2 = result.read()
|
||||
result.audio.seek(0)
|
||||
data2 = result.audio.read()
|
||||
assert data == data2
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Text chunking utilities for sending long content within Discord's limit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def split_message(text: str, limit: int = 1900) -> list[str]:
|
||||
"""Split ``text`` into chunks of at most ``limit`` characters.
|
||||
|
||||
Splits on newlines first so a line is never broken mid-text when
|
||||
avoidable; a single line longer than ``limit`` is hard-split by plain
|
||||
code-point slicing (Python ``str`` slicing is code-point safe, so no
|
||||
lone surrogates are produced). Multi-codepoint grapheme clusters (e.g.
|
||||
some emoji) are not protected — this matches the previous behavior and
|
||||
keeps the dependency footprint at zero.
|
||||
|
||||
Guarantees ``"".join(split_message(text, limit)) == text``.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
if limit <= 0:
|
||||
raise ValueError("limit must be a positive integer")
|
||||
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
for raw_line in text.splitlines(keepends=True):
|
||||
line = raw_line
|
||||
while len(line) > limit:
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = ""
|
||||
chunks.append(line[:limit])
|
||||
line = line[limit:]
|
||||
if len(current) + len(line) <= limit:
|
||||
current += line
|
||||
else:
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = line
|
||||
if current:
|
||||
chunks.append(current)
|
||||
return chunks
|
||||
+6
-4
@@ -33,13 +33,15 @@ def _format_member(member: Any) -> str:
|
||||
|
||||
|
||||
def get_channel_members_impl(channel: Any) -> str:
|
||||
"""Get a list of all members in the Discord channel the bot is part of.
|
||||
"""Get a list of the members of the guild the bot is a member of.
|
||||
|
||||
Use this tool when asked about who is in the channel, who the members are,
|
||||
or to get a roster of people present in the current channel.
|
||||
Discord has no per-channel membership, so the channel argument only locates
|
||||
the guild; the roster covers the whole guild, not just that channel. Use
|
||||
this tool when asked who is around, who the members are, or to get a
|
||||
roster of the people in the server.
|
||||
|
||||
Returns:
|
||||
A formatted string listing all members in the channel with their usernames,
|
||||
A formatted string listing the guild's members with their usernames,
|
||||
display names, and nicknames.
|
||||
|
||||
"""
|
||||
|
||||
+48
-13
@@ -3,9 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
|
||||
import numpy as np
|
||||
|
||||
# kokoro-tts and soundfile ship no type stubs upstream.
|
||||
import soundfile as sf # type: ignore[import-untyped]
|
||||
from kokoro_tts import ( # type: ignore[import-untyped]
|
||||
Kokoro,
|
||||
@@ -13,14 +16,31 @@ from kokoro_tts import ( # type: ignore[import-untyped]
|
||||
process_chunk_sequential,
|
||||
)
|
||||
|
||||
from vibe_bot.config import TTS_SPEED, TTS_VOICE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default voice settings
|
||||
DEFAULT_VOICE = "af_sarah"
|
||||
DEFAULT_SPEED = 1.0
|
||||
# Default voice settings (single source of truth: vibe_bot.config).
|
||||
DEFAULT_VOICE = TTS_VOICE
|
||||
DEFAULT_SPEED = TTS_SPEED
|
||||
DEFAULT_LANG = "en-us"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioResult:
|
||||
"""Audio output from a TTS generation.
|
||||
|
||||
Attributes:
|
||||
audio: The encoded audio (MP3) as a seekable BytesIO.
|
||||
partial: True if one or more text chunks failed to produce audio.
|
||||
failed_chunks: Number of text chunks that failed.
|
||||
"""
|
||||
|
||||
audio: BytesIO
|
||||
partial: bool
|
||||
failed_chunks: int
|
||||
|
||||
|
||||
class TTSEngine:
|
||||
"""Text-to-speech engine wrapper around Kokoro TTS."""
|
||||
|
||||
@@ -43,13 +63,14 @@ class TTSEngine:
|
||||
voice: str = DEFAULT_VOICE,
|
||||
speed: float = DEFAULT_SPEED,
|
||||
lang: str = DEFAULT_LANG,
|
||||
) -> BytesIO:
|
||||
"""Convert text to audio and return as BytesIO (MP3 format)."""
|
||||
) -> AudioResult:
|
||||
"""Convert text to audio and return an AudioResult (MP3 in .audio)."""
|
||||
all_samples: list[np.ndarray] = []
|
||||
sample_rate: int | None = None
|
||||
failed_chunks = 0
|
||||
|
||||
chunks: list[str] = list(chunk_text(text))
|
||||
logger.info("Split text into %d chunks", len(chunks))
|
||||
logger.debug("Split text into %d chunks", len(chunks))
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
try:
|
||||
@@ -60,15 +81,21 @@ class TTSEngine:
|
||||
speed,
|
||||
lang,
|
||||
)
|
||||
if samples is not None:
|
||||
if sample_rate is None:
|
||||
sample_rate = sr
|
||||
all_samples.append(np.asarray(samples))
|
||||
logger.info("Processed chunk %d/%d", i + 1, len(chunks))
|
||||
except Exception:
|
||||
logger.exception("Error processing chunk %d", i + 1)
|
||||
failed_chunks += 1
|
||||
continue
|
||||
|
||||
if samples is None:
|
||||
logger.warning("Chunk %d/%d produced no audio", i + 1, len(chunks))
|
||||
failed_chunks += 1
|
||||
continue
|
||||
|
||||
if sample_rate is None:
|
||||
sample_rate = sr
|
||||
all_samples.append(np.asarray(samples))
|
||||
logger.debug("Processed chunk %d/%d", i + 1, len(chunks))
|
||||
|
||||
if not all_samples:
|
||||
msg = "No audio samples generated - text may be invalid or too long"
|
||||
raise ValueError(msg)
|
||||
@@ -85,9 +112,17 @@ class TTSEngine:
|
||||
)
|
||||
buffer.seek(0)
|
||||
|
||||
logger.info(
|
||||
partial = failed_chunks > 0
|
||||
if partial:
|
||||
logger.warning(
|
||||
"TTS produced partial audio: %d of %d chunks failed",
|
||||
failed_chunks,
|
||||
len(chunks),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Generated MP3 audio: %d samples at %dHz",
|
||||
len(combined),
|
||||
sample_rate or 0,
|
||||
)
|
||||
return buffer
|
||||
return AudioResult(audio=buffer, partial=partial, failed_chunks=failed_chunks)
|
||||
|
||||
Reference in New Issue
Block a user