chore: initial import
This commit is contained in:
150
AIInteraction.py
Normal file
150
AIInteraction.py
Normal file
@@ -0,0 +1,150 @@
|
||||
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"})
|
||||
Reference in New Issue
Block a user