150 lines
5.9 KiB
Python
150 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",
|
|
"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"})
|