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
+195 -68
View File
@@ -1,30 +1,95 @@
"""File-based JSON storage for conversations and completed registrations."""
"""File-based JSON storage for conversations and completed registrations.
Conversations are keyed by the sender's normalized email address so that a
parent who sends a new email (instead of replying) continues the same
conversation. Completed registrations are stored with versioning so every
update produces a new numbered version rather than overwriting the original.
Directory layout::
data/
conversations/
parent_at_example.com.json # one file per unique sender address
registrations/
parent_at_example.com/
v1_2024-09-15T10-30-00Z.json # initial registration
v2_2024-10-03T14-22-10Z.json # updated registration
current.json # copy of the latest version
"""
import json
import logging
import uuid
import re
from datetime import datetime, timezone
from pathlib import Path
from ..models.conversation import ConversationState
from ..models.registration import RegistrationData
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def normalize_email(email: str) -> str:
"""Return a canonical email address for matching and storage.
Lowercases and strips whitespace. ``Maria@Example.com`` → ``maria@example.com``.
"""
return email.strip().lower()
def _email_to_filename(email: str) -> str:
"""Convert a normalized email address to a safe filename stem.
``parent@example.com`` → ``parent_at_example.com``
"""
return normalize_email(email).replace("@", "_at_")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _timestamp_for_filename() -> str:
"""Return a filesystem-safe ISO-8601-ish timestamp (no colons)."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
def _diff_registrations(old: dict, new: dict) -> dict[str, tuple]:
"""Return a mapping of field_path → (old_value, new_value) for changed fields."""
changes: dict[str, tuple] = {}
def _flatten(d: dict, prefix: str = "") -> dict:
out: dict = {}
for k, v in d.items():
key = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
out.update(_flatten(v, key))
else:
out[key] = v
return out
old_flat = _flatten(old)
new_flat = _flatten(new)
all_keys = set(old_flat) | set(new_flat)
for key in sorted(all_keys):
o = old_flat.get(key)
n = new_flat.get(key)
if o != n:
changes[key] = (o, n)
return changes
# ---------------------------------------------------------------------------
# ConversationStore
# ---------------------------------------------------------------------------
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
"""
"""Persists ConversationState and registration versions on disk."""
def __init__(self, data_dir: Path) -> None:
self._conversations_dir = data_dir / "conversations"
@@ -33,40 +98,43 @@ class ConversationStore:
self._registrations_dir.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------------
# Conversation CRUD
# Conversation CRUD — keyed by normalized email address
# ------------------------------------------------------------------
def load(self, conversation_id: str) -> ConversationState | None:
"""Load a conversation by ID. Returns None if not found."""
path = self._conversation_path(conversation_id)
def load(self, email_address: str) -> ConversationState | None:
"""Load a conversation by sender email address. Returns None if not found."""
path = self._conversation_path(email_address)
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)
logger.exception("Failed to load conversation for %s", email_address)
return None
# Alias for clarity in call sites that emphasise the email-lookup semantic
find_by_email = load
def save(self, state: ConversationState) -> None:
"""Persist a conversation state to disk."""
path = self._conversation_path(state.conversation_id)
path = self._conversation_path(state.parent_email or 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)
logger.exception("Failed to save conversation for %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)
def delete(self, email_address: str) -> None:
"""Remove a conversation file."""
path = self._conversation_path(email_address)
if path.exists():
path.unlink()
def list_incomplete(self) -> list[ConversationState]:
"""Return all conversations that are not yet completed."""
"""Return all conversations that have not yet been completed."""
states: list[ConversationState] = []
for path in self._conversations_dir.glob("*.json"):
try:
@@ -79,65 +147,124 @@ class ConversationStore:
return states
# ------------------------------------------------------------------
# Registration storage
# Versioned 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,
def save_registration(self, state: ConversationState) -> tuple[str, int]:
"""Store the first version of a completed registration.
Returns:
Tuple of (registration_dir_key, version_number).
"""
email_key = _email_to_filename(state.parent_email or state.conversation_id)
reg_dir = self._registrations_dir / email_key
reg_dir.mkdir(parents=True, exist_ok=True)
version = 1
record = self._build_record(state.registration.to_dict(), version, state)
self._write_version(reg_dir, version, record)
logger.info("Saved initial registration v%d for %s", version, email_key)
return email_key, version
def save_registration_version(
self,
state: ConversationState,
change_summary: dict[str, tuple],
) -> tuple[str, int]:
"""Store an updated registration as a new version.
Args:
state: Current conversation state with updated registration data.
change_summary: Dict of field_path → (old_value, new_value).
Returns:
Tuple of (registration_dir_key, new_version_number).
"""
email_key = _email_to_filename(state.parent_email or state.conversation_id)
reg_dir = self._registrations_dir / email_key
reg_dir.mkdir(parents=True, exist_ok=True)
history = self.get_registration_history(state.parent_email or state.conversation_id)
version = len(history) + 1
record = self._build_record(state.registration.to_dict(), version, state)
record["metadata"]["changeSummary"] = {
k: {"old": v[0], "new": v[1]} for k, v in change_summary.items()
}
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)
self._write_version(reg_dir, version, record)
logger.info("Saved registration v%d for %s", version, email_key)
return email_key, version
return registration_id
def get_registration_history(self, email_address: str) -> list[dict]:
"""Return all registration versions for an email address, oldest first."""
email_key = _email_to_filename(email_address)
reg_dir = self._registrations_dir / email_key
if not reg_dir.exists():
return []
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")):
for path in sorted(reg_dir.glob("v*.json")):
try:
records.append(json.loads(path.read_text(encoding="utf-8")))
except Exception:
logger.warning("Could not read registration file %s", path)
logger.warning("Could not read registration version %s", path)
return records
def get_current_registration(self, email_address: str) -> dict | None:
"""Return the latest registration version for an email address."""
email_key = _email_to_filename(email_address)
current_path = self._registrations_dir / email_key / "current.json"
if not current_path.exists():
return None
try:
return json.loads(current_path.read_text(encoding="utf-8"))
except Exception:
logger.exception("Failed to load current registration for %s", email_address)
return None
def list_registrations(self) -> list[dict]:
"""Return the current (latest) registration for every known email address."""
records: list[dict] = []
for email_dir in sorted(self._registrations_dir.iterdir()):
if not email_dir.is_dir():
continue
current = email_dir / "current.json"
if current.exists():
try:
records.append(json.loads(current.read_text(encoding="utf-8")))
except Exception:
logger.warning("Could not read %s", current)
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"
def _conversation_path(self, email_address: str) -> Path:
return self._conversations_dir / f"{_email_to_filename(email_address)}.json"
@staticmethod
def _build_record(reg_data: dict, version: int, state: ConversationState) -> dict:
record = dict(reg_data)
record["metadata"] = {
"version": version,
"submittedAt": _now(),
"channel": "email",
"parentEmail": state.parent_email,
"conversationId": state.conversation_id,
}
return record
@staticmethod
def _write_version(reg_dir: Path, version: int, record: dict) -> None:
ts = _timestamp_for_filename()
version_path = reg_dir / f"v{version}_{ts}.json"
version_path.write_text(
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
)
# Keep current.json as a plain copy of the latest version
(reg_dir / "current.json").write_text(
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
)