- email_channel.py: add detect_automated_message() that inspects RFC 3834 Auto-Submitted, mailer-daemon/postmaster sender patterns, X-Loop, multipart/report Content-Type, Precedence, and subject heuristics. fetch_unread_messages() now includes is_automated / automated_reason in every message dict. - main.py: if is_automated is set, call agent.handle_automated_message() instead of process_message() — no reply is ever sent to a bounce source. - agent/core.py: add MAX_USER_MESSAGES = 20 cap; process_message() returns "" without replying once a conversation exceeds the limit and calls notifier.notify_loop_escalation() on first breach. New public method handle_automated_message() records the event and triggers the same one-shot admin alert. - models/conversation.py: add loop_escalated: bool field (persisted) so the admin alert fires at most once per conversation. - notifications/notifier.py: add notify_loop_escalation() which sends a plain-text warning to the admin CC list (Markus Graf / spielgruppen@). https://claude.ai/code/session_01KwvR5hDPjSuJg4kvw5b5e5
87 lines
3.4 KiB
Python
87 lines
3.4 KiB
Python
"""Conversation state model — persisted per sender email address."""
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from .registration import RegistrationData
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
@dataclass
|
|
class ChatMessage:
|
|
role: str # "user" or "assistant"
|
|
content: str
|
|
timestamp: str = field(default_factory=_now)
|
|
|
|
|
|
@dataclass
|
|
class ConversationState:
|
|
conversation_id: str # normalized sender email address
|
|
language: str = "de" # "de" or "en"
|
|
flow_step: str = "greeting" # current step in registration flow
|
|
registration: RegistrationData = field(default_factory=RegistrationData)
|
|
messages: list = field(default_factory=list) # list[ChatMessage]
|
|
parent_email: str = ""
|
|
parent_name: Optional[str] = None
|
|
created_at: str = field(default_factory=_now)
|
|
updated_at: str = field(default_factory=_now)
|
|
last_activity: str = field(default_factory=_now)
|
|
completed: bool = False
|
|
reminder_count: int = 0
|
|
# Most recent inbound Message-ID — used for reply threading headers only,
|
|
# NOT for conversation matching (which is always by email address).
|
|
last_inbound_message_id: str = ""
|
|
# Loop / automated-sender prevention.
|
|
# Set to True once the admin has been notified; prevents repeated alerts.
|
|
loop_escalated: bool = False
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"conversation_id": self.conversation_id,
|
|
"language": self.language,
|
|
"flow_step": self.flow_step,
|
|
"registration": self.registration.to_dict(),
|
|
"messages": [
|
|
{"role": m.role, "content": m.content, "timestamp": m.timestamp}
|
|
for m in self.messages
|
|
],
|
|
"parent_email": self.parent_email,
|
|
"parent_name": self.parent_name,
|
|
"created_at": self.created_at,
|
|
"updated_at": self.updated_at,
|
|
"last_activity": self.last_activity,
|
|
"completed": self.completed,
|
|
"reminder_count": self.reminder_count,
|
|
"last_inbound_message_id": self.last_inbound_message_id,
|
|
"loop_escalated": self.loop_escalated,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> "ConversationState":
|
|
state = cls(conversation_id=data["conversation_id"])
|
|
state.language = data.get("language", "de")
|
|
state.flow_step = data.get("flow_step", "greeting")
|
|
state.registration = RegistrationData.from_dict(data.get("registration", {}))
|
|
state.messages = [
|
|
ChatMessage(
|
|
role=m["role"],
|
|
content=m["content"],
|
|
timestamp=m.get("timestamp", ""),
|
|
)
|
|
for m in data.get("messages", [])
|
|
]
|
|
state.parent_email = data.get("parent_email", "")
|
|
state.parent_name = data.get("parent_name")
|
|
state.created_at = data.get("created_at", "")
|
|
state.updated_at = data.get("updated_at", "")
|
|
state.last_activity = data.get("last_activity", "")
|
|
state.completed = data.get("completed", False)
|
|
state.reminder_count = data.get("reminder_count", 0)
|
|
state.last_inbound_message_id = data.get("last_inbound_message_id", "")
|
|
state.loop_escalated = data.get("loop_escalated", False)
|
|
return state
|