From 1f1048ee08e876bab1d2251188cf3792ebcae9af Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sun, 8 Mar 2026 16:07:36 -0500 Subject: [PATCH] refactor: migrate Discord bot to hexagonal adapter, remove old app/ directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord bot inbound adapter (adapters/inbound/discord_bot.py): - ChatService injected directly — no HTTP roundtrip to FastAPI API - No module-level singleton: create_bot() factory for construction - Pure functions extracted for testing: build_answer_embed, build_error_embed, parse_conversation_id - Uses message.reference.resolved cache before fetch_message - Error embeds never leak exception details - 19 new tests covering embed building, footer parsing, error safety Removed old app/ directory (9 files): - All functionality preserved in hexagonal domain/, adapters/, config/ - Old test_basic.py removed (superseded by 120 adapter/domain tests) Other changes: - docker-compose: api uses main:app, discord-bot uses run_discord.py with direct ChatService injection (no API dependency) - Removed unused openai dependency from pyproject.toml - Removed app/ from hatch build targets Test suite: 120 passed, 1 skipped Co-Authored-By: Claude Opus 4.6 --- adapters/inbound/discord_bot.py | 284 ++++++++++++++++++++++++++++++++ app/__init__.py | 1 - app/config.py | 53 ------ app/database.py | 162 ------------------ app/discord_bot.py | 276 ------------------------------- app/gitea.py | 95 ----------- app/llm.py | 179 -------------------- app/main.py | 198 ---------------------- app/models.py | 100 ----------- app/vector_store.py | 168 ------------------- docker-compose.yml | 28 ++-- pyproject.toml | 3 +- run_discord.py | 86 ++++++++++ tests/adapters/test_discord.py | 168 +++++++++++++++++++ tests/test_basic.py | 63 ------- uv.lock | 124 -------------- 16 files changed, 553 insertions(+), 1435 deletions(-) create mode 100644 adapters/inbound/discord_bot.py delete mode 100644 app/__init__.py delete mode 100644 app/config.py delete mode 100644 app/database.py delete mode 100644 app/discord_bot.py delete mode 100644 app/gitea.py delete mode 100644 app/llm.py delete mode 100644 app/main.py delete mode 100644 app/models.py delete mode 100644 app/vector_store.py create mode 100644 run_discord.py create mode 100644 tests/adapters/test_discord.py delete mode 100644 tests/test_basic.py diff --git a/adapters/inbound/discord_bot.py b/adapters/inbound/discord_bot.py new file mode 100644 index 0000000..d1ea296 --- /dev/null +++ b/adapters/inbound/discord_bot.py @@ -0,0 +1,284 @@ +"""Discord inbound adapter — translates Discord events into ChatService calls. + +Key design decisions vs the old app/discord_bot.py: +- No module-level singleton: the bot is constructed via create_bot() factory +- ChatService is injected directly — no HTTP roundtrip to the FastAPI API +- Pure functions (build_answer_embed, parse_conversation_id, etc.) are + extracted and independently testable +- All logging, no print() +- Error embeds never leak exception details +""" + +import logging +from typing import Optional + +import discord +from discord import app_commands +from discord.ext import commands + +from domain.models import ChatResult +from domain.services import ChatService + +logger = logging.getLogger(__name__) + +CONFIDENCE_THRESHOLD = 0.4 +FOOTER_PREFIX = "conv:" +MAX_EMBED_DESCRIPTION = 4000 + + +# --------------------------------------------------------------------------- +# Pure helper functions (testable without Discord) +# --------------------------------------------------------------------------- + + +def build_answer_embed( + result: ChatResult, + title: str = "Rules Answer", + color: discord.Color | None = None, +) -> discord.Embed: + """Build a Discord embed from a ChatResult. + + Handles truncation, cited rules, confidence warnings, and footer. + """ + if color is None: + color = discord.Color.blue() + + # Truncate long responses with a notice + text = result.response + if len(text) > MAX_EMBED_DESCRIPTION: + text = ( + text[: MAX_EMBED_DESCRIPTION - 60] + + "\n\n*(Response truncated — ask a more specific question)*" + ) + + embed = discord.Embed(title=title, description=text, color=color) + + # Cited rules + if result.cited_rules: + embed.add_field( + name="📋 Cited Rules", + value=", ".join(f"`{rid}`" for rid in result.cited_rules), + inline=False, + ) + + # Low confidence warning + if result.confidence < CONFIDENCE_THRESHOLD: + embed.add_field( + name="⚠️ Confidence", + value=f"Low ({result.confidence:.0%}) — a human review has been requested", + inline=False, + ) + + # Footer with full conversation ID for follow-ups + embed.set_footer( + text=f"{FOOTER_PREFIX}{result.conversation_id} | Reply to ask a follow-up" + ) + + return embed + + +def build_error_embed(error: Exception) -> discord.Embed: + """Build a safe error embed that never leaks exception internals.""" + _ = error # logged by the caller, not exposed to users + return discord.Embed( + title="❌ Error", + description=( + "Something went wrong while processing your request. " + "Please try again later." + ), + color=discord.Color.red(), + ) + + +def parse_conversation_id(footer_text: Optional[str]) -> Optional[str]: + """Extract conversation UUID from embed footer text. + + Expected format: "conv: | Reply to ask a follow-up" + Returns None if the footer is missing, malformed, or empty. + """ + if not footer_text or FOOTER_PREFIX not in footer_text: + return None + + try: + raw = footer_text.split(FOOTER_PREFIX)[1].split(" ")[0].strip() + return raw if raw else None + except (IndexError, AttributeError): + return None + + +# --------------------------------------------------------------------------- +# Bot class +# --------------------------------------------------------------------------- + + +class StratChatbot(commands.Bot): + """Discord bot that answers Strat-O-Matic rules questions. + + Unlike the old implementation, this bot calls ChatService directly + instead of going through the HTTP API, eliminating the roundtrip. + """ + + def __init__( + self, + chat_service: ChatService, + guild_id: Optional[str] = None, + ): + intents = discord.Intents.default() + intents.message_content = True + super().__init__(command_prefix="!", intents=intents) + + self.chat_service = chat_service + self.guild_id = guild_id + + # Register commands and events + self._register_commands() + + def _register_commands(self) -> None: + """Register slash commands and event handlers.""" + + @self.tree.command( + name="ask", + description="Ask a question about Strat-O-Matic league rules", + ) + @app_commands.describe( + question="Your rules question (e.g., 'Can a runner steal on a 2-2 count?')" + ) + async def ask_command(interaction: discord.Interaction, question: str): + await self._handle_ask(interaction, question) + + @self.event + async def on_ready(): + if not self.user: + return + logger.info("Bot logged in as %s (ID: %s)", self.user, self.user.id) + + @self.event + async def on_message(message: discord.Message): + await self._handle_follow_up(message) + + async def setup_hook(self) -> None: + """Sync slash commands on startup.""" + if self.guild_id: + guild = discord.Object(id=int(self.guild_id)) + self.tree.copy_global_to(guild=guild) + await self.tree.sync(guild=guild) + logger.info("Slash commands synced to guild %s", self.guild_id) + else: + await self.tree.sync() + logger.info("Slash commands synced globally") + + # ------------------------------------------------------------------ + # /ask command handler + # ------------------------------------------------------------------ + + async def _handle_ask( + self, interaction: discord.Interaction, question: str + ) -> None: + """Handle the /ask slash command.""" + await interaction.response.defer(ephemeral=False) + + try: + result = await self.chat_service.answer_question( + message=question, + user_id=str(interaction.user.id), + channel_id=str(interaction.channel_id), + ) + embed = build_answer_embed(result, title="Rules Answer") + await interaction.followup.send(embed=embed) + + except Exception as e: + logger.error( + "Error in /ask from user %s: %s", + interaction.user.id, + e, + exc_info=True, + ) + await interaction.followup.send(embed=build_error_embed(e)) + + # ------------------------------------------------------------------ + # Follow-up reply handler + # ------------------------------------------------------------------ + + async def _handle_follow_up(self, message: discord.Message) -> None: + """Handle reply-based follow-up questions.""" + if message.author.bot: + return + + if not message.reference or message.reference.message_id is None: + return + + # Use cached resolved message first, fetch only if needed + referenced = message.reference.resolved + if referenced is None or not isinstance(referenced, discord.Message): + referenced = await message.channel.fetch_message( + message.reference.message_id + ) + + if referenced.author != self.user: + return + + # Extract conversation ID from the referenced embed footer + embed = referenced.embeds[0] if referenced.embeds else None + footer_text = embed.footer.text if embed and embed.footer else None + conversation_id = parse_conversation_id(footer_text) + + if conversation_id is None: + await message.reply( + "❓ Could not find conversation context. Use `/ask` to start fresh.", + mention_author=True, + ) + return + + parent_message_id = str(referenced.id) + + loading_msg = await message.reply( + "🔍 Looking into that follow-up...", mention_author=True + ) + + try: + result = await self.chat_service.answer_question( + message=message.content, + user_id=str(message.author.id), + channel_id=str(message.channel.id), + conversation_id=conversation_id, + parent_message_id=parent_message_id, + ) + response_embed = build_answer_embed( + result, title="Follow-up Answer", color=discord.Color.green() + ) + await loading_msg.edit(content=None, embed=response_embed) + + except Exception as e: + logger.error( + "Error in follow-up from user %s: %s", + message.author.id, + e, + exc_info=True, + ) + await loading_msg.edit(content=None, embed=build_error_embed(e)) + + +# --------------------------------------------------------------------------- +# Factory + entry point +# --------------------------------------------------------------------------- + + +def create_bot( + chat_service: ChatService, + guild_id: Optional[str] = None, +) -> StratChatbot: + """Construct a StratChatbot with injected dependencies.""" + return StratChatbot(chat_service=chat_service, guild_id=guild_id) + + +def run_bot( + token: str, + chat_service: ChatService, + guild_id: Optional[str] = None, +) -> None: + """Construct and run the Discord bot (blocking call).""" + if not token: + raise ValueError("Discord bot token must not be empty") + + bot = create_bot(chat_service=chat_service, guild_id=guild_id) + bot.run(token) diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100644 index aacf55a..0000000 --- a/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Strat-Chatbot application package.""" diff --git a/app/config.py b/app/config.py deleted file mode 100644 index 203a064..0000000 --- a/app/config.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Configuration management using Pydantic Settings.""" - -from pathlib import Path -from pydantic_settings import BaseSettings -from pydantic import Field - - -class Settings(BaseSettings): - """Application settings with environment variable overrides.""" - - # OpenRouter - openrouter_api_key: str = Field(default="", env="OPENROUTER_API_KEY") - openrouter_model: str = Field( - default="stepfun/step-3.5-flash:free", env="OPENROUTER_MODEL" - ) - - # Discord - discord_bot_token: str = Field(default="", env="DISCORD_BOT_TOKEN") - discord_guild_id: str | None = Field(default=None, env="DISCORD_GUILD_ID") - - # Gitea - gitea_token: str = Field(default="", env="GITEA_TOKEN") - gitea_owner: str = Field(default="cal", env="GITEA_OWNER") - gitea_repo: str = Field(default="strat-chatbot", env="GITEA_REPO") - gitea_base_url: str = Field( - default="https://git.manticorum.com/api/v1", env="GITEA_BASE_URL" - ) - - # Paths - data_dir: Path = Field(default=Path("./data"), env="DATA_DIR") - rules_dir: Path = Field(default=Path("./data/rules"), env="RULES_DIR") - chroma_dir: Path = Field(default=Path("./data/chroma"), env="CHROMA_DIR") - - # Database - db_url: str = Field( - default="sqlite+aiosqlite:///./data/conversations.db", env="DB_URL" - ) - - # Conversation state TTL (seconds) - conversation_ttl: int = Field(default=1800, env="CONVERSATION_TTL") - - # Vector search - top_k_rules: int = Field(default=10, env="TOP_K_RULES") - embedding_model: str = Field( - default="sentence-transformers/all-MiniLM-L6-v2", env="EMBEDDING_MODEL" - ) - - class Config: - env_file = ".env" - env_file_encoding = "utf-8" - - -settings = Settings() diff --git a/app/database.py b/app/database.py deleted file mode 100644 index 3108be1..0000000 --- a/app/database.py +++ /dev/null @@ -1,162 +0,0 @@ -"""SQLAlchemy-based conversation state management with aiosqlite.""" - -from datetime import datetime, timedelta -from typing import Optional -import uuid -import sqlalchemy as sa -from fastapi import Request -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from sqlalchemy.orm import sessionmaker, declarative_base -from sqlalchemy import Column, String, DateTime, Boolean, ForeignKey, select -from .config import settings - -Base = declarative_base() - - -class ConversationTable(Base): - """SQLAlchemy model for conversations.""" - - __tablename__ = "conversations" - - id = Column(String, primary_key=True) - user_id = Column(String, nullable=False) - channel_id = Column(String, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow) - last_activity = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - -class MessageTable(Base): - """SQLAlchemy model for messages.""" - - __tablename__ = "messages" - - id = Column(String, primary_key=True) - conversation_id = Column(String, ForeignKey("conversations.id"), nullable=False) - content = Column(String, nullable=False) - is_user = Column(Boolean, nullable=False) - parent_id = Column(String, ForeignKey("messages.id"), nullable=True) - created_at = Column(DateTime, default=datetime.utcnow) - - -class ConversationManager: - """Manages conversation state in SQLite.""" - - def __init__(self, db_url: str): - """Initialize database engine and session factory.""" - self.engine = create_async_engine(db_url, echo=False) - self.async_session = sessionmaker( - self.engine, class_=AsyncSession, expire_on_commit=False - ) - - async def init_db(self): - """Create tables if they don't exist.""" - async with self.engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - async def get_or_create_conversation( - self, user_id: str, channel_id: str, conversation_id: Optional[str] = None - ) -> str: - """Get existing conversation or create a new one.""" - async with self.async_session() as session: - if conversation_id: - result = await session.execute( - select(ConversationTable).where( - ConversationTable.id == conversation_id - ) - ) - conv = result.scalar_one_or_none() - if conv: - conv.last_activity = datetime.utcnow() - await session.commit() - return conv.id - - # Create new conversation - new_id = str(uuid.uuid4()) - conv = ConversationTable(id=new_id, user_id=user_id, channel_id=channel_id) - session.add(conv) - await session.commit() - return new_id - - async def add_message( - self, - conversation_id: str, - content: str, - is_user: bool, - parent_id: Optional[str] = None, - ) -> str: - """Add a message to a conversation.""" - message_id = str(uuid.uuid4()) - async with self.async_session() as session: - msg = MessageTable( - id=message_id, - conversation_id=conversation_id, - content=content, - is_user=is_user, - parent_id=parent_id, - ) - session.add(msg) - - # Update conversation activity - result = await session.execute( - select(ConversationTable).where(ConversationTable.id == conversation_id) - ) - conv = result.scalar_one_or_none() - if conv: - conv.last_activity = datetime.utcnow() - - await session.commit() - return message_id - - async def get_conversation_history( - self, conversation_id: str, limit: int = 10 - ) -> list[dict]: - """Get recent messages from a conversation in OpenAI format.""" - async with self.async_session() as session: - result = await session.execute( - select(MessageTable) - .where(MessageTable.conversation_id == conversation_id) - .order_by(MessageTable.created_at.desc()) - .limit(limit) - ) - messages = result.scalars().all() - # Reverse to get chronological order and convert to API format - history = [] - for msg in reversed(messages): - role = "user" if msg.is_user else "assistant" - history.append({"role": role, "content": msg.content}) - - return history - - async def cleanup_old_conversations(self, ttl_seconds: int = 1800): - """Delete conversations older than TTL to free up storage.""" - cutoff = datetime.utcnow() - timedelta(seconds=ttl_seconds) - async with self.async_session() as session: - # Find old conversations - result = await session.execute( - select(ConversationTable).where( - ConversationTable.last_activity < cutoff - ) - ) - old_convs = result.scalars().all() - - conv_ids = [conv.id for conv in old_convs] - if conv_ids: - # Delete messages first (cascade would handle but explicit is clear) - await session.execute( - sa.delete(MessageTable).where( - MessageTable.conversation_id.in_(conv_ids) - ) - ) - # Delete conversations - await session.execute( - sa.delete(ConversationTable).where( - ConversationTable.id.in_(conv_ids) - ) - ) - await session.commit() - print(f"Cleaned up {len(conv_ids)} old conversations") - - -async def get_conversation_manager(request: Request) -> ConversationManager: - """Dependency for FastAPI to get the singleton ConversationManager from app state.""" - return request.app.state.db_manager diff --git a/app/discord_bot.py b/app/discord_bot.py deleted file mode 100644 index 3cd4b39..0000000 --- a/app/discord_bot.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Discord bot for Strat-O-Matic rules Q&A.""" - -import logging -import discord -from discord import app_commands -from discord.ext import commands -import aiohttp -from typing import Optional - -from .config import settings - -logger = logging.getLogger(__name__) - - -class StratChatbotBot(commands.Bot): - """Discord bot for the rules chatbot.""" - - def __init__(self): - """Initialize the bot with default intents.""" - intents = discord.Intents.default() - intents.message_content = True - super().__init__(command_prefix="!", intents=intents) - - self.api_base_url: Optional[str] = None - self.session: Optional[aiohttp.ClientSession] = None - - async def setup_hook(self): - """Set up the bot's HTTP session and sync commands.""" - self.session = aiohttp.ClientSession() - # Sync slash commands with Discord - if settings.discord_guild_id: - guild = discord.Object(id=int(settings.discord_guild_id)) - self.tree.copy_global_to(guild=guild) - await self.tree.sync(guild=guild) - logger.info("Slash commands synced to guild %s", settings.discord_guild_id) - else: - await self.tree.sync() - logger.info("Slash commands synced globally") - - async def close(self): - """Cleanup on shutdown.""" - if self.session: - await self.session.close() - await super().close() - - async def query_chat_api( - self, - message: str, - user_id: str, - channel_id: str, - conversation_id: Optional[str] = None, - parent_message_id: Optional[str] = None, - ) -> dict: - """Send a request to the FastAPI chat endpoint.""" - if not self.session: - raise RuntimeError("Bot HTTP session not initialized") - - payload = { - "message": message, - "user_id": user_id, - "channel_id": channel_id, - "conversation_id": conversation_id, - "parent_message_id": parent_message_id, - } - - async with self.session.post( - f"{self.api_base_url}/chat", - json=payload, - timeout=aiohttp.ClientTimeout(total=120), - ) as response: - if response.status != 200: - error_text = await response.text() - logger.error( - "API returned %s for %s %s — body: %s", - response.status, - response.method, - response.url, - error_text, - ) - raise RuntimeError(f"API error {response.status}") - return await response.json() - - -bot = StratChatbotBot() - - -@bot.event -async def on_ready(): - """Called when the bot is ready.""" - if not bot.user: - return - logger.info("Bot logged in as %s (ID: %s)", bot.user, bot.user.id) - logger.info("Ready to answer Strat-O-Matic rules questions!") - - -@bot.tree.command( - name="ask", description="Ask a question about Strat-O-Matic league rules" -) -@app_commands.describe( - question="Your rules question (e.g., 'Can a runner steal on a 2-2 count?')" -) -async def ask_command(interaction: discord.Interaction, question: str): - """Handle /ask command.""" - await interaction.response.defer(ephemeral=False) - - try: - result = await bot.query_chat_api( - message=question, - user_id=str(interaction.user.id), - channel_id=str(interaction.channel_id), - conversation_id=None, # New conversation - parent_message_id=None, - ) - - # Build response embed - embed = discord.Embed( - title="Rules Answer", - description=result["response"][:4000], # Discord limit - color=discord.Color.blue(), - ) - - # Add cited rules if any - if result.get("cited_rules"): - embed.add_field( - name="📋 Cited Rules", - value=", ".join([f"`{rid}`" for rid in result["cited_rules"]]), - inline=False, - ) - - # Add confidence indicator - confidence = result.get("confidence", 0.0) - if confidence < 0.4: - embed.add_field( - name="⚠️ Confidence", - value=f"Low ({confidence:.0%}) - A human review has been requested", - inline=False, - ) - - # Add conversation ID for follow-ups (full UUID so replies can be threaded) - embed.set_footer( - text=f"conv:{result['conversation_id']} | Reply to ask a follow-up" - ) - - await interaction.followup.send(embed=embed) - - except Exception as e: - logger.error( - "Error handling /ask from user %s: %s", - interaction.user.id, - e, - exc_info=True, - ) - await interaction.followup.send( - embed=discord.Embed( - title="❌ Error", - description="Something went wrong while fetching your answer. Please try again later.", - color=discord.Color.red(), - ) - ) - - -@bot.event -async def on_message(message: discord.Message): - """Handle follow-up messages via reply.""" - # Ignore bot messages - if message.author.bot: - return - - # Only handle replies to the bot's messages - if not message.reference or message.reference.message_id is None: - return - - referenced = await message.channel.fetch_message(message.reference.message_id) - - # Check if the referenced message was from this bot - if referenced.author != bot.user: - return - - # Try to extract conversation ID from the footer - embed = referenced.embeds[0] if referenced.embeds else None - if not embed or not embed.footer: - await message.reply( - "❓ I couldn't find this conversation. Please use `/ask` to start a new question.", - mention_author=True, - ) - return - - footer_text = embed.footer.text or "" - if "conv:" not in footer_text: - await message.reply( - "❓ Could not determine conversation. Use `/ask` to start fresh.", - mention_author=True, - ) - return - - # Extract full conversation UUID from "conv: | ..." format - try: - conversation_id = footer_text.split("conv:")[1].split(" ")[0].strip() - except (IndexError, AttributeError): - await message.reply( - "❓ Could not parse conversation ID. Use `/ask` to start fresh.", - mention_author=True, - ) - return - - # Get parent message ID (the original answer message) - parent_message_id = str(referenced.id) - - # Send a loading placeholder and replace it with the real answer when ready - loading_msg = await message.reply( - "🔍 Looking into that follow-up...", mention_author=True - ) - - try: - result = await bot.query_chat_api( - message=message.content, - user_id=str(message.author.id), - channel_id=str(message.channel.id), - conversation_id=conversation_id, - parent_message_id=parent_message_id, - ) - - response_embed = discord.Embed( - title="Follow-up Answer", - description=result["response"][:4000], - color=discord.Color.green(), - ) - - if result.get("cited_rules"): - response_embed.add_field( - name="📋 Cited Rules", - value=", ".join([f"`{rid}`" for rid in result["cited_rules"]]), - inline=False, - ) - - if result.get("confidence", 0.0) < 0.4: - response_embed.add_field( - name="⚠️ Confidence", - value="Low - Human review requested", - inline=False, - ) - - # Carry the conversation ID forward so further replies stay in the same thread - response_embed.set_footer( - text=f"conv:{result['conversation_id']} | Reply to ask a follow-up" - ) - - await loading_msg.edit(content=None, embed=response_embed) - - except Exception as e: - logger.error( - "Error handling follow-up from user %s in channel %s: %s", - message.author.id, - message.channel.id, - e, - exc_info=True, - ) - await loading_msg.edit( - content=None, - embed=discord.Embed( - title="❌ Error", - description="Something went wrong while processing your follow-up. Please try again later.", - color=discord.Color.red(), - ), - ) - - -def run_bot(api_base_url: str = "http://localhost:8000"): - """Entry point to run the Discord bot.""" - bot.api_base_url = api_base_url - - if not settings.discord_bot_token: - logger.critical("DISCORD_BOT_TOKEN environment variable is required") - exit(1) - - bot.run(settings.discord_bot_token) diff --git a/app/gitea.py b/app/gitea.py deleted file mode 100644 index a9522f2..0000000 --- a/app/gitea.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Gitea client for creating issues when questions need human review.""" - -import httpx -from typing import Optional -from .config import settings - - -class GiteaClient: - """Client for Gitea API interactions.""" - - def __init__(self): - """Initialize Gitea client with credentials.""" - self.token = settings.gitea_token - self.owner = settings.gitea_owner - self.repo = settings.gitea_repo - self.base_url = settings.gitea_base_url.rstrip("/") - self.headers = { - "Authorization": f"token {self.token}", - "Content-Type": "application/json", - "Accept": "application/json", - } - - async def create_issue( - self, - title: str, - body: str, - labels: Optional[list[str]] = None, - assignee: Optional[str] = None, - ) -> dict: - """Create a new issue in the configured repository.""" - url = f"{self.base_url}/repos/{self.owner}/{self.repo}/issues" - - payload: dict = {"title": title, "body": body} - - if labels: - payload["labels"] = labels - - if assignee: - payload["assignee"] = assignee - - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post(url, headers=self.headers, json=payload) - - if response.status_code not in (200, 201): - error_detail = response.text - raise RuntimeError( - f"Gitea API error creating issue: {response.status_code} - {error_detail}" - ) - - return response.json() - - async def create_unanswered_issue( - self, - question: str, - user_id: str, - channel_id: str, - attempted_rules: list[str], - conversation_id: str, - ) -> str: - """Create an issue for an unanswered question needing human review.""" - title = f"🤔 Unanswered rules question: {question[:80]}{'...' if len(question) > 80 else ''}" - - body = f"""## Unanswered Question - -**User:** {user_id} - -**Channel:** {channel_id} - -**Conversation ID:** {conversation_id} - -**Question:** -{question} - -**Searched Rules:** -{', '.join(attempted_rules) if attempted_rules else 'None'} - -**Additional Context:** -This question was asked in Discord and the bot could not provide a confident answer. The rules either don't cover this question or the information was ambiguous. - ---- - -*This issue was automatically created by the Strat-Chatbot.*""" - - labels = ["rules-gap", "ai-generated", "needs-review"] - - issue = await self.create_issue(title=title, body=body, labels=labels) - - return issue.get("html_url", "") - - -def get_gitea_client() -> Optional[GiteaClient]: - """Factory to get Gitea client if token is configured.""" - if settings.gitea_token: - return GiteaClient() - return None diff --git a/app/llm.py b/app/llm.py deleted file mode 100644 index e804fc4..0000000 --- a/app/llm.py +++ /dev/null @@ -1,179 +0,0 @@ -"""OpenRouter LLM integration for answering rules questions.""" - -from typing import Optional -import json -import httpx -from .config import settings -from .models import RuleSearchResult, ChatResponse - -SYSTEM_PROMPT = """You are a helpful assistant for a Strat-O-Matic baseball league. -Your job is to answer questions about league rules and procedures using the provided rule excerpts. - -CRITICAL RULES: -1. ONLY use information from the provided rules. If the rules don't contain the answer, say so clearly. -2. ALWAYS cite rule IDs when referencing a rule (e.g., "Rule 5.2.1(b) states that...") -3. If multiple rules are relevant, cite all of them. -4. If you're uncertain or the rules are ambiguous, say so and suggest asking a league administrator. -5. Keep responses concise but complete. Use examples when helpful from the rules. -6. Do NOT make up rules or infer beyond what's explicitly stated. - -When answering: -- Start with a direct answer to the question -- Support with rule citations -- Include relevant details from the rules -- If no relevant rules found, explicitly state: "I don't have a rule that addresses this question." - -Response format (JSON): -{ - "answer": "Your response text", - "cited_rules": ["rule_id_1", "rule_id_2"], - "confidence": 0.0-1.0, - "needs_human": boolean -} - -Higher confidence (0.8-1.0) when rules clearly answer the question. -Lower confidence (0.3-0.7) when rules partially address the question or are ambiguous. -Very low confidence (0.0-0.2) when rules don't address the question at all. -""" - - -class OpenRouterClient: - """Client for OpenRouter API.""" - - def __init__(self): - """Initialize the client.""" - self.api_key = settings.openrouter_api_key - if not self.api_key: - raise ValueError("OPENROUTER_API_KEY is required") - self.model = settings.openrouter_model - self.base_url = "https://openrouter.ai/api/v1/chat/completions" - - async def generate_response( - self, - question: str, - rules: list[RuleSearchResult], - conversation_history: Optional[list[dict]] = None, - ) -> ChatResponse: - """Generate a response using the LLM with retrieved rules as context.""" - # Build context from rules - rules_context = "\n\n".join( - [f"Rule {r.rule_id}: {r.title}\n{r.content}" for r in rules] - ) - - if rules: - context_msg = ( - f"Here are the relevant rules for the question:\n\n{rules_context}" - ) - else: - context_msg = "No relevant rules were found in the knowledge base." - - # Build conversation history - messages = [{"role": "system", "content": SYSTEM_PROMPT}] - - if conversation_history: - # Add last few turns of conversation (limit to avoid token overflow) - messages.extend( - conversation_history[-6:] - ) # Last 3 exchanges (user+assistant) - - # Add current question with context - user_message = f"{context_msg}\n\nUser question: {question}\n\nAnswer the question based on the rules provided." - messages.append({"role": "user", "content": user_message}) - - # Call OpenRouter API - async with httpx.AsyncClient(timeout=120.0) as client: - response = await client.post( - self.base_url, - headers={ - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - }, - json={ - "model": self.model, - "messages": messages, - "temperature": 0.3, - "max_tokens": 1000, - "top_p": 0.9, - }, - ) - - if response.status_code != 200: - error_detail = response.text - raise RuntimeError( - f"OpenRouter API error: {response.status_code} - {error_detail}" - ) - - result = response.json() - content = result["choices"][0]["message"]["content"] - - # Parse the JSON response - try: - # Extract JSON from response (LLM might add markdown formatting) - if "```json" in content: - json_str = content.split("```json")[1].split("```")[0].strip() - else: - json_str = content.strip() - - parsed = json.loads(json_str) - - cited_rules = parsed.get("cited_rules", []) - if not cited_rules and rules: - # Fallback: extract rule IDs from the text if not properly returned - import re - - rule_ids = re.findall( - r"Rule\s+([\d\.\(\)a-b]+)", parsed.get("answer", "") - ) - cited_rules = list(set(rule_ids)) - - return ChatResponse( - response=parsed["answer"], - conversation_id="", # Will be set by caller - message_id="", # Will be set by caller - cited_rules=cited_rules, - confidence=float(parsed.get("confidence", 0.5)), - needs_human=bool(parsed.get("needs_human", False)), - ) - except (json.JSONDecodeError, KeyError) as e: - # If parsing fails, return what we can extract - return ChatResponse( - response=content, - conversation_id="", - message_id="", - cited_rules=[], - confidence=0.5, - needs_human=False, - ) - - -class MockLLMClient: - """Mock LLM client for testing without API calls.""" - - async def generate_response( - self, - question: str, - rules: list[RuleSearchResult], - conversation_history: Optional[list[dict]] = None, - ) -> ChatResponse: - """Return a mock response.""" - if rules: - rule_list = ", ".join([r.rule_id for r in rules]) - answer = f"Based on rule(s) {rule_list}, here's what you need to know..." - else: - answer = "I don't have a rule that addresses this question. You should ask a league administrator." - - return ChatResponse( - response=answer, - conversation_id="", - message_id="", - cited_rules=[r.rule_id for r in rules], - confidence=1.0 if rules else 0.0, - needs_human=not rules, - ) - - -def get_llm_client(use_mock: bool = False): - """Factory to get the appropriate LLM client.""" - if use_mock or not settings.openrouter_api_key: - return MockLLMClient() - return OpenRouterClient() diff --git a/app/main.py b/app/main.py deleted file mode 100644 index ffee99d..0000000 --- a/app/main.py +++ /dev/null @@ -1,198 +0,0 @@ -"""FastAPI application for Strat-O-Matic rules chatbot.""" - -from contextlib import asynccontextmanager -from typing import Optional -import uuid - -from fastapi import FastAPI, HTTPException, Depends -import uvicorn -import sqlalchemy as sa - -from .config import settings -from .models import ChatRequest, ChatResponse -from .vector_store import VectorStore -from .database import ConversationManager, get_conversation_manager -from .llm import get_llm_client -from .gitea import GiteaClient - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Manage application lifespan - startup and shutdown.""" - # Startup - print("Initializing Strat-Chatbot...") - - # Initialize vector store - chroma_dir = settings.data_dir / "chroma" - vector_store = VectorStore(chroma_dir, settings.embedding_model) - print(f"Vector store ready at {chroma_dir} ({vector_store.count()} rules loaded)") - - # Initialize database - db_manager = ConversationManager(settings.db_url) - await db_manager.init_db() - print("Database initialized") - - # Initialize LLM client - llm_client = get_llm_client(use_mock=not settings.openrouter_api_key) - print(f"LLM client ready (model: {settings.openrouter_model})") - - # Initialize Gitea client - gitea_client = GiteaClient() if settings.gitea_token else None - - # Store in app state - app.state.vector_store = vector_store - app.state.db_manager = db_manager - app.state.llm_client = llm_client - app.state.gitea_client = gitea_client - - print("Strat-Chatbot ready!") - - yield - - # Shutdown - print("Shutting down...") - - -app = FastAPI( - title="Strat-Chatbot", - description="Strat-O-Matic rules Q&A API", - version="0.1.0", - lifespan=lifespan, -) - - -@app.get("/health") -async def health_check(): - """Health check endpoint.""" - vector_store: VectorStore = app.state.vector_store - stats = vector_store.get_stats() - return { - "status": "healthy", - "rules_count": stats["total_rules"], - "sections": stats["sections"], - } - - -@app.post("/chat", response_model=ChatResponse) -async def chat( - request: ChatRequest, - db_manager: ConversationManager = Depends(get_conversation_manager), -): - """Handle chat requests from Discord.""" - vector_store: VectorStore = app.state.vector_store - llm_client = app.state.llm_client - gitea_client = app.state.gitea_client - - # Validate API key if using real LLM - if not settings.openrouter_api_key: - return ChatResponse( - response="⚠️ OpenRouter API key not configured. Set OPENROUTER_API_KEY environment variable.", - conversation_id=request.conversation_id or str(uuid.uuid4()), - message_id=str(uuid.uuid4()), - cited_rules=[], - confidence=0.0, - needs_human=True, - ) - - # Get or create conversation - conversation_id = await db_manager.get_or_create_conversation( - user_id=request.user_id, - channel_id=request.channel_id, - conversation_id=request.conversation_id, - ) - - # Save user message - user_message_id = await db_manager.add_message( - conversation_id=conversation_id, - content=request.message, - is_user=True, - parent_id=request.parent_message_id, - ) - - try: - # Search for relevant rules - search_results = vector_store.search( - query=request.message, top_k=settings.top_k_rules - ) - - # Get conversation history for context - history = await db_manager.get_conversation_history(conversation_id, limit=10) - - # Generate response from LLM - response = await llm_client.generate_response( - question=request.message, rules=search_results, conversation_history=history - ) - - # Save assistant message - assistant_message_id = await db_manager.add_message( - conversation_id=conversation_id, - content=response.response, - is_user=False, - parent_id=user_message_id, - ) - - # If needs human or confidence is low, create Gitea issue - if gitea_client and (response.needs_human or response.confidence < 0.4): - try: - issue_url = await gitea_client.create_unanswered_issue( - question=request.message, - user_id=request.user_id, - channel_id=request.channel_id, - attempted_rules=[r.rule_id for r in search_results], - conversation_id=conversation_id, - ) - print(f"Created Gitea issue: {issue_url}") - except Exception as e: - print(f"Failed to create Gitea issue: {e}") - - # Build final response - return ChatResponse( - response=response.response, - conversation_id=conversation_id, - message_id=assistant_message_id, - parent_message_id=user_message_id, - cited_rules=response.cited_rules, - confidence=response.confidence, - needs_human=response.needs_human, - ) - - except Exception as e: - print(f"Error processing chat request: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@app.get("/stats") -async def stats(): - """Get statistics about the knowledge base and system.""" - vector_store: VectorStore = app.state.vector_store - db_manager: ConversationManager = app.state.db_manager - - # Get vector store stats - vs_stats = vector_store.get_stats() - - # Get database stats - async with db_manager.async_session() as session: - conv_count = await session.execute( - sa.text("SELECT COUNT(*) FROM conversations") - ) - msg_count = await session.execute(sa.text("SELECT COUNT(*) FROM messages")) - - total_conversations = conv_count.scalar() or 0 - total_messages = msg_count.scalar() or 0 - - return { - "knowledge_base": vs_stats, - "conversations": { - "total": total_conversations, - "total_messages": total_messages, - }, - "config": { - "openrouter_model": settings.openrouter_model, - "top_k_rules": settings.top_k_rules, - "embedding_model": settings.embedding_model, - }, - } - - -if __name__ == "__main__": - uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/app/models.py b/app/models.py deleted file mode 100644 index 0ae2b66..0000000 --- a/app/models.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Data models for rules and conversations.""" - -from pydantic import BaseModel, Field -from typing import Optional -from datetime import datetime - - -class RuleMetadata(BaseModel): - """Frontmatter metadata for a rule document.""" - - rule_id: str = Field(..., description="Unique rule identifier, e.g. '5.2.1(b)'") - title: str = Field(..., description="Rule title") - section: str = Field(..., description="Section/category name") - parent_rule: Optional[str] = Field( - None, description="Parent rule ID for hierarchical rules" - ) - last_updated: str = Field( - default_factory=lambda: datetime.now().strftime("%Y-%m-%d"), - description="Last update date", - ) - page_ref: Optional[str] = Field( - None, description="Reference to page number in rulebook" - ) - - -class RuleDocument(BaseModel): - """Complete rule document with metadata and content.""" - - metadata: RuleMetadata - content: str = Field(..., description="Rule text and examples") - source_file: str = Field(..., description="Source file path") - embedding: Optional[list[float]] = None - - def to_chroma_metadata(self) -> dict: - """Convert to ChromaDB metadata format.""" - return { - "rule_id": self.metadata.rule_id, - "title": self.metadata.title, - "section": self.metadata.section, - "parent_rule": self.metadata.parent_rule or "", - "page_ref": self.metadata.page_ref or "", - "last_updated": self.metadata.last_updated, - "source_file": self.source_file, - } - - -class Conversation(BaseModel): - """Conversation session.""" - - id: str - user_id: str # Discord user ID - channel_id: str # Discord channel ID - created_at: datetime = Field(default_factory=datetime.now) - last_activity: datetime = Field(default_factory=datetime.now) - - -class Message(BaseModel): - """Individual message in a conversation.""" - - id: str - conversation_id: str - content: str - is_user: bool - parent_id: Optional[str] = None - created_at: datetime = Field(default_factory=datetime.now) - - -class ChatRequest(BaseModel): - """Incoming chat request from Discord.""" - - message: str - conversation_id: Optional[str] = None - parent_message_id: Optional[str] = None - user_id: str - channel_id: str - - -class ChatResponse(BaseModel): - """Response to chat request.""" - - response: str - conversation_id: str - message_id: str - parent_message_id: Optional[str] = None - cited_rules: list[str] = Field(default_factory=list) - confidence: float = Field(..., ge=0.0, le=1.0) - needs_human: bool = Field( - default=False, - description="Whether the question needs human review (unanswered)", - ) - - -class RuleSearchResult(BaseModel): - """Result from vector search.""" - - rule_id: str - title: str - content: str - section: str - similarity: float = Field(..., ge=0.0, le=1.0) diff --git a/app/vector_store.py b/app/vector_store.py deleted file mode 100644 index ddb9b9e..0000000 --- a/app/vector_store.py +++ /dev/null @@ -1,168 +0,0 @@ -"""ChromaDB vector store for rule embeddings.""" - -from pathlib import Path -from typing import Optional -import chromadb -from chromadb.config import Settings as ChromaSettings -from sentence_transformers import SentenceTransformer -import numpy as np -from .config import settings -from .models import RuleDocument, RuleSearchResult - - -class VectorStore: - """Wrapper around ChromaDB for rule retrieval.""" - - def __init__(self, persist_dir: Path, embedding_model: str): - """Initialize vector store with embedding model.""" - self.persist_dir = Path(persist_dir) - self.persist_dir.mkdir(parents=True, exist_ok=True) - - chroma_settings = ChromaSettings( - anonymized_telemetry=False, is_persist_directory_actually_writable=True - ) - - self.client = chromadb.PersistentClient( - path=str(self.persist_dir), settings=chroma_settings - ) - - self.embedding_model = SentenceTransformer(embedding_model) - - def get_collection(self): - """Get or create the rules collection.""" - return self.client.get_or_create_collection( - name="rules", metadata={"hnsw:space": "cosine"} - ) - - def add_document(self, doc: RuleDocument) -> None: - """Add a single rule document to the vector store.""" - embedding = self.embedding_model.encode(doc.content).tolist() - - collection = self.get_collection() - collection.add( - ids=[doc.metadata.rule_id], - embeddings=[embedding], - documents=[doc.content], - metadatas=[doc.to_chroma_metadata()], - ) - - def add_documents(self, docs: list[RuleDocument]) -> None: - """Add multiple documents in batch.""" - if not docs: - return - - ids = [doc.metadata.rule_id for doc in docs] - contents = [doc.content for doc in docs] - embeddings = self.embedding_model.encode(contents).tolist() - metadatas = [doc.to_chroma_metadata() for doc in docs] - - collection = self.get_collection() - collection.add( - ids=ids, embeddings=embeddings, documents=contents, metadatas=metadatas - ) - - def search( - self, query: str, top_k: int = 10, section_filter: Optional[str] = None - ) -> list[RuleSearchResult]: - """Search for relevant rules using semantic similarity.""" - query_embedding = self.embedding_model.encode(query).tolist() - - collection = self.get_collection() - - where = None - if section_filter: - where = {"section": section_filter} - - results = collection.query( - query_embeddings=[query_embedding], - n_results=top_k, - where=where, - include=["documents", "metadatas", "distances"], - ) - - search_results = [] - if results and results["documents"] and results["documents"][0]: - for i in range(len(results["documents"][0])): - metadata = results["metadatas"][0][i] - distance = results["distances"][0][i] - similarity = max( - 0.0, min(1.0, 1 - distance) - ) # Clamp to [0, 1]: cosine distance ranges 0–2 - - search_results.append( - RuleSearchResult( - rule_id=metadata["rule_id"], - title=metadata["title"], - content=results["documents"][0][i], - section=metadata["section"], - similarity=similarity, - ) - ) - - return search_results - - def delete_rule(self, rule_id: str) -> None: - """Remove a rule by its ID.""" - collection = self.get_collection() - collection.delete(ids=[rule_id]) - - def clear_all(self) -> None: - """Delete all rules from the collection.""" - self.client.delete_collection("rules") - self.get_collection() # Recreate empty collection - - def get_rule(self, rule_id: str) -> Optional[RuleSearchResult]: - """Retrieve a specific rule by ID.""" - collection = self.get_collection() - result = collection.get(ids=[rule_id], include=["documents", "metadatas"]) - - if result and result["documents"] and result["documents"][0]: - metadata = result["metadatas"][0][0] - return RuleSearchResult( - rule_id=metadata["rule_id"], - title=metadata["title"], - content=result["documents"][0][0], - section=metadata["section"], - similarity=1.0, - ) - return None - - def list_all_rules(self) -> list[RuleSearchResult]: - """Return all rules in the store.""" - collection = self.get_collection() - result = collection.get(include=["documents", "metadatas"]) - - all_rules = [] - if result and result["documents"]: - for i in range(len(result["documents"])): - metadata = result["metadatas"][i] - all_rules.append( - RuleSearchResult( - rule_id=metadata["rule_id"], - title=metadata["title"], - content=result["documents"][i], - section=metadata["section"], - similarity=1.0, - ) - ) - - return all_rules - - def count(self) -> int: - """Return the number of rules in the store.""" - collection = self.get_collection() - return collection.count() - - def get_stats(self) -> dict: - """Get statistics about the vector store.""" - collection = self.get_collection() - all_rules = self.list_all_rules() - sections = {} - for rule in all_rules: - sections[rule.section] = sections.get(rule.section, 0) + 1 - - return { - "total_rules": len(all_rules), - "sections": sections, - "persist_directory": str(self.persist_dir), - } diff --git a/docker-compose.yml b/docker-compose.yml index ec86db3..c4f8a39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,6 @@ services: dockerfile: Dockerfile volumes: - ./data:/app/data - - ./app:/app/app ports: - "127.0.0.1:8000:8000" environment: @@ -40,7 +39,7 @@ services: depends_on: chroma: condition: service_healthy - command: uvicorn app.main:app --host 0.0.0.0 --port 8000 + command: uvicorn main:app --host 0.0.0.0 --port 8000 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 15s @@ -54,23 +53,24 @@ services: dockerfile: Dockerfile volumes: - ./data:/app/data - - ./app:/app/app environment: + # The bot now calls ChatService directly — needs its own adapter config - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - OPENROUTER_MODEL=${OPENROUTER_MODEL:-stepfun/step-3.5-flash:free} - DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-} - DISCORD_GUILD_ID=${DISCORD_GUILD_ID:-} - - API_BASE_URL=http://api:8000 - - API_SECRET=${API_SECRET:-} + - GITEA_TOKEN=${GITEA_TOKEN:-} + - GITEA_OWNER=${GITEA_OWNER:-cal} + - GITEA_REPO=${GITEA_REPO:-strat-chatbot} + - DATA_DIR=/app/data + - RULES_DIR=/app/data/rules + - CHROMA_DIR=/app/data/chroma + - DB_URL=sqlite+aiosqlite:///./data/conversations.db + - CONVERSATION_TTL=1800 + - TOP_K_RULES=10 + - EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2 depends_on: - api: + chroma: condition: service_healthy - # Override the default command to run the Discord bot - command: > - sh -c " - echo 'Waiting for API to be ready...' && - while ! curl -s http://api:8000/health > /dev/null; do sleep 2; done && - echo 'API ready, starting Discord bot...' && - python -m app.discord_bot - " + command: python -m run_discord restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml index 60390a4..5b38c6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ dependencies = [ "discord.py>=2.5.0", "chromadb>=0.5.0", "sentence-transformers>=3.0.0", - "openai>=1.0.0", "python-dotenv>=1.0.0", "sqlalchemy>=2.0.0", "aiosqlite>=0.19.0", @@ -31,7 +30,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["domain", "adapters", "config", "app"] +packages = ["domain", "adapters", "config"] [tool.black] line-length = 88 diff --git a/run_discord.py b/run_discord.py new file mode 100644 index 0000000..57174d7 --- /dev/null +++ b/run_discord.py @@ -0,0 +1,86 @@ +"""Entry point for running the Discord bot with direct ChatService injection. + +This script constructs the same adapter stack as the FastAPI app but runs +the Discord bot instead of a web server. The bot calls ChatService directly +— no HTTP roundtrip to the API. +""" + +import asyncio +import logging + +from adapters.outbound.chroma_rules import ChromaRuleRepository +from adapters.outbound.gitea_issues import GiteaIssueTracker +from adapters.outbound.openrouter import OpenRouterLLM +from adapters.outbound.sqlite_convos import SQLiteConversationStore +from adapters.inbound.discord_bot import run_bot +from config.settings import Settings +from domain.services import ChatService + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def _init_and_run() -> None: + settings = Settings() + + if not settings.discord_bot_token: + raise ValueError("DISCORD_BOT_TOKEN is required") + + logger.info("Initialising adapters for Discord bot...") + + # Vector store + chroma_repo = ChromaRuleRepository( + persist_dir=settings.chroma_dir, + embedding_model=settings.embedding_model, + ) + logger.info("ChromaDB ready (%d rules)", chroma_repo.count()) + + # Conversation store + conv_store = SQLiteConversationStore(db_url=settings.db_url) + await conv_store.init_db() + logger.info("SQLite conversation store ready") + + # LLM + llm = None + if settings.openrouter_api_key: + llm = OpenRouterLLM( + api_key=settings.openrouter_api_key, + model=settings.openrouter_model, + ) + logger.info("OpenRouter LLM ready (model: %s)", settings.openrouter_model) + else: + logger.warning("OPENROUTER_API_KEY not set — LLM disabled") + + # Gitea + gitea = None + if settings.gitea_token: + gitea = GiteaIssueTracker( + token=settings.gitea_token, + owner=settings.gitea_owner, + repo=settings.gitea_repo, + base_url=settings.gitea_base_url, + ) + + # Service + service = ChatService( + rules=chroma_repo, + llm=llm, # type: ignore[arg-type] + conversations=conv_store, + issues=gitea, + top_k_rules=settings.top_k_rules, + ) + + logger.info("Starting Discord bot...") + run_bot( + token=settings.discord_bot_token, + chat_service=service, + guild_id=settings.discord_guild_id, + ) + + +def main() -> None: + asyncio.run(_init_and_run()) + + +if __name__ == "__main__": + main() diff --git a/tests/adapters/test_discord.py b/tests/adapters/test_discord.py new file mode 100644 index 0000000..db3aa34 --- /dev/null +++ b/tests/adapters/test_discord.py @@ -0,0 +1,168 @@ +"""Tests for the Discord inbound adapter. + +Discord.py makes it hard to test event handlers directly (they require a +running gateway connection). Instead, we test the *pure logic* that the +adapter extracts into standalone functions / methods: + +- build_answer_embed: constructs the Discord embed from a ChatResult +- build_error_embed: constructs a safe error embed (no leaked details) +- parse_conversation_id: extracts conversation UUID from footer text +- truncate_response: handles Discord's 4000-char embed limit + +The bot class itself (StratChatbot) is tested for construction, dependency +injection, and configuration — not for full gateway event handling. +""" + +import pytest + +from domain.models import ChatResult +from adapters.inbound.discord_bot import ( + build_answer_embed, + build_error_embed, + parse_conversation_id, + FOOTER_PREFIX, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_result(**overrides) -> ChatResult: + """Create a ChatResult with sensible defaults, overridable per-test.""" + defaults = { + "response": "Based on Rule 5.2.1(b), runners can steal.", + "conversation_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "message_id": "msg-123", + "parent_message_id": "msg-000", + "cited_rules": ["5.2.1(b)"], + "confidence": 0.9, + "needs_human": False, + } + defaults.update(overrides) + return ChatResult(**defaults) + + +# --------------------------------------------------------------------------- +# build_answer_embed +# --------------------------------------------------------------------------- + + +class TestBuildAnswerEmbed: + """build_answer_embed turns a ChatResult into a Discord Embed.""" + + def test_description_contains_response(self): + result = _make_result() + embed = build_answer_embed(result, title="Rules Answer") + assert result.response in embed.description + + def test_footer_contains_full_conversation_id(self): + result = _make_result() + embed = build_answer_embed(result, title="Rules Answer") + assert result.conversation_id in embed.footer.text + + def test_footer_starts_with_prefix(self): + result = _make_result() + embed = build_answer_embed(result, title="Rules Answer") + assert embed.footer.text.startswith(FOOTER_PREFIX) + + def test_cited_rules_field_present(self): + result = _make_result(cited_rules=["5.2.1(b)", "3.1"]) + embed = build_answer_embed(result, title="Rules Answer") + field_names = [f.name for f in embed.fields] + assert any("Cited" in name for name in field_names) + # Both rule IDs should be in the field value + rules_field = [f for f in embed.fields if "Cited" in f.name][0] + assert "5.2.1(b)" in rules_field.value + assert "3.1" in rules_field.value + + def test_no_cited_rules_field_when_empty(self): + result = _make_result(cited_rules=[]) + embed = build_answer_embed(result, title="Rules Answer") + field_names = [f.name for f in embed.fields] + assert not any("Cited" in name for name in field_names) + + def test_low_confidence_adds_warning_field(self): + result = _make_result(confidence=0.2) + embed = build_answer_embed(result, title="Rules Answer") + field_names = [f.name for f in embed.fields] + assert any("Confidence" in name for name in field_names) + + def test_high_confidence_no_warning_field(self): + result = _make_result(confidence=0.9) + embed = build_answer_embed(result, title="Rules Answer") + field_names = [f.name for f in embed.fields] + assert not any("Confidence" in name for name in field_names) + + def test_response_truncated_at_4000_chars(self): + long_response = "x" * 5000 + result = _make_result(response=long_response) + embed = build_answer_embed(result, title="Rules Answer") + assert len(embed.description) <= 4000 + + def test_truncation_notice_appended(self): + long_response = "x" * 5000 + result = _make_result(response=long_response) + embed = build_answer_embed(result, title="Rules Answer") + assert "truncated" in embed.description.lower() + + def test_custom_title(self): + result = _make_result() + embed = build_answer_embed(result, title="Follow-up Answer") + assert embed.title == "Follow-up Answer" + + +# --------------------------------------------------------------------------- +# build_error_embed +# --------------------------------------------------------------------------- + + +class TestBuildErrorEmbed: + """build_error_embed creates a safe error embed with no leaked details.""" + + def test_does_not_contain_exception_text(self): + error = RuntimeError("API key abc123 is invalid for https://internal.host") + embed = build_error_embed(error) + assert "abc123" not in embed.description + assert "internal.host" not in embed.description + + def test_has_generic_message(self): + embed = build_error_embed(RuntimeError("anything")) + assert ( + "try again" in embed.description.lower() + or "went wrong" in embed.description.lower() + ) + + def test_title_indicates_error(self): + embed = build_error_embed(ValueError("x")) + assert "Error" in embed.title or "error" in embed.title + + +# --------------------------------------------------------------------------- +# parse_conversation_id +# --------------------------------------------------------------------------- + + +class TestParseConversationId: + """parse_conversation_id extracts the full UUID from embed footer text.""" + + def test_parses_valid_footer(self): + footer = "conv:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee | Reply to ask a follow-up" + assert parse_conversation_id(footer) == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + def test_returns_none_for_missing_prefix(self): + assert parse_conversation_id("no prefix here") is None + + def test_returns_none_for_empty_string(self): + assert parse_conversation_id("") is None + + def test_returns_none_for_none_input(self): + assert parse_conversation_id(None) is None + + def test_returns_none_for_malformed_footer(self): + assert parse_conversation_id("conv:") is None + + def test_handles_no_pipe_separator(self): + footer = "conv:some-uuid-value" + result = parse_conversation_id(footer) + assert result == "some-uuid-value" diff --git a/tests/test_basic.py b/tests/test_basic.py deleted file mode 100644 index 34739d1..0000000 --- a/tests/test_basic.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Basic test to verify the vector store and ingestion.""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent.parent / "app")) - -from app.config import settings -from app.vector_store import VectorStore -from app.models import RuleDocument, RuleMetadata - - -def test_ingest_example_rule(): - """Test ingesting the example rule and searching.""" - # Override settings for test - test_data_dir = Path(__file__).parent.parent / "data" - test_chroma_dir = test_data_dir / "chroma_test" - test_rules_dir = test_data_dir / "rules" - - vs = VectorStore(test_chroma_dir, settings.embedding_model) - vs.clear_all() - - # Load example rule - example_rule_path = test_rules_dir / "example_rule.md" - if not example_rule_path.exists(): - print(f"Example rule not found at {example_rule_path}, skipping test") - return - - content = example_rule_path.read_text(encoding="utf-8") - import re - import yaml - - pattern = r"^---\s*\n(.*?)\n---\s*\n(.*)$" - match = re.match(pattern, content, re.DOTALL) - if match: - metadata_dict = yaml.safe_load(match.group(1)) - body = match.group(2).strip() - metadata = RuleMetadata(**metadata_dict) - doc = RuleDocument( - metadata=metadata, content=body, source_file=str(example_rule_path) - ) - vs.add_document(doc) - - # Verify count - assert vs.count() == 1, f"Expected 1 rule, got {vs.count()}" - - # Search for relevant content - results = vs.search("runner steal base", top_k=5) - assert len(results) > 0, "Expected at least one search result" - assert ( - results[0].rule_id == "5.2.1(b)" - ), f"Expected rule 5.2.1(b), got {results[0].rule_id}" - - print("✓ Test passed: Ingestion and search work correctly") - print(f" Found rule: {results[0].title}") - print(f" Similarity: {results[0].similarity:.2%}") - - # Cleanup - vs.clear_all() - - -if __name__ == "__main__": - test_ingest_example_rule() diff --git a/uv.lock b/uv.lock index d75581d..26cb5cb 100644 --- a/uv.lock +++ b/uv.lock @@ -540,15 +540,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/a7/17208c3b3f92319e7fad259f1c6d5a5baf8fd0654c54846ced329f83c3eb/discord_py-2.7.1-py3-none-any.whl", hash = "sha256:849dca2c63b171146f3a7f3f8acc04248098e9e6203412ce3cf2745f284f7439", size = 1227550, upload-time = "2026-03-03T18:40:44.492Z" }, ] -[[package]] -name = "distro" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, -] - [[package]] name = "durationpy" version = "0.10" @@ -996,91 +987,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jiter" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, - { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, - { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, - { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, - { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, - { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, - { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, - { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, - { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, - { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, - { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, - { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, - { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, - { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, - { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, - { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, - { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, - { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, - { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, - { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, - { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, - { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, - { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, - { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, - { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, - { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, - { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, - { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, - { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, - { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, - { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, - { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, - { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, - { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, - { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, - { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, - { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, - { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, - { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, - { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, - { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, - { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, - { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, - { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, - { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, - { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, - { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, - { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, - { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, - { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, - { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, - { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, - { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, - { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, - { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, - { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, - { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, - { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, - { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, - { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, -] - [[package]] name = "joblib" version = "1.5.3" @@ -1734,25 +1640,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/d6/413e98ab666c6fb9e8be7d1c6eb3bd403b0bea1b8d42db066dab98c7df07/onnxruntime-1.24.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02aaf6ddfa784523b6873b4176a79d508e599efe12ab0ea1a3a6e7314408b7aa", size = 17240738, upload-time = "2026-03-05T17:18:15.203Z" }, ] -[[package]] -name = "openai" -version = "2.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d7/91/2a06c4e9597c338cac1e5e5a8dd6f29e1836fc229c4c523529dca387fda8/openai-2.26.0.tar.gz", hash = "sha256:b41f37c140ae0034a6e92b0c509376d907f3a66109935fba2c1b471a7c05a8fb", size = 666702, upload-time = "2026-03-05T23:17:35.874Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/2e/3f73e8ca53718952222cacd0cf7eecc9db439d020f0c1fe7ae717e4e199a/openai-2.26.0-py3-none-any.whl", hash = "sha256:6151bf8f83802f036117f06cc8a57b3a4da60da9926826cc96747888b57f394f", size = 1136409, upload-time = "2026-03-05T23:17:34.072Z" }, -] - [[package]] name = "opentelemetry-api" version = "1.40.0" @@ -2983,15 +2870,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - [[package]] name = "sqlalchemy" version = "2.0.48" @@ -3068,7 +2946,6 @@ dependencies = [ { name = "discord-py" }, { name = "fastapi" }, { name = "httpx" }, - { name = "openai" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, @@ -3093,7 +2970,6 @@ requires-dist = [ { name = "discord-py", specifier = ">=2.5.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.27.0" }, - { name = "openai", specifier = ">=1.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },