Files
ADHDbot/AIInteraction.py
2025-11-11 23:11:59 -06:00

151 lines
5.9 KiB
Python

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":"<verbatim text>"} 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",
"topic": "adhdbot-<user id>",
"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"})