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
+143 -48
View File
@@ -6,7 +6,7 @@ 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 help the model understand where it is in the registration flow.
# ---------------------------------------------------------------------------
STEP_DESCRIPTIONS = {
"greeting": "Greet the parent and detect their intent (registration vs. questions).",
@@ -35,66 +35,28 @@ STEP_DESCRIPTIONS = {
"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
_PERSONALITY = """## 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 12 questions at a time — never send an overwhelming form-like list
- Be patient and understanding; never make parents feel they made a mistake
- 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
_CONTACTS = """## 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"""
**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
_PLAYGROUP_DETAILS = """## Playgroup Details
- **Indoor (Innenspielgruppe)**: Mon / Wed / Thu, 09:0011:30 | CHF 130/260/390 per month (1/2/3×/week)
- **Outdoor Forest (Waldspielgruppe)**: Mon only, 09:0014: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
- **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
_REGISTRATION_RESPONSE_FORMAT = """## CRITICAL: Response Format
You MUST respond with **only** a valid JSON object — no markdown, no extra text outside the JSON.
@@ -128,5 +90,138 @@ Rules:
- 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."""
_POST_COMPLETION_RESPONSE_FORMAT = """## 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)",
"intent": "question",
"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
}},
"language": "de"
}}
```
`intent` values:
- `"question"` — parent is asking about fees, schedule, policies, etc. → answer from knowledge base; set `updates` to all nulls.
- `"update"` — parent explicitly wants to change their registration data → collect the new values in `updates`, confirm the change in `reply`.
- `"new_child"` — parent wants to register an additional child → treat as a new registration; begin from step child_name.
Rules:
- Only set fields in `updates` when intent is `"update"` AND the parent has provided the new value in this message.
- Use `null` for all `updates` fields when intent is `"question"` or `"new_child"`.
- `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.
- If you are unsure of the parent's intent, ask a clarifying question and set intent to `"question"`."""
def build_system_prompt(kb: KnowledgeBase, state: ConversationState) -> str:
"""Return the system prompt appropriate for the current conversation state."""
if state.completed:
return _build_post_completion_prompt(kb, state)
return _build_registration_prompt(kb, state)
def _build_registration_prompt(kb: KnowledgeBase, state: ConversationState) -> str:
"""System prompt for an in-progress registration conversation."""
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.
{_PERSONALITY}
## 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}
{_CONTACTS}
---
{_REGISTRATION_RESPONSE_FORMAT}
"""
def _build_post_completion_prompt(kb: KnowledgeBase, state: ConversationState) -> str:
"""System prompt for a conversation where registration is already complete."""
kb_content = kb.get_all()
reg_json = json.dumps(state.registration.to_dict(), ensure_ascii=False, indent=2)
child_name = state.registration.child.full_name or "their child"
return f"""You are the registration assistant for Spielgruppe Pumuckl, run by Familienverein Fällanden in Fällanden, Switzerland.
{_PERSONALITY}
## Context: Registration Already Complete
This parent has already completed registration for {child_name}. Their current registration data is:
```json
{reg_json}
```
The parent is contacting you again. Your job is to:
1. Detect their **intent**: are they asking a question, requesting a change to their registration, or registering another child?
2. Respond helpfully and warmly.
3. If they want to **update** their registration, confirm exactly what they want to change and include the new values in `updates`.
4. If they are asking a **question**, answer from the knowledge base.
5. If they want to register a **new child**, let them know you'll start a new registration and guide them from the beginning.
When handling update requests:
- Confirm the change explicitly before reporting it as done ("So you'd like to change X to Y — is that right?").
- Once confirmed, include the new value in `updates` so it can be saved.
- Let the parent know the playgroup team will be informed of the change.
## Knowledge Base
{kb_content}
{_PLAYGROUP_DETAILS}
{_CONTACTS}
---
{_POST_COMPLETION_RESPONSE_FORMAT}
"""