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:
+195
-68
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user