import json import os from datetime import datetime, timezone import urllib.error import urllib.request from typing import Any, Dict, List, Optional from Memory import MemoryManager from AppConfig import readToolInstructions from Notification import NotificationDispatcher openRouterEndpoint = "https://openrouter.ai/api/v1/chat/completions" openRouterModel = "anthropic/claude-haiku-4.5" baseSystemPrompt = """You are Chelsea, an ADHD-focused executive-function coach who helps users get unstuck with empathy, tiny actionable steps, and supportive reminder workflows. Principles: - Sound warm, concise, and encouraging. Mirror the user's tone gently. - Always clarify the desired outcome before suggesting steps. - Break plans into 2-5 observable actions with relaxed estimates ("~5 min", "1 song"). - Offer an initiation nudge ("Want me to save this plan or set a reminder?"). - Never hallucinate capabilities outside notes/reminders/task breakdowns. - Structured actions: - If the user clearly wants to capture a thought, emit one ```json block with {"action":"take_note","note":""} and keep conversational text short. - If the user wants to save a plan, emit the `store_task` JSON (title, steps, next_step, context, status). - If the user confirms reminder details (task, timing, delivery), emit exactly ONE ```json block in this simplified shape: { "action": "schedule_reminder", "reminder": { "message": "short friendly text", "trigger": { "value": "ISO 8601 timestamp" } } } - Keep the conversational reply outside the JSON block. - When the user gives relative timing ("in 10 minutes", "tomorrow at 9"), convert it to a specific ISO 8601 timestamp using the current UTC time provided in system context. - Only output a JSON block after the user explicitly agrees or gives all required info. - Outside of JSON blocks, stay conversational; never mix multiple JSON blocks in one reply. Whenever you emit `schedule_reminder`, assume another service will fan out push notifications, so keep the natural language summary clear and mention timing explicitly. """ class AIInteraction: """Keeps high-level AI steps together so Discord plumbing stays focused.""" @staticmethod def callAI(userId, category, promptName, context, history=None, modeHint=None): history = history or [] userText = context or "" messages = AIInteraction.composeMessages(userText, history, modeHint) AIInteraction.logPrompt(messages, userId) response = AIInteraction.requestCompletion(messages) if response: MemoryManager.parseAiResponse(userId, response) NotificationDispatcher.handleAiResponse(userId, response) return response @staticmethod def composeMessages(latestUserText: str, history, modeHint: Optional[str]): systemContent = AIInteraction.buildSystemPrompt() current_time = datetime.now(timezone.utc).replace(microsecond=0).isoformat() messages: List[Dict[str, str]] = [ {"role": "system", "content": systemContent}, {"role": "system", "content": f"Current UTC time: {current_time}"}, ] if modeHint: messages.append({ "role": "system", "content": f"Mode hint: {modeHint}. Blend this focus into your reply while honoring all instructions.", }) allowed_roles = {"user", "assistant"} trimmed_history = history[-12:] for turn in trimmed_history: role = (turn.get("role") or "").strip().lower() content = (turn.get("content") or "").strip() if role not in allowed_roles or not content: continue messages.append({"role": role, "content": content}) if latestUserText: messages.append({"role": "user", "content": latestUserText}) return messages @staticmethod def buildSystemPrompt(): instructions = readToolInstructions() if instructions: return f"{baseSystemPrompt}\n\nTooling contract (mandatory):\n{instructions}" return baseSystemPrompt @staticmethod def logPrompt(messages: List[Dict[str, str]], userId): if not os.getenv("LOG_PROMPTS", "1"): return header = f"[ai] prompt (user={userId})" divider = "-" * len(header) formatted = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages]) print(f"{header}\n{divider}\n{formatted}\n{divider}") @staticmethod def requestCompletion(messages: List[Dict[str, str]]): apiKey = os.getenv("OPENROUTER_API_KEY") fallback = messages[-1]["content"] if messages else "" if not apiKey: return f"(offline mode) You said: {fallback}" payload = { "model": openRouterModel, "messages": messages, } encoded = json.dumps(payload).encode("utf-8") request = urllib.request.Request( openRouterEndpoint, data=encoded, method="POST", headers={ "Content-Type": "application/json", "Authorization": f"Bearer {apiKey}", }, ) try: with urllib.request.urlopen(request, timeout=20) as response: body = response.read().decode("utf-8") except (urllib.error.URLError, urllib.error.HTTPError): return fallback try: data = json.loads(body) choices = data.get("choices") or [] firstChoice = choices[0] if choices else {} message = (firstChoice.get("message") or {}).get("content") return message or fallback except (json.JSONDecodeError, IndexError): return fallback @staticmethod def takeNote(userId, noteContent): if not noteContent: return MemoryManager.recordNote(userId, noteContent, {"source": "manual"})