diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f9cd395 --- /dev/null +++ b/.env.example @@ -0,0 +1,59 @@ +# --------------------------------------------------------------- +# Meister-Eder Email Agent — Configuration Template +# --------------------------------------------------------------- +# Copy this file to .env and fill in your values. +# The .env file must NOT be committed to version control. +# --------------------------------------------------------------- + +# --------------------------------------------------------------- +# AI Provider +# --------------------------------------------------------------- +# Choose "anthropic" (Claude) or "openai" (GPT). +AI_PROVIDER=anthropic + +# Optional: override the default model for the chosen provider. +# Anthropic default: claude-opus-4-6 +# OpenAI default: gpt-4o +# AI_MODEL= + +# --------------------------------------------------------------- +# API Keys — set the one matching your AI_PROVIDER +# --------------------------------------------------------------- +ANTHROPIC_API_KEY=sk-ant-... +# OPENAI_API_KEY=sk-... + +# --------------------------------------------------------------- +# Email — IMAP (receiving parent messages) +# --------------------------------------------------------------- +IMAP_HOST=imap.example.com +IMAP_PORT=993 +IMAP_USERNAME=anmeldung@example.com +IMAP_PASSWORD=your-imap-password +IMAP_USE_SSL=true + +# --------------------------------------------------------------- +# Email — SMTP (sending replies and notifications) +# --------------------------------------------------------------- +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USE_TLS=true + +# --------------------------------------------------------------- +# Registration email address (displayed as sender to parents) +# --------------------------------------------------------------- +REGISTRATION_EMAIL=anmeldung@example.com + +# --------------------------------------------------------------- +# Storage +# --------------------------------------------------------------- +# Directory for conversation state and completed registrations. +DATA_DIR=data + +# Path to the knowledge-base markdown files (admin-editable). +KNOWLEDGE_BASE_DIR=openspec/changes/define-project-scope/content/knowledge-base + +# --------------------------------------------------------------- +# Polling +# --------------------------------------------------------------- +# How often (in seconds) to check the inbox for new messages. +POLL_INTERVAL=60 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa1968e --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Environment / secrets +.env + +# Agent data (conversations and registrations stored at runtime) +data/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo diff --git a/main.py b/main.py new file mode 100644 index 0000000..c14abf6 --- /dev/null +++ b/main.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Meister-Eder — Email Registration Agent for Spielgruppe Pumuckl. + +Usage +----- +Copy `.env.example` to `.env`, fill in your credentials, then run: + + python main.py + +The agent polls the configured IMAP inbox every POLL_INTERVAL seconds, +processes new messages, and replies via SMTP. + +Environment variables (see .env.example for full list): + AI_PROVIDER anthropic | openai (default: anthropic) + AI_MODEL Model name override (default: provider default) + ANTHROPIC_API_KEY Required if AI_PROVIDER=anthropic + OPENAI_API_KEY Required if AI_PROVIDER=openai + IMAP_HOST IMAP server hostname + IMAP_PORT IMAP port (default: 993) + IMAP_USERNAME Email account username + IMAP_PASSWORD Email account password + SMTP_HOST SMTP server hostname + SMTP_PORT SMTP port (default: 587) + REGISTRATION_EMAIL Sender address shown to parents + DATA_DIR Directory for JSON storage (default: data/) + POLL_INTERVAL Seconds between inbox polls (default: 60) +""" + +import logging +import sys +import time + +from src.agent.core import EmailAgent +from src.channels.email_channel import EmailChannel +from src.config import Config +from src.knowledge_base.loader import KnowledgeBase +from src.notifications.notifier import AdminNotifier +from src.providers import create_provider +from src.storage.json_store import ConversationStore + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", +) +logger = logging.getLogger(__name__) + + +def build_components(config: Config): + """Instantiate and wire together all agent components.""" + + # Resolve API key for the chosen provider + if config.ai_provider == "anthropic": + if not config.anthropic_api_key: + logger.error("ANTHROPIC_API_KEY is required when AI_PROVIDER=anthropic") + sys.exit(1) + api_key = config.anthropic_api_key + elif config.ai_provider == "openai": + if not config.openai_api_key: + logger.error("OPENAI_API_KEY is required when AI_PROVIDER=openai") + sys.exit(1) + api_key = config.openai_api_key + else: + logger.error("Unknown AI_PROVIDER '%s'. Choose 'anthropic' or 'openai'.", config.ai_provider) + sys.exit(1) + + provider = create_provider(config.ai_provider, api_key, config.ai_model) + logger.info("AI provider: %s / model: %s", config.ai_provider, provider.model_name) + + kb = KnowledgeBase(config.knowledge_base_dir) + store = ConversationStore(config.data_dir) + + notifier = AdminNotifier( + smtp_host=config.smtp_host, + smtp_port=config.smtp_port, + username=config.imap_username, + password=config.imap_password, + use_tls=config.smtp_use_tls, + from_email=config.registration_email, + ) + + agent = EmailAgent(provider=provider, kb=kb, store=store, notifier=notifier) + + channel = EmailChannel( + imap_host=config.imap_host, + imap_port=config.imap_port, + smtp_host=config.smtp_host, + smtp_port=config.smtp_port, + username=config.imap_username, + password=config.imap_password, + use_ssl=config.imap_use_ssl, + use_tls=config.smtp_use_tls, + registration_email=config.registration_email, + ) + + return agent, channel + + +def run_poll_loop(agent: EmailAgent, channel: EmailChannel, poll_interval: int) -> None: + """Main polling loop — never returns unless interrupted.""" + logger.info("Agent started. Polling every %ds for new messages.", poll_interval) + + while True: + try: + messages = channel.fetch_unread_messages() + + for msg in messages: + logger.info( + "Processing message from %s (thread: %s)", + msg["from"], + msg["thread_id"], + ) + try: + reply = agent.process_message( + conversation_id=msg["thread_id"], + parent_email=msg["from"], + message_text=msg["body"], + ) + if reply: + channel.send_reply( + to=msg["from"], + subject=msg["subject"], + body=reply, + in_reply_to=msg["message_id"], + references=msg["references"], + ) + except Exception: + logger.exception( + "Unhandled error processing message from %s", msg["from"] + ) + + except KeyboardInterrupt: + logger.info("Shutdown requested — stopping.") + break + except Exception: + logger.exception("Unexpected error in poll loop") + + time.sleep(poll_interval) + + +def main() -> None: + config = Config.from_env() + + if not config.imap_host: + logger.error( + "IMAP_HOST is not set. " + "Copy .env.example to .env and fill in your email credentials." + ) + sys.exit(1) + + agent, channel = build_components(config) + run_poll_loop(agent, channel, config.poll_interval) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9304899 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +# Core — at least one AI provider SDK is required +anthropic>=0.40.0 +openai>=1.50.0 + +# Configuration +python-dotenv>=1.0.0 + +# Registration schema validation +jsonschema>=4.23.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agent/__init__.py b/src/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agent/core.py b/src/agent/core.py new file mode 100644 index 0000000..564ea20 --- /dev/null +++ b/src/agent/core.py @@ -0,0 +1,216 @@ +"""EmailAgent — the channel-agnostic conversation orchestrator.""" + +import json +import logging +import re +from datetime import datetime, timezone + +from ..models.conversation import ConversationState, ChatMessage +from ..models.registration import BookingDay +from ..providers.base import LLMProvider, LLMMessage +from ..knowledge_base.loader import KnowledgeBase +from ..storage.json_store import ConversationStore +from ..notifications.notifier import AdminNotifier +from .prompts import build_system_prompt + +logger = logging.getLogger(__name__) + + +class EmailAgent: + """Processes one inbound message and returns the agent's reply text. + + All business logic (conversation flow, field extraction, validation, + storage, notifications) lives here. Channel-specific I/O is handled + by the caller. + """ + + def __init__( + self, + provider: LLMProvider, + kb: KnowledgeBase, + store: ConversationStore, + notifier: AdminNotifier, + ) -> None: + self._provider = provider + self._kb = kb + self._store = store + self._notifier = notifier + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def process_message( + self, + conversation_id: str, + parent_email: str, + message_text: str, + ) -> str: + """Process an inbound message and return the reply to send back. + + Args: + conversation_id: Unique thread identifier (from email headers). + parent_email: Sender's email address. + message_text: Stripped plain-text body of the inbound email. + + Returns: + Reply text to send to the parent. + """ + # Load or create conversation state + state = self._store.load(conversation_id) + if state is None: + state = ConversationState( + conversation_id=conversation_id, + parent_email=parent_email, + ) + + now = datetime.now(timezone.utc).isoformat() + state.last_activity = now + + # Append the user's message to history + state.messages.append(ChatMessage(role="user", content=message_text)) + + # Build system prompt and conversation history for the LLM + system = build_system_prompt(self._kb, state) + llm_messages = [ + LLMMessage(role=m.role, content=m.content) for m in state.messages + ] + + # Call the LLM + try: + response = self._provider.complete(system=system, messages=llm_messages) + parsed = self._parse_llm_response(response.content) + except Exception: + logger.exception("LLM call failed for conversation %s", conversation_id) + parsed = self._fallback_response(state) + + reply_text: str = parsed.get("reply", "") + updates: dict = parsed.get("updates", {}) or {} + next_step: str = parsed.get("next_step", state.flow_step) + is_complete: bool = bool(parsed.get("registration_complete", False)) + language: str = parsed.get("language", state.language) + + # Apply extracted field updates + self._apply_updates(state, updates) + + # Update conversation metadata + state.flow_step = next_step + state.language = language + state.updated_at = now + + # Record the assistant reply + state.messages.append(ChatMessage(role="assistant", content=reply_text)) + + # Handle registration completion + if is_complete and not state.completed: + state.completed = True + registration_id = self._store.save_registration(state) + try: + self._notifier.notify_admin( + registration=state.registration, + registration_id=registration_id, + conversation_id=state.conversation_id, + channel="email", + ) + except Exception: + logger.exception("Failed to send admin notification for %s", registration_id) + logger.info("Registration complete: %s", registration_id) + + # Persist conversation state + self._store.save(state) + + return reply_text + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _parse_llm_response(self, content: str) -> dict: + """Extract the JSON payload from the LLM's output.""" + text = content.strip() + + # Strip markdown code fences if present + fence_pattern = re.compile(r"^```(?:json)?\s*\n(.*?)\n```\s*$", re.DOTALL) + match = fence_pattern.match(text) + if match: + text = match.group(1).strip() + + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + # Fallback: find the first {...} block + brace_match = re.search(r"\{.*\}", text, re.DOTALL) + if brace_match: + try: + return json.loads(brace_match.group()) + except json.JSONDecodeError: + pass + + logger.warning("Could not parse LLM response as JSON; using raw text as reply.") + return { + "reply": content, + "updates": {}, + "next_step": "greeting", + "registration_complete": False, + "language": "de", + } + + def _fallback_response(self, state: ConversationState) -> dict: + """Return a safe fallback when the LLM call fails.""" + if state.language == "en": + msg = ( + "I'm sorry, I'm having a technical issue right now. " + "Please try again in a moment or contact us directly." + ) + else: + msg = ( + "Entschuldigung, ich habe gerade ein technisches Problem. " + "Bitte versuche es gleich nochmal oder kontaktiere uns direkt." + ) + return { + "reply": msg, + "updates": {}, + "next_step": state.flow_step, + "registration_complete": False, + "language": state.language, + } + + def _apply_updates(self, state: ConversationState, updates: dict) -> None: + """Write extracted field values into the RegistrationData object.""" + reg = state.registration + + field_map = { + "child.fullName": lambda v: setattr(reg.child, "full_name", v), + "child.dateOfBirth": lambda v: setattr(reg.child, "date_of_birth", v), + "child.specialNeeds": lambda v: setattr(reg.child, "special_needs", v), + "parentGuardian.fullName": lambda v: ( + setattr(reg.parent_guardian, "full_name", v), + setattr(state, "parent_name", v), + ), + "parentGuardian.streetAddress": lambda v: setattr(reg.parent_guardian, "street_address", v), + "parentGuardian.postalCode": lambda v: setattr(reg.parent_guardian, "postal_code", str(v)), + "parentGuardian.city": lambda v: setattr(reg.parent_guardian, "city", v), + "parentGuardian.phone": lambda v: setattr(reg.parent_guardian, "phone", v), + "parentGuardian.email": lambda v: setattr(reg.parent_guardian, "email", v), + "emergencyContact.fullName": lambda v: setattr(reg.emergency_contact, "full_name", v), + "emergencyContact.phone": lambda v: setattr(reg.emergency_contact, "phone", v), + } + + for key, value in updates.items(): + if value is None: + continue + + if key in field_map: + field_map[key](value) + elif key == "booking.playgroupTypes" and isinstance(value, list): + reg.booking.playgroup_types = value + elif key == "booking.selectedDays" and isinstance(value, list): + reg.booking.selected_days = [ + BookingDay(day=d["day"], type=d["type"]) + for d in value + if isinstance(d, dict) and "day" in d and "type" in d + ] + else: + logger.debug("Unknown update key ignored: %s", key) diff --git a/src/agent/prompts.py b/src/agent/prompts.py new file mode 100644 index 0000000..8a43b92 --- /dev/null +++ b/src/agent/prompts.py @@ -0,0 +1,132 @@ +"""Build the system prompt sent to the LLM on every turn.""" + +import json + +from ..knowledge_base.loader import KnowledgeBase +from ..models.conversation import ConversationState + +# --------------------------------------------------------------------------- +# Step descriptions help the model understand where it is in the flow. +# --------------------------------------------------------------------------- +STEP_DESCRIPTIONS = { + "greeting": "Greet the parent and detect their intent (registration vs. questions).", + "child_name": "Ask for the child's full name.", + "child_dob": ( + "Ask for the child's date of birth. " + "Validate age: indoor requires ≥2.5 years, outdoor requires ≥3 years." + ), + "playgroup_selection": ( + "Explain both playgroup options and ask which the parent wants " + "(indoor / outdoor / both) and which days." + ), + "special_needs": ( + "Ask whether the child has any special needs, allergies, or medical conditions." + ), + "parent_contact": ( + "Collect the parent/guardian's full name, street address, postal code (4 digits), " + "city, phone number, and email address." + ), + "emergency_contact": ( + "Ask for an emergency contact (someone other than the parent): full name and phone." + ), + "confirmation": ( + "Show a summary of all collected information and ask the parent to confirm." + ), + "complete": "Thank the parent, mention fees and next steps. Registration is done.", +} + + +def build_system_prompt(kb: KnowledgeBase, state: ConversationState) -> str: + """Return the full system prompt for the current conversation turn.""" + + kb_content = kb.get_all() + reg_json = json.dumps(state.registration.to_dict(), ensure_ascii=False, indent=2) + step_hint = STEP_DESCRIPTIONS.get(state.flow_step, "Continue the conversation.") + + return f"""You are the registration assistant for Spielgruppe Pumuckl, run by Familienverein Fällanden in Fällanden, Switzerland. You help parents register their children for the playgroup and answer questions about it. + +## Your Personality +- Warm, friendly, and helpful — like a caring playgroup staff member +- Use informal "du" in German (never the formal "Sie") +- Auto-detect the parent's language from their message; respond in the same language +- Default language is German if unclear +- Ask 1–2 questions at a time — never send an overwhelming form-like list +- Be patient and understanding; never make parents feel they made a mistake + +## Registration Flow (8 steps) +1. greeting — greet and detect intent +2. child_name — ask for child's full name +3. child_dob — ask for date of birth; validate age (indoor ≥2.5 yrs, outdoor ≥3 yrs) +4. playgroup_selection — present options, collect type(s) and day(s) +5. special_needs — ask about special needs / allergies / medical conditions +6. parent_contact — name, street address, postal code, city, phone, email +7. emergency_contact — emergency contact name and phone +8. confirmation — show full summary; ask to confirm; submit on confirmation +9. complete — thank parent, mention CHF 80 registration fee, monthly fees, and contacts + +**Current step: {state.flow_step}** +**What to do now: {step_hint}** + +At any point the parent may ask a question. Answer it from the knowledge base, then offer to continue the registration. + +## Current Registration Data (so far) +```json +{reg_json} +``` + +## Knowledge Base +Use the information below to answer parent questions accurately: + +{kb_content} + +## Playgroup Details +- **Indoor (Innenspielgruppe)**: Mon / Wed / Thu, 09:00–11:30 | CHF 130/260/390 per month (1/2/3×/week) +- **Outdoor Forest (Waldspielgruppe)**: Mon only, 09:00–14:00 (includes snack & lunch) | CHF 250/month +- **One-time registration fee**: CHF 80 (first year); CHF 80 craft materials from second year +- **Cleaning deposit (indoor only)**: CHF 50 (refundable) +- **Sibling discount**: 10% per additional child +- **July & August**: fee-free + +## Admin Contacts +- Administration: Markus Graf — spielgruppen@familien-verein.ch — 079 261 16 37 +- Indoor leader: Andrea Sigrist — andrea.sigrist@gmx.net — 079 674 99 92 +- Outdoor leader: Barbara Gross — baba.laeubli@gmail.com — 078 761 19 64 + +--- + +## CRITICAL: Response Format + +You MUST respond with **only** a valid JSON object — no markdown, no extra text outside the JSON. + +```json +{{ + "reply": "Your conversational message to the parent (plain text, NOT JSON)", + "updates": {{ + "child.fullName": "string or null", + "child.dateOfBirth": "YYYY-MM-DD or null", + "child.specialNeeds": "string or null", + "parentGuardian.fullName": "string or null", + "parentGuardian.streetAddress": "string or null", + "parentGuardian.postalCode": "4-digit string or null", + "parentGuardian.city": "string or null", + "parentGuardian.phone": "string or null", + "parentGuardian.email": "string or null", + "emergencyContact.fullName": "string or null", + "emergencyContact.phone": "string or null", + "booking.playgroupTypes": ["indoor", "outdoor"] or null, + "booking.selectedDays": [{{"day": "monday", "type": "indoor"}}] or null + }}, + "next_step": "greeting|child_name|child_dob|playgroup_selection|special_needs|parent_contact|emergency_contact|confirmation|complete", + "registration_complete": false, + "language": "de" +}} +``` + +Rules: +- Only set fields in `updates` that you actually extracted from the parent's **latest message**. Use `null` for everything else. +- Set `registration_complete` to `true` **only** when ALL required fields are filled AND the parent has just confirmed the summary is correct. +- Dates must be YYYY-MM-DD. Postal codes must be exactly 4 digits. +- Valid days: "monday", "wednesday", "thursday" (indoor) or "monday" (outdoor). +- `language` must be "de" or "en" based on the parent's message. +- The `reply` field must be natural, friendly, conversational text — not JSON and not a list of fields. +""" diff --git a/src/channels/__init__.py b/src/channels/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/channels/email_channel.py b/src/channels/email_channel.py new file mode 100644 index 0000000..778807a --- /dev/null +++ b/src/channels/email_channel.py @@ -0,0 +1,289 @@ +"""IMAP / SMTP email channel adapter. + +Handles: +- Polling the inbox for unread messages (IMAP) +- Thread tracking via Message-ID / In-Reply-To / References headers +- Sending reply emails (SMTP) with proper threading headers +- Stripping quoted reply text so the agent only sees the new content +""" + +import email +import email.header +import email.utils +import imaplib +import logging +import re +import smtplib +import time +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Optional + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _decode_header(value: str) -> str: + """Decode an RFC-2047 encoded email header value.""" + parts = email.header.decode_header(value or "") + decoded = [] + for part, charset in parts: + if isinstance(part, bytes): + decoded.append(part.decode(charset or "utf-8", errors="replace")) + else: + decoded.append(part) + return "".join(decoded) + + +def _extract_text(msg: email.message.Message) -> str: + """Extract the plain-text body from a (potentially multi-part) message.""" + if msg.is_multipart(): + for part in msg.walk(): + if ( + part.get_content_type() == "text/plain" + and "attachment" not in str(part.get("Content-Disposition", "")) + ): + charset = part.get_content_charset() or "utf-8" + payload = part.get_payload(decode=True) + if payload: + return payload.decode(charset, errors="replace") + else: + charset = msg.get_content_charset() or "utf-8" + payload = msg.get_payload(decode=True) + if payload: + return payload.decode(charset, errors="replace") + return "" + + +def _strip_quoted_text(text: str) -> str: + """Remove quoted reply text from the email body. + + Heuristics: + - Drop lines starting with ">" + - Stop at common reply-separator patterns + """ + lines = text.splitlines() + result: list[str] = [] + for line in lines: + stripped = line.strip() + if stripped.startswith(">"): + continue + # Common separators used by email clients + if re.match(r"^-{3,}|^_{3,}|^={3,}", stripped): + break + if re.match(r"^On .+ wrote:$", stripped): + break + if re.match(r"^Am .+ schrieb .+:$", stripped): # German Outlook/Thunderbird + break + if "-----Original Message-----" in stripped: + break + result.append(line) + return "\n".join(result).strip() + + +def _generate_message_id(from_addr: str) -> str: + domain = from_addr.split("@")[-1] if "@" in from_addr else "meister-eder.local" + return f"<{time.time():.6f}.{id(from_addr)}@{domain}>" + + +# --------------------------------------------------------------------------- +# Main class +# --------------------------------------------------------------------------- + +class EmailChannel: + """Wraps IMAP polling and SMTP sending for the email conversation channel.""" + + def __init__( + self, + imap_host: str, + imap_port: int, + smtp_host: str, + smtp_port: int, + username: str, + password: str, + use_ssl: bool = True, + use_tls: bool = True, + registration_email: str = "", + ) -> None: + self._imap_host = imap_host + self._imap_port = imap_port + self._smtp_host = smtp_host + self._smtp_port = smtp_port + self._username = username + self._password = password + self._use_ssl = use_ssl + self._use_tls = use_tls + self._from_email = registration_email or username + + # ------------------------------------------------------------------ + # IMAP — receive + # ------------------------------------------------------------------ + + def fetch_unread_messages(self) -> list[dict]: + """Poll the inbox and return all unread messages as structured dicts. + + Each dict contains: + from — sender email address + subject — decoded subject line + message_id — Message-ID header of this email + in_reply_to — In-Reply-To header (may be empty) + references — References header (may be empty) + thread_id — canonical ID for the email thread + body — stripped plain-text body (quoted text removed) + """ + messages: list[dict] = [] + try: + imap = self._connect_imap() + imap.select("INBOX") + + _, data = imap.search(None, "UNSEEN") + msg_nums = data[0].split() + + for num in msg_nums: + try: + _, raw_data = imap.fetch(num, "(RFC822)") + raw = raw_data[0][1] + msg = email.message_from_bytes(raw) + + from_addr = email.utils.parseaddr(msg.get("From", ""))[1] + subject = _decode_header(msg.get("Subject", "(no subject)")) + message_id = msg.get("Message-ID", "").strip() + in_reply_to = msg.get("In-Reply-To", "").strip() + references = msg.get("References", "").strip() + + body = _extract_text(msg) + body = _strip_quoted_text(body) + + if not body.strip(): + imap.store(num, "+FLAGS", "\\Seen") + continue + + thread_id = self._resolve_thread_id(message_id, in_reply_to, references) + + messages.append( + { + "from": from_addr, + "subject": subject, + "message_id": message_id, + "in_reply_to": in_reply_to, + "references": references, + "thread_id": thread_id, + "body": body, + } + ) + imap.store(num, "+FLAGS", "\\Seen") + + except Exception: + logger.exception("Error processing IMAP message %s", num) + + imap.logout() + + except Exception: + logger.exception("IMAP connection/fetch error") + + return messages + + # ------------------------------------------------------------------ + # SMTP — send + # ------------------------------------------------------------------ + + def send_reply( + self, + to: str, + subject: str, + body: str, + in_reply_to: str = "", + references: str = "", + ) -> str: + """Send an email reply. + + Returns the new Message-ID so the caller can track the thread. + """ + new_message_id = _generate_message_id(self._from_email) + + # Ensure subject starts with "Re:" + if not subject.lower().startswith("re:"): + subject = f"Re: {subject}" + + # Build References chain + ref_parts = [r for r in [references, in_reply_to] if r] + new_references = " ".join(ref_parts) + + msg = MIMEMultipart("alternative") + msg["From"] = self._from_email + msg["To"] = to + msg["Subject"] = subject + msg["Message-ID"] = new_message_id + if in_reply_to: + msg["In-Reply-To"] = in_reply_to + if new_references: + msg["References"] = new_references + + msg.attach(MIMEText(body, "plain", "utf-8")) + + if not self._smtp_host: + logger.warning("SMTP not configured — reply NOT sent to %s: %s", to, subject) + logger.debug("Reply body:\n%s", body) + return new_message_id + + try: + if self._use_tls: + server = smtplib.SMTP(self._smtp_host, self._smtp_port) + server.starttls() + else: + server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port) + + server.login(self._username, self._password) + server.sendmail(self._from_email, [to], msg.as_string()) + server.quit() + logger.info("Reply sent to %s (thread %s)", to, in_reply_to or new_message_id) + except Exception: + logger.exception("Failed to send reply to %s", to) + + return new_message_id + + def send_reminder( + self, + to: str, + subject: str, + body: str, + in_reply_to: str = "", + references: str = "", + ) -> None: + """Send a reminder email for an incomplete registration.""" + self.send_reply( + to=to, + subject=subject, + body=body, + in_reply_to=in_reply_to, + references=references, + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _connect_imap(self) -> imaplib.IMAP4: + if self._use_ssl: + conn = imaplib.IMAP4_SSL(self._imap_host, self._imap_port) + else: + conn = imaplib.IMAP4(self._imap_host, self._imap_port) + conn.login(self._username, self._password) + return conn + + @staticmethod + def _resolve_thread_id(message_id: str, in_reply_to: str, references: str) -> str: + """Determine the canonical thread ID from email threading headers. + + The root of the thread is the first message ever sent — its ID is the + first token in the References header (oldest first convention). + """ + if references: + first_ref = references.strip().split()[0] + return first_ref + if in_reply_to: + return in_reply_to.strip() + return message_id.strip() or f"" diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..25dcca0 --- /dev/null +++ b/src/config.py @@ -0,0 +1,72 @@ +"""Configuration loaded from environment variables.""" + +import os +from dataclasses import dataclass, field +from pathlib import Path + +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass # python-dotenv is optional + + +@dataclass +class Config: + # AI Provider + ai_provider: str = "anthropic" # "anthropic" or "openai" + ai_model: str = "" + anthropic_api_key: str = "" + openai_api_key: str = "" + + # Email — IMAP (receiving) + imap_host: str = "" + imap_port: int = 993 + imap_username: str = "" + imap_password: str = "" + imap_use_ssl: bool = True + + # Email — SMTP (sending) + smtp_host: str = "" + smtp_port: int = 587 + smtp_use_tls: bool = True + + # Registration email address shown to parents + registration_email: str = "" + + # Storage + data_dir: Path = field(default_factory=lambda: Path("data")) + knowledge_base_dir: Path = field( + default_factory=lambda: Path( + "openspec/changes/define-project-scope/content/knowledge-base" + ) + ) + + # Polling interval in seconds + poll_interval: int = 60 + + @classmethod + def from_env(cls) -> "Config": + return cls( + ai_provider=os.getenv("AI_PROVIDER", "anthropic"), + ai_model=os.getenv("AI_MODEL", ""), + anthropic_api_key=os.getenv("ANTHROPIC_API_KEY", ""), + openai_api_key=os.getenv("OPENAI_API_KEY", ""), + imap_host=os.getenv("IMAP_HOST", ""), + imap_port=int(os.getenv("IMAP_PORT", "993")), + imap_username=os.getenv("IMAP_USERNAME", ""), + imap_password=os.getenv("IMAP_PASSWORD", ""), + imap_use_ssl=os.getenv("IMAP_USE_SSL", "true").lower() == "true", + smtp_host=os.getenv("SMTP_HOST", ""), + smtp_port=int(os.getenv("SMTP_PORT", "587")), + smtp_use_tls=os.getenv("SMTP_USE_TLS", "true").lower() == "true", + registration_email=os.getenv("REGISTRATION_EMAIL", ""), + data_dir=Path(os.getenv("DATA_DIR", "data")), + knowledge_base_dir=Path( + os.getenv( + "KNOWLEDGE_BASE_DIR", + "openspec/changes/define-project-scope/content/knowledge-base", + ) + ), + poll_interval=int(os.getenv("POLL_INTERVAL", "60")), + ) diff --git a/src/knowledge_base/__init__.py b/src/knowledge_base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/knowledge_base/loader.py b/src/knowledge_base/loader.py new file mode 100644 index 0000000..afe296d --- /dev/null +++ b/src/knowledge_base/loader.py @@ -0,0 +1,38 @@ +"""Load admin-editable knowledge-base markdown files into memory.""" + +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class KnowledgeBase: + """Reads markdown files from *kb_dir* and exposes them as a single string.""" + + def __init__(self, kb_dir: Path) -> None: + self._dir = kb_dir + self._content: dict[str, str] = {} + self._load() + + def _load(self) -> None: + if not self._dir.exists(): + logger.warning("Knowledge-base directory not found: %s", self._dir) + return + for path in sorted(self._dir.glob("*.md")): + self._content[path.stem] = path.read_text(encoding="utf-8") + logger.info("Loaded %d knowledge-base file(s) from %s", len(self._content), self._dir) + + def get_all(self) -> str: + """Return every KB file concatenated with section headers.""" + if not self._content: + return "(No knowledge-base content available.)" + sections = [ + f"### {name.upper().replace('-', ' ')}\n\n{content}" + for name, content in self._content.items() + ] + return "\n\n---\n\n".join(sections) + + def reload(self) -> None: + """Re-read all files from disk (useful when admins update content).""" + self._content = {} + self._load() diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/conversation.py b/src/models/conversation.py new file mode 100644 index 0000000..0574030 --- /dev/null +++ b/src/models/conversation.py @@ -0,0 +1,76 @@ +"""Conversation state model — persisted per email thread.""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional + +from .registration import RegistrationData + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class ChatMessage: + role: str # "user" or "assistant" + content: str + timestamp: str = field(default_factory=_now) + + +@dataclass +class ConversationState: + conversation_id: str + language: str = "de" # "de" or "en" + flow_step: str = "greeting" # current step in registration flow + registration: RegistrationData = field(default_factory=RegistrationData) + messages: list = field(default_factory=list) # list[ChatMessage] + parent_email: str = "" + parent_name: Optional[str] = None + created_at: str = field(default_factory=_now) + updated_at: str = field(default_factory=_now) + last_activity: str = field(default_factory=_now) + completed: bool = False + reminder_count: int = 0 + + def to_dict(self) -> dict: + return { + "conversation_id": self.conversation_id, + "language": self.language, + "flow_step": self.flow_step, + "registration": self.registration.to_dict(), + "messages": [ + {"role": m.role, "content": m.content, "timestamp": m.timestamp} + for m in self.messages + ], + "parent_email": self.parent_email, + "parent_name": self.parent_name, + "created_at": self.created_at, + "updated_at": self.updated_at, + "last_activity": self.last_activity, + "completed": self.completed, + "reminder_count": self.reminder_count, + } + + @classmethod + def from_dict(cls, data: dict) -> "ConversationState": + state = cls(conversation_id=data["conversation_id"]) + state.language = data.get("language", "de") + state.flow_step = data.get("flow_step", "greeting") + state.registration = RegistrationData.from_dict(data.get("registration", {})) + state.messages = [ + ChatMessage( + role=m["role"], + content=m["content"], + timestamp=m.get("timestamp", ""), + ) + for m in data.get("messages", []) + ] + state.parent_email = data.get("parent_email", "") + state.parent_name = data.get("parent_name") + state.created_at = data.get("created_at", "") + state.updated_at = data.get("updated_at", "") + state.last_activity = data.get("last_activity", "") + state.completed = data.get("completed", False) + state.reminder_count = data.get("reminder_count", 0) + return state diff --git a/src/models/registration.py b/src/models/registration.py new file mode 100644 index 0000000..b64784f --- /dev/null +++ b/src/models/registration.py @@ -0,0 +1,126 @@ +"""Registration data models matching the JSON schema in registration-schema.json.""" + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class BookingDay: + day: str # "monday", "wednesday", "thursday" + type: str # "indoor", "outdoor" + + +@dataclass +class Booking: + playgroup_types: list = field(default_factory=list) # ["indoor", "outdoor"] + selected_days: list = field(default_factory=list) # list[BookingDay] + + +@dataclass +class ChildInfo: + full_name: Optional[str] = None + date_of_birth: Optional[str] = None # YYYY-MM-DD + special_needs: Optional[str] = None # text or "None" + + +@dataclass +class ParentGuardian: + full_name: Optional[str] = None + street_address: Optional[str] = None + postal_code: Optional[str] = None # 4-digit Swiss code + city: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + + +@dataclass +class EmergencyContact: + full_name: Optional[str] = None + phone: Optional[str] = None + + +@dataclass +class RegistrationData: + child: ChildInfo = field(default_factory=ChildInfo) + parent_guardian: ParentGuardian = field(default_factory=ParentGuardian) + emergency_contact: EmergencyContact = field(default_factory=EmergencyContact) + booking: Booking = field(default_factory=Booking) + + def is_complete(self) -> bool: + """Return True when all required schema fields are present.""" + return ( + bool(self.child.full_name) + and bool(self.child.date_of_birth) + and self.child.special_needs is not None + and bool(self.parent_guardian.full_name) + and bool(self.parent_guardian.street_address) + and bool(self.parent_guardian.postal_code) + and bool(self.parent_guardian.city) + and bool(self.parent_guardian.phone) + and bool(self.parent_guardian.email) + and bool(self.emergency_contact.full_name) + and bool(self.emergency_contact.phone) + and len(self.booking.playgroup_types) > 0 + and len(self.booking.selected_days) > 0 + ) + + def to_dict(self) -> dict: + return { + "child": { + "fullName": self.child.full_name, + "dateOfBirth": self.child.date_of_birth, + "specialNeeds": self.child.special_needs, + }, + "parentGuardian": { + "fullName": self.parent_guardian.full_name, + "streetAddress": self.parent_guardian.street_address, + "postalCode": self.parent_guardian.postal_code, + "city": self.parent_guardian.city, + "phone": self.parent_guardian.phone, + "email": self.parent_guardian.email, + }, + "emergencyContact": { + "fullName": self.emergency_contact.full_name, + "phone": self.emergency_contact.phone, + }, + "booking": { + "playgroupTypes": self.booking.playgroup_types, + "selectedDays": [ + {"day": d.day, "type": d.type} + for d in self.booking.selected_days + ], + }, + } + + @classmethod + def from_dict(cls, data: dict) -> "RegistrationData": + reg = cls() + if child := data.get("child", {}): + reg.child = ChildInfo( + full_name=child.get("fullName"), + date_of_birth=child.get("dateOfBirth"), + special_needs=child.get("specialNeeds"), + ) + if parent := data.get("parentGuardian", {}): + reg.parent_guardian = ParentGuardian( + full_name=parent.get("fullName"), + street_address=parent.get("streetAddress"), + postal_code=parent.get("postalCode"), + city=parent.get("city"), + phone=parent.get("phone"), + email=parent.get("email"), + ) + if emergency := data.get("emergencyContact", {}): + reg.emergency_contact = EmergencyContact( + full_name=emergency.get("fullName"), + phone=emergency.get("phone"), + ) + if booking := data.get("booking", {}): + reg.booking = Booking( + playgroup_types=booking.get("playgroupTypes", []), + selected_days=[ + BookingDay(day=d["day"], type=d["type"]) + for d in booking.get("selectedDays", []) + ], + ) + return reg diff --git a/src/notifications/__init__.py b/src/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/notifications/notifier.py b/src/notifications/notifier.py new file mode 100644 index 0000000..1bf9c78 --- /dev/null +++ b/src/notifications/notifier.py @@ -0,0 +1,241 @@ +"""Admin email notifications sent when a registration is completed.""" + +import logging +import smtplib +from datetime import date, datetime +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from ..models.registration import RegistrationData + +logger = logging.getLogger(__name__) + +# Notification routing per spec +_INDOOR_EMAIL = "andrea.sigrist@gmx.net" +_OUTDOOR_EMAIL = "baba.laeubli@gmail.com" +_ADMIN_CC_EMAIL = "spielgruppen@familien-verein.ch" + + +class AdminNotifier: + """Sends formatted admin notification emails upon registration completion. + + The SMTP credentials are re-used from the agent's outbound email config. + When *smtp_host* is empty the notifier logs the notification and skips + sending (useful for local development / testing). + """ + + def __init__( + self, + smtp_host: str, + smtp_port: int, + username: str, + password: str, + use_tls: bool = True, + from_email: str = "", + ) -> None: + self._smtp_host = smtp_host + self._smtp_port = smtp_port + self._username = username + self._password = password + self._use_tls = use_tls + self._from_email = from_email or username + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def notify_admin( + self, + registration: RegistrationData, + registration_id: str, + conversation_id: str, + channel: str, + ) -> None: + """Send notification email(s) for a completed registration.""" + types = registration.booking.playgroup_types + + to_addresses: list[str] = [] + if "indoor" in types: + to_addresses.append(_INDOOR_EMAIL) + if "outdoor" in types: + to_addresses.append(_OUTDOOR_EMAIL) + + subject = ( + f"New Registration: {registration.child.full_name} " + f"for {self._format_types(types)}" + ) + body = self._build_body(registration, registration_id, channel) + + self._send( + to=to_addresses, + cc=[_ADMIN_CC_EMAIL], + subject=subject, + body=body, + reply_to=registration.parent_guardian.email or "", + ) + + # ------------------------------------------------------------------ + # Formatting helpers + # ------------------------------------------------------------------ + + @staticmethod + def _format_types(types: list[str]) -> str: + has_indoor = "indoor" in types + has_outdoor = "outdoor" in types + if has_indoor and has_outdoor: + return "Indoor + Outdoor Playgroup" + if has_indoor: + return "Indoor Playgroup" + if has_outdoor: + return "Outdoor Playgroup" + return "Playgroup" + + @staticmethod + def _calculate_age(dob_str: str) -> str: + try: + dob = datetime.strptime(dob_str, "%Y-%m-%d").date() + today = date.today() + years = today.year - dob.year - ( + (today.month, today.day) < (dob.month, dob.day) + ) + months = (today.month - dob.month) % 12 + return f"{years} years, {months} months" + except Exception: + return dob_str + + @staticmethod + def _format_dob(dob_str: str) -> str: + try: + return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y") + except Exception: + return dob_str or "" + + @staticmethod + def _calculate_monthly_fee(registration: RegistrationData) -> str: + indoor_days = sum(1 for d in registration.booking.selected_days if d.type == "indoor") + outdoor_days = sum(1 for d in registration.booking.selected_days if d.type == "outdoor") + fee = 0 + if indoor_days == 1: + fee += 130 + elif indoor_days == 2: + fee += 260 + elif indoor_days >= 3: + fee += 390 + if outdoor_days >= 1: + fee += 250 + return f"CHF {fee}.-" + + @staticmethod + def _format_days(registration: RegistrationData) -> str: + day_map = { + "monday": "Monday", + "wednesday": "Wednesday", + "thursday": "Thursday", + } + return ", ".join( + f"{day_map.get(d.day, d.day.capitalize())} ({d.type})" + for d in registration.booking.selected_days + ) + + def _build_body( + self, + registration: RegistrationData, + registration_id: str, + channel: str, + ) -> str: + now = datetime.utcnow() + pg = registration.parent_guardian + ec = registration.emergency_contact + + return ( + "===============================================\n" + "NEW PLAYGROUP REGISTRATION\n" + "===============================================\n" + "\n" + f"Submitted: {now.strftime('%d.%m.%Y')} at {now.strftime('%H:%M')} UTC\n" + f"Channel: {channel.title()}\n" + f"Registration ID: {registration_id}\n" + "\n" + "-----------------------------------------------\n" + "CHILD INFORMATION\n" + "-----------------------------------------------\n" + f"Name: {registration.child.full_name}\n" + f"Date of Birth: {self._format_dob(registration.child.date_of_birth or '')} " + f"(Age: {self._calculate_age(registration.child.date_of_birth or '')})\n" + f"Special Needs: {registration.child.special_needs or 'None'}\n" + "\n" + "-----------------------------------------------\n" + "PLAYGROUP SELECTION\n" + "-----------------------------------------------\n" + f"Type: {self._format_types(registration.booking.playgroup_types)}\n" + f"Days: {self._format_days(registration)}\n" + "\n" + f"Monthly Fee: {self._calculate_monthly_fee(registration)}\n" + "(Plus CHF 80 registration fee if first enrolment)\n" + "\n" + "-----------------------------------------------\n" + "PARENT / GUARDIAN\n" + "-----------------------------------------------\n" + f"Name: {pg.full_name}\n" + f"Address: {pg.street_address}\n" + f" {pg.postal_code} {pg.city}\n" + f"Phone: {pg.phone}\n" + f"Email: {pg.email}\n" + "\n" + "-----------------------------------------------\n" + "EMERGENCY CONTACT\n" + "-----------------------------------------------\n" + f"Name: {ec.full_name}\n" + f"Phone: {ec.phone}\n" + "\n" + "===============================================\n" + "\n" + "This registration was submitted via the automated registration assistant.\n" + ) + + # ------------------------------------------------------------------ + # SMTP dispatch + # ------------------------------------------------------------------ + + def _send( + self, + to: list[str], + cc: list[str], + subject: str, + body: str, + reply_to: str = "", + ) -> None: + if not self._smtp_host: + logger.warning( + "SMTP not configured — notification NOT sent. Would have emailed %s (CC: %s): %s", + to, + cc, + subject, + ) + logger.debug("Notification body:\n%s", body) + return + + msg = MIMEMultipart("alternative") + msg["From"] = self._from_email + msg["To"] = ", ".join(to) + msg["CC"] = ", ".join(cc) + msg["Subject"] = subject + if reply_to: + msg["Reply-To"] = reply_to + + msg.attach(MIMEText(body, "plain", "utf-8")) + all_recipients = to + cc + + try: + if self._use_tls: + server = smtplib.SMTP(self._smtp_host, self._smtp_port) + server.starttls() + else: + server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port) + + server.login(self._username, self._password) + server.sendmail(self._from_email, all_recipients, msg.as_string()) + server.quit() + logger.info("Admin notification sent to %s", all_recipients) + except Exception: + logger.exception("Failed to send admin notification to %s", all_recipients) diff --git a/src/providers/__init__.py b/src/providers/__init__.py new file mode 100644 index 0000000..a7a50b0 --- /dev/null +++ b/src/providers/__init__.py @@ -0,0 +1,34 @@ +"""LLM provider registry.""" + +from .base import LLMProvider, LLMMessage, LLMResponse +from .anthropic_provider import AnthropicProvider +from .openai_provider import OpenAIProvider + +__all__ = [ + "LLMProvider", + "LLMMessage", + "LLMResponse", + "AnthropicProvider", + "OpenAIProvider", + "create_provider", +] + + +def create_provider(provider: str, api_key: str, model: str = "") -> LLMProvider: + """Instantiate the correct LLMProvider by name. + + Args: + provider: "anthropic" or "openai" + api_key: API key for the chosen provider. + model: Optional model name override. + + Returns: + Configured LLMProvider instance. + """ + if provider == "anthropic": + return AnthropicProvider(api_key=api_key, model=model) + if provider == "openai": + return OpenAIProvider(api_key=api_key, model=model) + raise ValueError( + f"Unknown AI provider: '{provider}'. Supported values: 'anthropic', 'openai'." + ) diff --git a/src/providers/anthropic_provider.py b/src/providers/anthropic_provider.py new file mode 100644 index 0000000..6fa21c0 --- /dev/null +++ b/src/providers/anthropic_provider.py @@ -0,0 +1,37 @@ +"""Anthropic (Claude) LLM provider.""" + +from .base import LLMProvider, LLMMessage, LLMResponse + + +class AnthropicProvider(LLMProvider): + DEFAULT_MODEL = "claude-opus-4-6" + + def __init__(self, api_key: str, model: str = "") -> None: + try: + import anthropic + except ImportError as exc: + raise ImportError( + "Install the 'anthropic' package to use the Anthropic provider: " + "pip install anthropic" + ) from exc + + self._client = anthropic.Anthropic(api_key=api_key) + self._model = model or self.DEFAULT_MODEL + + def complete(self, system: str, messages: list) -> LLMResponse: + api_messages = [ + {"role": m.role, "content": m.content} + for m in messages + if m.role in ("user", "assistant") + ] + response = self._client.messages.create( + model=self._model, + max_tokens=2048, + system=system, + messages=api_messages, + ) + return LLMResponse(content=response.content[0].text) + + @property + def model_name(self) -> str: + return self._model diff --git a/src/providers/base.py b/src/providers/base.py new file mode 100644 index 0000000..eee417d --- /dev/null +++ b/src/providers/base.py @@ -0,0 +1,36 @@ +"""Abstract base class for LLM providers.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass +class LLMMessage: + role: str # "user" or "assistant" + content: str + + +@dataclass +class LLMResponse: + content: str + + +class LLMProvider(ABC): + """Uniform interface for any LLM backend.""" + + @abstractmethod + def complete(self, system: str, messages: list) -> LLMResponse: + """Generate a completion. + + Args: + system: System prompt text. + messages: List of LLMMessage objects (user/assistant turns). + + Returns: + LLMResponse with the model's text output. + """ + + @property + @abstractmethod + def model_name(self) -> str: + """Human-readable model identifier.""" diff --git a/src/providers/openai_provider.py b/src/providers/openai_provider.py new file mode 100644 index 0000000..36b80f2 --- /dev/null +++ b/src/providers/openai_provider.py @@ -0,0 +1,37 @@ +"""OpenAI (GPT) LLM provider.""" + +from .base import LLMProvider, LLMMessage, LLMResponse + + +class OpenAIProvider(LLMProvider): + DEFAULT_MODEL = "gpt-4o" + + def __init__(self, api_key: str, model: str = "") -> None: + try: + from openai import OpenAI + except ImportError as exc: + raise ImportError( + "Install the 'openai' package to use the OpenAI provider: " + "pip install openai" + ) from exc + + self._client = OpenAI(api_key=api_key) + self._model = model or self.DEFAULT_MODEL + + def complete(self, system: str, messages: list) -> LLMResponse: + api_messages = [{"role": "system", "content": system}] + api_messages.extend( + {"role": m.role, "content": m.content} + for m in messages + if m.role in ("user", "assistant") + ) + response = self._client.chat.completions.create( + model=self._model, + messages=api_messages, + max_tokens=2048, + ) + return LLMResponse(content=response.choices[0].message.content) + + @property + def model_name(self) -> str: + return self._model diff --git a/src/storage/__init__.py b/src/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/storage/json_store.py b/src/storage/json_store.py new file mode 100644 index 0000000..7917f8d --- /dev/null +++ b/src/storage/json_store.py @@ -0,0 +1,143 @@ +"""File-based JSON storage for conversations and completed registrations.""" + +import json +import logging +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from ..models.conversation import ConversationState +from ..models.registration import RegistrationData + +logger = logging.getLogger(__name__) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +class ConversationStore: + """Persists ConversationState objects as JSON files on disk. + + Directory layout:: + + data/ + conversations/ # one file per email thread + registrations/ # one file per completed registration + """ + + def __init__(self, data_dir: Path) -> None: + self._conversations_dir = data_dir / "conversations" + self._registrations_dir = data_dir / "registrations" + self._conversations_dir.mkdir(parents=True, exist_ok=True) + self._registrations_dir.mkdir(parents=True, exist_ok=True) + + # ------------------------------------------------------------------ + # Conversation CRUD + # ------------------------------------------------------------------ + + def load(self, conversation_id: str) -> ConversationState | None: + """Load a conversation by ID. Returns None if not found.""" + path = self._conversation_path(conversation_id) + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + return ConversationState.from_dict(data) + except Exception: + logger.exception("Failed to load conversation %s", conversation_id) + return None + + def save(self, state: ConversationState) -> None: + """Persist a conversation state to disk.""" + path = self._conversation_path(state.conversation_id) + try: + path.write_text( + json.dumps(state.to_dict(), ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except Exception: + logger.exception("Failed to save conversation %s", state.conversation_id) + + def delete(self, conversation_id: str) -> None: + """Remove a conversation file (e.g., after retention period expires).""" + path = self._conversation_path(conversation_id) + if path.exists(): + path.unlink() + + def list_incomplete(self) -> list[ConversationState]: + """Return all conversations that are not yet completed.""" + states: list[ConversationState] = [] + for path in self._conversations_dir.glob("*.json"): + try: + data = json.loads(path.read_text(encoding="utf-8")) + state = ConversationState.from_dict(data) + if not state.completed: + states.append(state) + except Exception: + logger.warning("Could not read conversation file %s", path) + return states + + # ------------------------------------------------------------------ + # Registration storage + # ------------------------------------------------------------------ + + def save_registration(self, state: ConversationState) -> str: + """Write the completed registration to disk and return its ID.""" + registration_id = str(uuid.uuid4()) + record = state.registration.to_dict() + record["metadata"] = { + "registrationId": registration_id, + "submittedAt": _now(), + "channel": "email", + "conversationId": state.conversation_id, + } + + path = self._registrations_dir / f"{registration_id}.json" + try: + path.write_text( + json.dumps(record, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + logger.info("Saved registration %s", registration_id) + except Exception: + logger.exception("Failed to save registration %s", registration_id) + + return registration_id + + def load_registration(self, registration_id: str) -> dict | None: + """Load a completed registration record by ID.""" + path = self._registrations_dir / f"{registration_id}.json" + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + logger.exception("Failed to load registration %s", registration_id) + return None + + def list_registrations(self) -> list[dict]: + """Return all completed registration records.""" + records: list[dict] = [] + for path in sorted(self._registrations_dir.glob("*.json")): + try: + records.append(json.loads(path.read_text(encoding="utf-8"))) + except Exception: + logger.warning("Could not read registration file %s", path) + return records + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _conversation_path(self, conversation_id: str) -> Path: + """Sanitise the conversation ID to produce a safe filename.""" + safe = ( + conversation_id + .replace("/", "_") + .replace("\\", "_") + .replace("<", "") + .replace(">", "") + .replace("@", "_at_") + )[:200] + return self._conversations_dir / f"{safe}.json"