Files
Meister-Eder/src/storage/json_store.py
T
Claude 0c3b5a9033 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
2026-02-20 22:15:49 +00:00

271 lines
10 KiB
Python

"""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 re
from datetime import datetime, timezone
from pathlib import Path
from ..models.conversation import ConversationState
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 and registration versions on disk."""
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 — keyed by normalized email address
# ------------------------------------------------------------------
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 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.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 for %s", state.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 have not yet been 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
# ------------------------------------------------------------------
# Versioned registration storage
# ------------------------------------------------------------------
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()
}
self._write_version(reg_dir, version, record)
logger.info("Saved registration v%d for %s", version, email_key)
return email_key, version
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 []
records: list[dict] = []
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 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, 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"
)