main
py 112 lines 3.31 KB
Raw
1 """
2 Email dispatch logic — routes inbound emails to chats.
3
4 No agent deps in pure helpers.
5 """
6
7 import re
8 from dataclasses import dataclass
9 from typing import Literal, TypedDict
10
11 # Pattern for extracting chat thread ID from email subject
12 # Matches: [a0-xxxxxxxx] at end of subject
13 _THREAD_ID_RE = re.compile(r"\[a0-([a-zA-Z0-9]+)\]")
14
15 # Context data keys (no underscore prefix — must persist across restarts)
16 CTX_EMAIL_HANDLER = "email_handler"
17 CTX_EMAIL_SENDER = "email_sender"
18 CTX_EMAIL_THREAD_ID = "email_thread_id"
19 CTX_EMAIL_SUBJECT = "email_subject"
20 CTX_EMAIL_MESSAGE_ID = "email_message_id"
21 CTX_EMAIL_REFERENCES = "email_references"
22 CTX_EMAIL_LAST_BODY = "email_last_body"
23 # Transient — consumed per-reply, not persisted
24 CTX_EMAIL_ATTACHMENTS = "_email_response_attachments"
25
26 BODY_PREVIEW_MAX_CHARS: int = 2000
27
28 DispatchAction = Literal["new_chat", "continue_chat"]
29
30
31 @dataclass
32 class DispatchDecision:
33 action: DispatchAction
34 context_id: str = ""
35 reason: str = ""
36
37
38 def extract_thread_id(subject: str) -> str:
39 match = _THREAD_ID_RE.search(subject)
40 return match.group(1) if match else ""
41
42
43 def build_reply_subject(original_subject: str, thread_id: str) -> str:
44 clean = _THREAD_ID_RE.sub("", original_subject).strip()
45 if not clean.lower().startswith("re:"):
46 clean = f"Re: {clean}"
47 return f"{clean} [a0-{thread_id}]"
48
49
50 class ChatSummary(TypedDict):
51 context_id: str
52 thread_id: str
53 sender: str
54 subject: str
55 handler: str
56 history_preview: str
57
58
59 def build_chat_summary(context_id: str, data: dict) -> ChatSummary:
60 return {
61 "context_id": context_id,
62 "thread_id": data.get(CTX_EMAIL_THREAD_ID, ""),
63 "sender": data.get(CTX_EMAIL_SENDER, ""),
64 "subject": data.get(CTX_EMAIL_SUBJECT, ""),
65 "handler": data.get(CTX_EMAIL_HANDLER, ""),
66 "history_preview": "",
67 }
68
69
70 # ------------------------------------------------------------------
71 # Dispatcher prompt builders
72 # ------------------------------------------------------------------
73
74 def truncate_body(body: str) -> str:
75 if len(body) <= BODY_PREVIEW_MAX_CHARS:
76 return body
77 return body[:BODY_PREVIEW_MAX_CHARS] + "... (truncated)"
78
79
80 def format_chats_list(existing_chats: list[ChatSummary]) -> str:
81 if not existing_chats:
82 return "No existing chats for this handler."
83 sections = []
84 for c in existing_chats[:20]:
85 header = (
86 f"- context_id={c['context_id']}"
87 f" sender={c.get('sender', '')} subject={c.get('subject', '')}"
88 )
89 preview = c.get("history_preview", "")
90 if preview:
91 header += f"\n conversation:\n {preview}"
92 sections.append(header)
93 return "\n".join(sections)
94
95
96 def parse_dispatcher_response(response: str) -> DispatchDecision:
97 line = response.strip().split("\n")[0].strip()
98 parts = line.split(None, 2)
99
100 if len(parts) < 2:
101 return DispatchDecision(action="new_chat", reason="unparseable response")
102
103 action_raw = parts[0].upper()
104 ctx_id = parts[1] if parts[1] != "_" else ""
105 reason = parts[2] if len(parts) > 2 else ""
106
107 action_map: dict[str, DispatchAction] = {
108 "NEW_CHAT": "new_chat",
109 "CONTINUE": "continue_chat",
110 }
111 action = action_map.get(action_raw, "new_chat")
112 return DispatchDecision(action=action, context_id=ctx_id, reason=reason)