diff --git a/.clinerules b/.clinerules index da8e3fb..e798975 100644 --- a/.clinerules +++ b/.clinerules @@ -80,4 +80,10 @@ Bot: - Avoid long explanations unless asked # === GOAL === -Act like a mentor helping users complete an AOSSIE template repo step-by-step. \ No newline at end of file +Act like a mentor helping users complete an AOSSIE template repo step-by-step. + +# === CLARIFYING FLOW FOR OOD/UNSUPPORTED QUERIES === +If the user's question does not match setup, README, contribute, or error topics: +- Acknowledge that the query does not directly match the standard template tasks. +- Ask a friendly, concise clarifying question to guide them back to one of the supported tasks (setup, README, contributing, or error debugging). +- Under no circumstances should you generate answers outside these core categories. \ No newline at end of file diff --git a/bot.py b/bot.py index c258633..184f173 100644 --- a/bot.py +++ b/bot.py @@ -1,4 +1,5 @@ import os +import re import json import logging import discord @@ -104,6 +105,20 @@ async def generate_ollama_response(prompt: str, context: str) -> tuple[str, bool logger.warning(f"Empty Ollama response (attempt {attempt}/{MAX_RETRIES})") except httpx.TimeoutException: logger.error(f"Ollama timed out (attempt {attempt}/{MAX_RETRIES})") + except httpx.HTTPStatusError as e: + logger.error(f"Ollama HTTP error {e.response.status_code} (attempt {attempt}/{MAX_RETRIES}): {e}") + if e.response.status_code == 404: + err_msg = ( + f"I'm sorry, the local Ollama model '{OLLAMA_MODEL}' was not found (HTTP 404).\n" + f"Please contact @kpj2006 or run `ollama pull {OLLAMA_MODEL}` on your machine." + ) + return err_msg, True + elif 400 <= e.response.status_code < 500: + err_msg = ( + f"Local Ollama configuration or client error (HTTP {e.response.status_code}).\n" + f"Details: {e.response.text}" + ) + return err_msg, True except httpx.RequestError as e: logger.error(f"Ollama unreachable (attempt {attempt}/{MAX_RETRIES}): {e}") except Exception as e: @@ -146,6 +161,18 @@ async def _get_or_create_thread(message: discord.Message, channel: discord.TextC logger.warning(f"Thread {thread.id} is archived/locked — creating a new one") return None # cannot create thread from message already in a thread + # If the message already has a thread attached to it, fetch and use it + if message.flags.has_thread: + try: + thread = message.guild.get_thread(message.id) if message.guild else None + if not thread: + thread = await client.fetch_channel(message.id) + if isinstance(thread, discord.Thread) and not thread.archived and not thread.locked: + logger.info(f"Reusing existing active thread {thread.id} from message object") + return thread + except Exception as fetch_err: + logger.error(f"Failed to fetch existing thread for message {message.id}: {fetch_err}") + try: author = message.author thread = await message.create_thread( @@ -157,12 +184,46 @@ async def _get_or_create_thread(message: discord.Message, channel: discord.TextC except discord.Forbidden: logger.error(f"Cannot create thread — missing permissions in channel {channel.id}") except discord.HTTPException as e: - logger.error(f"Discord API error creating thread: {e}") + if e.code == 160004: + logger.info(f"Thread already exists for message {message.id}. Attempting to retrieve it...") + try: + # Thread ID equals the message ID it was created from + thread = message.guild.get_thread(message.id) if message.guild else None + if not thread: + thread = await client.fetch_channel(message.id) + if isinstance(thread, discord.Thread): + logger.info(f"Successfully retrieved existing thread {thread.id}") + return thread + except Exception as fetch_err: + logger.error(f"Failed to fetch existing thread for message {message.id}: {fetch_err}") + else: + logger.error(f"Discord API error creating thread: {e}") except Exception as e: logger.error(f"Unexpected error creating thread for {message.author.id}: {e}") return None +def is_query_covered(query: str) -> bool: + """Check if the query contains keywords covered in .clinerules using word boundaries.""" + q = query.lower() + + # Predefined keyword maps based on .clinerules + categories = { + "setup": ["setup", "install", "run", "build", "clone", "docker", "env", "start", "dev server", "npm run dev"], + "readme": ["readme", "read me", "documentation", "project name", "description", "user flow", "feature"], + "contribute": ["contribute", "contributor", "fork", "pr", "pull request", "issue", "branch", "git", "onboarding"], + "error": ["error", "exception", "bug", "fail", "crash", "issue", "logs", "broken", "debug", "not working"] + } + + for cat, keywords in categories.items(): + for kw in keywords: + # Use raw pattern and re.escape for safety, matching word boundaries for the keyword/phrase + pattern = r'\b' + re.escape(kw) + r'\b' + if re.search(pattern, q): + return True + return False + + async def process_message(message: discord.Message): """Process a single message: new messages in the main channel spawn a thread, messages in existing threads continue the conversation there.""" @@ -170,9 +231,12 @@ async def process_message(message: discord.Message): return is_in_thread = isinstance(message.channel, discord.Thread) - is_in_configured_channel = message.channel.id == DISCORD_CHANNEL_ID_INT + is_in_configured_channel = ( + (message.channel.parent_id if is_in_thread else message.channel.id) + == DISCORD_CHANNEL_ID_INT + ) - if not is_in_thread and not is_in_configured_channel: + if not is_in_configured_channel: return author = message.author @@ -186,7 +250,7 @@ async def process_message(message: discord.Message): channel = message.channel thread = await _get_or_create_thread(message, channel) if not thread: - _log_gap(message.content, "thread_creation_failed") + await _log_gap(message.content, "thread_creation_failed") try: await message.reply( "I couldn't create a thread to answer your question. Please ask a maintainer for help." @@ -212,10 +276,31 @@ async def process_message(message: discord.Message): else: full_prompt = message.content + # Check if the query has sufficient information/context based on .clinerules + if not is_query_covered(message.content): + # Pass conversation context explicitly to the LLM so it has thread history for the clarifying question + history_str = f"Previous conversation history:\n{conversation_context}\n\n" if conversation_context else "" + full_prompt = ( + f"{history_str}" + f"The user is asking: '{message.content}'. " + f"This query is not covered by the standard guidelines in .clinerules. " + f"Generate a polite response asking the user to clarify if they need help with: " + f"1. Setting up the project template\n" + f"2. Writing or updating the README\n" + f"3. Contributing to the repository\n" + f"4. Debugging an error\n" + f"Keep the response short, friendly, and under 5 lines." + ) + await _log_gap( + message.content, + "insufficient_info", + thread_id=thread.id, + ) + response_text, used_fallback = await generate_ollama_response(full_prompt, skill_context) if used_fallback or not skill_context: - _log_gap( + await _log_gap( message.content, "ollama_unavailable" if used_fallback else "no_skill_context", thread_id=thread.id, @@ -223,7 +308,7 @@ async def process_message(message: discord.Message): except Exception as e: logger.error(f"Unexpected error processing message from {author.name}: {e}") response_text = "An unexpected error occurred. Please try again or ask a maintainer." - _log_gap(message.content, f"processing_error: {e}", thread_id=thread.id) + await _log_gap(message.content, f"processing_error: {e}", thread_id=thread.id) if len(response_text) > 1900: response_text = response_text[:1896] + "..."