Implement email-address-based conversation matching

Closes the gap where parents sending a new email (instead of replying)
would lose their registration progress. All changes follow the
email-based-conversation-matching OpenSpec change.

Key changes
-----------
storage/json_store.py
  - normalize_email() helper (lowercase + trim)
  - Conversations now keyed by sender email address, not thread ID
  - Versioned registration storage: data/registrations/<email>/v<N>_<ts>.json
  - current.json always reflects the latest version
  - save_registration() returns (email_key, version) tuple
  - save_registration_version() for updates with change_summary
  - get_registration_history() returns all versions in order

models/conversation.py
  - Added last_inbound_message_id field for reply threading (not matching)

channels/email_channel.py
  - fetch_unread_messages() no longer exposes thread_id
  - Conversation matching removed from channel layer (now in agent)
  - Removed _resolve_thread_id() — threading headers kept for SMTP only

agent/core.py
  - process_message() takes parent_email + inbound_message_id (no thread ID)
  - Looks up conversation by normalized email address
  - Post-completion handler: detects intent (question / update / new_child)
  - Registration updates: diffs old vs new, versions storage, notifies admin

agent/prompts.py
  - build_system_prompt() dispatches to registration or post-completion prompt
  - Post-completion prompt guides LLM to return intent field
  - Reminder language updated: no expiration threats

notifications/notifier.py
  - notify_admin() accepts version parameter
  - notify_registration_update() sends "Registration Updated" emails with diff
  - _build_update_body() includes field-level old→new change summary

main.py
  - Poll loop passes parent_email + inbound_message_id to agent (no thread_id)

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
This commit is contained in:
Claude
2026-02-20 22:15:49 +00:00
parent c37862b75e
commit 0c3b5a9033
7 changed files with 594 additions and 241 deletions
+128 -72
View File
@@ -6,10 +6,10 @@ import re
from datetime import datetime, timezone
from ..models.conversation import ConversationState, ChatMessage
from ..models.registration import BookingDay
from ..models.registration import BookingDay, RegistrationData
from ..providers.base import LLMProvider, LLMMessage
from ..knowledge_base.loader import KnowledgeBase
from ..storage.json_store import ConversationStore
from ..storage.json_store import ConversationStore, normalize_email, _diff_registrations
from ..notifications.notifier import AdminNotifier
from .prompts import build_system_prompt
@@ -17,11 +17,13 @@ logger = logging.getLogger(__name__)
class EmailAgent:
"""Processes one inbound message and returns the agent's reply text.
"""Processes one inbound email 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.
Conversations are identified by the sender's normalized email address, so
a parent who composes a fresh email (instead of replying) continues their
existing conversation seamlessly.
All business logic lives here; channel I/O is handled by the caller.
"""
def __init__(
@@ -42,47 +44,67 @@ class EmailAgent:
def process_message(
self,
conversation_id: str,
parent_email: str,
message_text: str,
inbound_message_id: str = "",
) -> str:
"""Process an inbound message and return the reply to send back.
"""Process one inbound message and return the reply text.
Args:
conversation_id: Unique thread identifier (from email headers).
parent_email: Sender's email address.
parent_email: Sender email address — used as conversation key.
message_text: Stripped plain-text body of the inbound email.
inbound_message_id: Message-ID of the inbound email (stored for
reply threading headers; not used for conversation matching).
Returns:
Reply text to send to the parent.
Reply text to send back to the parent.
"""
# Load or create conversation state
state = self._store.load(conversation_id)
email_key = normalize_email(parent_email)
# Load or create conversation state — keyed by email address
state = self._store.load(email_key)
if state is None:
state = ConversationState(
conversation_id=conversation_id,
parent_email=parent_email,
conversation_id=email_key,
parent_email=email_key,
)
now = datetime.now(timezone.utc).isoformat()
state.last_activity = now
if inbound_message_id:
state.last_inbound_message_id = inbound_message_id
# 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
# Route to the appropriate handler
if state.completed:
reply_text = self._handle_post_completion(state)
else:
reply_text = self._handle_registration(state)
# Record the assistant reply and persist
state.messages.append(ChatMessage(role="assistant", content=reply_text))
state.updated_at = now
self._store.save(state)
return reply_text
# ------------------------------------------------------------------
# Registration flow
# ------------------------------------------------------------------
def _handle_registration(self, state: ConversationState) -> str:
"""Drive the in-progress registration conversation."""
system = build_system_prompt(self._kb, state)
llm_messages = [
LLMMessage(role=m.role, content=m.content) for m in state.messages
]
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)
logger.exception("LLM call failed for %s", state.conversation_id)
return self._fallback_message(state)
reply_text: str = parsed.get("reply", "")
updates: dict = parsed.get("updates", {}) or {}
@@ -90,57 +112,100 @@ class EmailAgent:
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)
email_key, version = self._store.save_registration(state)
try:
self._notifier.notify_admin(
registration=state.registration,
registration_id=registration_id,
registration_id=email_key,
version=version,
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)
logger.exception("Failed to send admin notification for %s", email_key)
logger.info("Registration complete for %s", state.conversation_id)
return reply_text
# ------------------------------------------------------------------
# Internal helpers
# Post-completion flow
# ------------------------------------------------------------------
def _handle_post_completion(self, state: ConversationState) -> str:
"""Handle messages received after a registration is already complete."""
system = build_system_prompt(self._kb, state)
llm_messages = [LLMMessage(role=m.role, content=m.content) for m in state.messages]
try:
response = self._provider.complete(system=system, messages=llm_messages)
parsed = self._parse_llm_response(response.content)
except Exception:
logger.exception("LLM call failed (post-completion) for %s", state.conversation_id)
return self._fallback_message(state)
reply_text: str = parsed.get("reply", "")
intent: str = parsed.get("intent", "question")
updates: dict = parsed.get("updates", {}) or {}
language: str = parsed.get("language", state.language)
state.language = language
if intent == "update" and any(v is not None for v in updates.values()):
self._handle_registration_update(state, updates)
elif intent == "new_child":
# Reset registration so a fresh flow begins in the next message
state.registration = RegistrationData()
state.completed = False
state.flow_step = "child_name"
logger.info("Starting new child registration for %s", state.conversation_id)
return reply_text
def _handle_registration_update(self, state: ConversationState, updates: dict) -> None:
"""Apply field updates, version the record, and notify the admin."""
old_data = state.registration.to_dict()
self._apply_updates(state, updates)
new_data = state.registration.to_dict()
change_summary = _diff_registrations(old_data, new_data)
if not change_summary:
return # Nothing actually changed
email_key, version = self._store.save_registration_version(state, change_summary)
try:
self._notifier.notify_registration_update(
registration=state.registration,
registration_id=email_key,
version=version,
change_summary=change_summary,
conversation_id=state.conversation_id,
)
except Exception:
logger.exception("Failed to send update notification for %s", email_key)
logger.info("Registration updated to v%d for %s", version, state.conversation_id)
# ------------------------------------------------------------------
# Shared helpers
# ------------------------------------------------------------------
def _parse_llm_response(self, content: str) -> dict:
"""Extract the JSON payload from the LLM's output."""
"""Extract the JSON payload from the LLM's raw 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()
fence_match = re.match(r"^```(?:json)?\s*\n(.*?)\n```\s*$", text, re.DOTALL)
if fence_match:
text = fence_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:
@@ -148,60 +213,51 @@ class EmailAgent:
except json.JSONDecodeError:
pass
logger.warning("Could not parse LLM response as JSON; using raw text as reply.")
logger.warning("Could not parse LLM response as JSON using raw text as reply.")
return {
"reply": content,
"intent": "question",
"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."""
def _fallback_message(self, state: ConversationState) -> str:
if state.language == "en":
msg = (
return (
"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,
}
return (
"Entschuldigung, ich habe gerade ein technisches Problem. "
"Bitte versuche es gleich nochmal oder kontaktiere uns direkt."
)
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: (
"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),
"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):