gohumanloop 0.0.1__py3-none-any.whl → 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gohumanloop/adapters/__init__.py +17 -0
- gohumanloop/adapters/langgraph_adapter.py +819 -0
- gohumanloop/cli/__init__.py +0 -0
- gohumanloop/cli/main.py +29 -0
- gohumanloop/core/__init__.py +0 -0
- gohumanloop/core/interface.py +437 -0
- gohumanloop/core/manager.py +576 -0
- gohumanloop/manager/__init__.py +0 -0
- gohumanloop/manager/ghl_manager.py +532 -0
- gohumanloop/models/__init__.py +0 -0
- gohumanloop/models/api_model.py +54 -0
- gohumanloop/models/glh_model.py +23 -0
- gohumanloop/providers/__init__.py +0 -0
- gohumanloop/providers/api_provider.py +628 -0
- gohumanloop/providers/base.py +428 -0
- gohumanloop/providers/email_provider.py +1019 -0
- gohumanloop/providers/ghl_provider.py +64 -0
- gohumanloop/providers/terminal_provider.py +302 -0
- gohumanloop/utils/__init__.py +1 -0
- gohumanloop/utils/context_formatter.py +59 -0
- gohumanloop/utils/threadsafedict.py +243 -0
- gohumanloop/utils/utils.py +40 -0
- {gohumanloop-0.0.1.dist-info → gohumanloop-0.0.2.dist-info}/METADATA +2 -1
- gohumanloop-0.0.2.dist-info/RECORD +30 -0
- gohumanloop-0.0.1.dist-info/RECORD +0 -8
- {gohumanloop-0.0.1.dist-info → gohumanloop-0.0.2.dist-info}/WHEEL +0 -0
- {gohumanloop-0.0.1.dist-info → gohumanloop-0.0.2.dist-info}/entry_points.txt +0 -0
- {gohumanloop-0.0.1.dist-info → gohumanloop-0.0.2.dist-info}/licenses/LICENSE +0 -0
- {gohumanloop-0.0.1.dist-info → gohumanloop-0.0.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1019 @@
|
|
1
|
+
import os
|
2
|
+
import re
|
3
|
+
import asyncio
|
4
|
+
import smtplib
|
5
|
+
from imapclient import IMAPClient
|
6
|
+
import email.mime.multipart
|
7
|
+
import email.mime.text
|
8
|
+
from email.header import decode_header
|
9
|
+
from email import message_from_bytes
|
10
|
+
from email.message import Message
|
11
|
+
import logging
|
12
|
+
from typing import Dict, Any, Optional, List, Tuple
|
13
|
+
from datetime import datetime
|
14
|
+
from pydantic import SecretStr
|
15
|
+
|
16
|
+
from gohumanloop.core.interface import ( HumanLoopResult, HumanLoopStatus, HumanLoopType
|
17
|
+
)
|
18
|
+
from gohumanloop.providers.base import BaseProvider
|
19
|
+
from gohumanloop.utils import get_secret_from_env
|
20
|
+
logger = logging.getLogger(__name__)
|
21
|
+
|
22
|
+
class EmailProvider(BaseProvider):
|
23
|
+
"""Email-based human-in-the-loop provider implementation"""
|
24
|
+
|
25
|
+
def __init__(
|
26
|
+
self,
|
27
|
+
name: str,
|
28
|
+
smtp_server: str,
|
29
|
+
smtp_port: int,
|
30
|
+
imap_server: str,
|
31
|
+
imap_port: int,
|
32
|
+
username: Optional[str] = None,
|
33
|
+
password: Optional[SecretStr]=None,
|
34
|
+
sender_email: Optional[str] = None,
|
35
|
+
check_interval: int = 60,
|
36
|
+
language: str = "zh",
|
37
|
+
config: Optional[Dict[str, Any]] = None
|
38
|
+
):
|
39
|
+
"""Initialize Email Provider
|
40
|
+
|
41
|
+
Args:
|
42
|
+
name: Provider name
|
43
|
+
smtp_server: SMTP server address
|
44
|
+
smtp_port: SMTP server port
|
45
|
+
imap_server: IMAP server address
|
46
|
+
imap_port: IMAP server port
|
47
|
+
username: Email username
|
48
|
+
password: Email password
|
49
|
+
sender_email: Sender email address (if different from username)
|
50
|
+
check_interval: Email check interval in seconds
|
51
|
+
language: Language for templates and messages ("zh" for Chinese, "en" for English)
|
52
|
+
config: Additional configuration parameters
|
53
|
+
"""
|
54
|
+
super().__init__(name, config)
|
55
|
+
self.smtp_server = smtp_server
|
56
|
+
self.smtp_port = smtp_port
|
57
|
+
self.imap_server = imap_server
|
58
|
+
self.imap_port = imap_port
|
59
|
+
self.language = language # 保存语言设置
|
60
|
+
|
61
|
+
# 优先从参数获取凭证,如果未提供则从环境变量获取
|
62
|
+
self.username = username or os.environ.get("GOHUMANLOOP_EMAIL_USERNAME")
|
63
|
+
if not self.username:
|
64
|
+
raise ValueError(f"Email username not provided, please set it via parameter or environment variable GOHUMANLOOP_EMAIL_USERNAME")
|
65
|
+
|
66
|
+
self.password = password or get_secret_from_env("GOHUMANLOOP_EMAIL_PASSWORD")
|
67
|
+
if not self.password:
|
68
|
+
raise ValueError(f"Email password not provided, please set it via parameter or environment variable GOHUMANLOOP_EMAIL_PASSWORD")
|
69
|
+
|
70
|
+
self.sender_email = sender_email or self.username
|
71
|
+
self.check_interval = check_interval
|
72
|
+
|
73
|
+
# 存储邮件主题与请求ID的映射关系
|
74
|
+
self._subject_to_request = {}
|
75
|
+
# 存储正在运行的邮件检查任务
|
76
|
+
self._mail_check_tasks = {}
|
77
|
+
# 存储邮件会话ID与对话ID的映射
|
78
|
+
self._thread_to_conversation = {}
|
79
|
+
|
80
|
+
self._init_language_templates()
|
81
|
+
|
82
|
+
def _init_language_templates(self):
|
83
|
+
"""初始化不同语言的模板和关键词"""
|
84
|
+
if self.language == "zh":
|
85
|
+
# 中文关键词和模板
|
86
|
+
self.approve_keywords = {"批准", "同意", "approve"}
|
87
|
+
self.reject_keywords = {"拒绝", "否决", "reject"}
|
88
|
+
self.templates = {
|
89
|
+
"approval_instruction": "请按以下格式回复:",
|
90
|
+
"content_start_mark": "===== 请保留此行作为内容开始标记 =====",
|
91
|
+
"content_end_mark": "===== 请保留此行作为内容结束标记 =====",
|
92
|
+
"decision_prefix": "决定:",
|
93
|
+
"reason_prefix": "理由:",
|
94
|
+
"information_prefix": "信息:",
|
95
|
+
"conversation_instruction": "请直接回复您的内容。如需结束对话,请在回复中包含\"[结束对话]\"。",
|
96
|
+
"conversation_end_mark": "[结束对话]",
|
97
|
+
"input_placeholder": "[请在此处输入您的回复内容]",
|
98
|
+
"approve_template_title": "批准模板:",
|
99
|
+
"reject_template_title": "拒绝模板:",
|
100
|
+
"info_template_title": "信息提供模板:",
|
101
|
+
"template_instruction": "请选择一个模板,复制到您的回复中,替换方括号内的内容后发送。请保留标记行,这将帮助系统准确识别您的回复。",
|
102
|
+
"info_template_instruction": "请复制上面的模板到您的回复中,替换方括号内的内容后发送。请保留标记行,这将帮助系统准确识别您的回复。",
|
103
|
+
"footer": "由 GoHumanLoop 提供支持 - Perfecting AI workflows with human intelligence",
|
104
|
+
"continue_conversation_template":"继续对话模板:",
|
105
|
+
"end_conversation_template": "结束对话模板:",
|
106
|
+
"invalid_decision": "未提供有效的审批决定",
|
107
|
+
"powered_by": "由 GoHumanLoop 提供支持"
|
108
|
+
}
|
109
|
+
else:
|
110
|
+
# 英文关键词和模板
|
111
|
+
self.approve_keywords = {"approve", "approved", "accept", "yes"}
|
112
|
+
self.reject_keywords = {"reject", "rejected", "deny", "no"}
|
113
|
+
self.templates = {
|
114
|
+
"approval_instruction": "Please reply in the following format:",
|
115
|
+
"content_start_mark": "===== PLEASE KEEP THIS LINE AS CONTENT START MARKER =====",
|
116
|
+
"content_end_mark": "===== PLEASE KEEP THIS LINE AS CONTENT END MARKER =====",
|
117
|
+
"decision_prefix": "Decision: ",
|
118
|
+
"reason_prefix": "Reason: ",
|
119
|
+
"information_prefix": "Information: ",
|
120
|
+
"conversation_instruction": "Please reply directly with your content. If you want to end the conversation, include \"[END CONVERSATION]\" in your reply.",
|
121
|
+
"conversation_end_mark": "[END CONVERSATION]",
|
122
|
+
"input_placeholder": "[Please enter your reply here]",
|
123
|
+
"approve_template_title": "Approval Template:",
|
124
|
+
"reject_template_title": "Rejection Template:",
|
125
|
+
"info_template_title": "Information Template:",
|
126
|
+
"template_instruction": "Please select a template, copy it to your reply, replace the content in brackets, and send. Please keep the marker lines as they help the system accurately identify your reply.",
|
127
|
+
"info_template_instruction": "Please copy the template above to your reply, replace the content in brackets, and send. Please keep the marker lines as they help the system accurately identify your reply.",
|
128
|
+
"footer": "Powered by GoHumanLoop - Perfecting AI workflows with human intelligence",
|
129
|
+
"continue_conversation_template": "Continue Conversation Template:",
|
130
|
+
"end_conversation_template": "End Conversation Template:",
|
131
|
+
"invalid_decision": "No valid approval decision provided",
|
132
|
+
"powered_by": "Powered by GoHumanLoop"
|
133
|
+
}
|
134
|
+
|
135
|
+
async def _send_email(
|
136
|
+
self,
|
137
|
+
to_email: str,
|
138
|
+
subject: str,
|
139
|
+
body: str,
|
140
|
+
html_body: Optional[str] = None,
|
141
|
+
reply_to: Optional[str] = None
|
142
|
+
) -> bool:
|
143
|
+
"""Send email
|
144
|
+
|
145
|
+
Args:
|
146
|
+
to_email: Recipient email address
|
147
|
+
subject: Email subject
|
148
|
+
body: Email body (plain text)
|
149
|
+
html_body: Email body (HTML format, optional)
|
150
|
+
reply_to: Reply email ID (optional)
|
151
|
+
|
152
|
+
Returns:
|
153
|
+
bool: Whether sending was successful
|
154
|
+
"""
|
155
|
+
try:
|
156
|
+
msg = email.mime.multipart.MIMEMultipart('alternative')
|
157
|
+
msg['From'] = self.sender_email
|
158
|
+
msg['To'] = to_email
|
159
|
+
msg['Subject'] = subject
|
160
|
+
|
161
|
+
# 添加纯文本内容
|
162
|
+
msg.attach(email.mime.text.MIMEText(body, 'plain'))
|
163
|
+
|
164
|
+
# 如果提供了HTML内容,也添加HTML版本
|
165
|
+
if html_body:
|
166
|
+
msg.attach(email.mime.text.MIMEText(html_body, 'html'))
|
167
|
+
|
168
|
+
# 如果是回复邮件,添加相关邮件头
|
169
|
+
if reply_to:
|
170
|
+
msg['In-Reply-To'] = reply_to
|
171
|
+
msg['References'] = reply_to
|
172
|
+
|
173
|
+
# 使用异步方式发送邮件
|
174
|
+
loop = asyncio.get_event_loop()
|
175
|
+
await loop.run_in_executor(
|
176
|
+
None,
|
177
|
+
self._send_email_sync,
|
178
|
+
msg
|
179
|
+
)
|
180
|
+
return True
|
181
|
+
except Exception as e:
|
182
|
+
logger.error(f"Failed to send email: {str(e)}", exc_info=True)
|
183
|
+
return False
|
184
|
+
|
185
|
+
def _send_email_sync(self, msg):
|
186
|
+
"""Synchronously send email (runs in executor)"""
|
187
|
+
try:
|
188
|
+
with smtplib.SMTP_SSL(self.smtp_server, self.smtp_port) as server:
|
189
|
+
server.login(self.username, self.password.get_secret_value())
|
190
|
+
server.send_message(msg)
|
191
|
+
except smtplib.SMTPException as e:
|
192
|
+
logger.exception(f"SMTP error: {str(e)}")
|
193
|
+
raise
|
194
|
+
except Exception as e:
|
195
|
+
logger.exception(f"Unknown error occurred while sending email: {str(e)}")
|
196
|
+
raise
|
197
|
+
|
198
|
+
async def _check_emails(self, conversation_id: str, request_id: str, recipient_email: str, subject: str):
|
199
|
+
"""Check email replies
|
200
|
+
|
201
|
+
Args:
|
202
|
+
conversation_id: Conversation ID
|
203
|
+
request_id: Request ID
|
204
|
+
recipient_email: Recipient email address
|
205
|
+
subject: Email subject
|
206
|
+
"""
|
207
|
+
request_key = (conversation_id, request_id)
|
208
|
+
|
209
|
+
while request_key in self._requests and self._requests[request_key]["status"] in [
|
210
|
+
HumanLoopStatus.PENDING
|
211
|
+
]:
|
212
|
+
try:
|
213
|
+
# 使用异步方式检查邮件
|
214
|
+
loop = asyncio.get_event_loop()
|
215
|
+
email_msg = await loop.run_in_executor(
|
216
|
+
None,
|
217
|
+
self._fetch_emails_sync,
|
218
|
+
subject,
|
219
|
+
recipient_email
|
220
|
+
)
|
221
|
+
|
222
|
+
|
223
|
+
await self._process_email_response(conversation_id, request_id, email_msg)
|
224
|
+
|
225
|
+
# 等待一段时间后再次检查
|
226
|
+
await asyncio.sleep(self.check_interval)
|
227
|
+
except Exception as e:
|
228
|
+
logger.error(f"Failed to check emails: {str(e)}", exc_info=True)
|
229
|
+
self._update_request_status_error(conversation_id, request_id, f"Failed to check emails: {str(e)}")
|
230
|
+
break
|
231
|
+
|
232
|
+
def _decode_email_header(self, header_value: str) -> str:
|
233
|
+
"""Parse email header information and handle potential encoding issues
|
234
|
+
|
235
|
+
Args:
|
236
|
+
header_value: Raw email header value
|
237
|
+
|
238
|
+
Returns:
|
239
|
+
str: Decoded email header value
|
240
|
+
"""
|
241
|
+
decoded_parts = decode_header(header_value)
|
242
|
+
result = ""
|
243
|
+
for part, charset in decoded_parts:
|
244
|
+
if isinstance(part, bytes):
|
245
|
+
# 如果字符集未指定,默认使用utf-8
|
246
|
+
charset = charset or "utf-8"
|
247
|
+
result += part.decode(charset)
|
248
|
+
else:
|
249
|
+
result += str(part)
|
250
|
+
return result
|
251
|
+
|
252
|
+
def _fetch_emails_sync(self, subject: str, sender_email: Optional[str] = None) -> Any:
|
253
|
+
"""Synchronously fetch emails (runs in executor)
|
254
|
+
|
255
|
+
Args:
|
256
|
+
subject: Email subject
|
257
|
+
sender_email: Sender email address (optional filter)
|
258
|
+
|
259
|
+
Returns:
|
260
|
+
List[email.message.Message]: List of matching emails
|
261
|
+
"""
|
262
|
+
|
263
|
+
# 连接到IMAP服务器
|
264
|
+
with IMAPClient(host=self.imap_server, port=self.imap_port ,ssl=True) as client:
|
265
|
+
client.login(self.username, self.password.get_secret_value())
|
266
|
+
|
267
|
+
# 发送 ID 命令,解决某些邮箱服务器的安全限制(如网易邮箱)
|
268
|
+
try:
|
269
|
+
client.id_({
|
270
|
+
"name": "GoHumanLoop",
|
271
|
+
"version": "1.0.0",
|
272
|
+
"vendor": "GoHumanLoop Client",
|
273
|
+
"contact": "baird0917@163.com"
|
274
|
+
})
|
275
|
+
logger.debug("IMAP ID command sent")
|
276
|
+
except Exception as e:
|
277
|
+
logger.warning(f"Failed to send IMAP ID command: {str(e)}")
|
278
|
+
|
279
|
+
# 选择收件箱
|
280
|
+
client.select_folder("INBOX")
|
281
|
+
|
282
|
+
# 执行搜索
|
283
|
+
messages = client.search("UNSEEN")
|
284
|
+
|
285
|
+
if not messages:
|
286
|
+
logger.warning(f"No unread emails found")
|
287
|
+
return None
|
288
|
+
|
289
|
+
# 获取邮件内容
|
290
|
+
for uid, message_data in client.fetch(messages, "RFC822").items():
|
291
|
+
email_message = message_from_bytes(message_data[b"RFC822"])
|
292
|
+
|
293
|
+
# 使用通用方法解析发件人
|
294
|
+
from_header = self._decode_email_header(email_message.get("From", ""))
|
295
|
+
|
296
|
+
# 使用通用方法解析主题
|
297
|
+
email_subject = self._decode_email_header(email_message.get("Subject", ""))
|
298
|
+
|
299
|
+
# 检查是否匹配发件人和主题条件
|
300
|
+
if (sender_email and sender_email not in from_header) or \
|
301
|
+
(subject and subject not in email_subject):
|
302
|
+
continue
|
303
|
+
|
304
|
+
# 将邮件标记为已读
|
305
|
+
client.set_flags(uid, [b'\\Seen'])
|
306
|
+
return email_message
|
307
|
+
return None
|
308
|
+
|
309
|
+
async def _process_email_response(self, conversation_id: str, request_id: str, email_msg: Message):
|
310
|
+
"""Process email response
|
311
|
+
|
312
|
+
Args:
|
313
|
+
conversation_id: Conversation ID
|
314
|
+
request_id: Request ID
|
315
|
+
email_msg: Email message object
|
316
|
+
"""
|
317
|
+
if email_msg is None:
|
318
|
+
return
|
319
|
+
|
320
|
+
request_key = (conversation_id, request_id)
|
321
|
+
if request_key not in self._requests:
|
322
|
+
return
|
323
|
+
|
324
|
+
# 提取邮件内容
|
325
|
+
body = ""
|
326
|
+
html_body = ""
|
327
|
+
|
328
|
+
if email_msg.is_multipart():
|
329
|
+
for part in email_msg.walk():
|
330
|
+
content_type = part.get_content_type()
|
331
|
+
charse_type = part.get_content_charset()
|
332
|
+
content_disposition = str(part.get("Content-Disposition"))
|
333
|
+
|
334
|
+
# 跳过附件
|
335
|
+
if "attachment" in content_disposition:
|
336
|
+
continue
|
337
|
+
|
338
|
+
# 获取邮件内容
|
339
|
+
payload = part.get_payload(decode=True)
|
340
|
+
if payload is None:
|
341
|
+
continue
|
342
|
+
|
343
|
+
if content_type == "text/plain":
|
344
|
+
body = payload.decode(encoding=charse_type)
|
345
|
+
elif content_type == "text/html":
|
346
|
+
html_body = payload.decode(encoding=charse_type)
|
347
|
+
else:
|
348
|
+
# 非多部分邮件
|
349
|
+
charse_type = email_msg.get_content_charset()
|
350
|
+
payload = email_msg.get_payload(decode=True)
|
351
|
+
if payload:
|
352
|
+
body = payload.decode(encoding=charse_type)
|
353
|
+
|
354
|
+
# 获取请求信息
|
355
|
+
request_info = self._requests[request_key]
|
356
|
+
loop_type = request_info.get("loop_type", HumanLoopType.CONVERSATION)
|
357
|
+
responded_by = self._decode_email_header(email_msg.get("From", ""))
|
358
|
+
|
359
|
+
# 解析响应内容
|
360
|
+
parsed_response = {
|
361
|
+
"text": body,
|
362
|
+
"html": html_body,
|
363
|
+
"subject": self._decode_email_header(email_msg.get("Subject", "")),
|
364
|
+
"from": responded_by,
|
365
|
+
"date": self._decode_email_header(email_msg.get("Date", "")),
|
366
|
+
"message_id": email_msg.get("Message-ID", "")
|
367
|
+
}
|
368
|
+
|
369
|
+
# 提取用户的实际回复内容
|
370
|
+
user_content = self._extract_user_reply_content(body)
|
371
|
+
logger.debug(f"user_content:\n {user_content}")
|
372
|
+
# 根据不同的循环类型解析回复
|
373
|
+
if loop_type == HumanLoopType.APPROVAL:
|
374
|
+
# 解析审批决定和理由
|
375
|
+
decision = None
|
376
|
+
reason = None
|
377
|
+
|
378
|
+
# 使用语言相关的关键词
|
379
|
+
approve_keywords = self.approve_keywords
|
380
|
+
reject_keywords = self.reject_keywords
|
381
|
+
decision_prefix = self.templates["decision_prefix"]
|
382
|
+
|
383
|
+
# 遍历每行内容寻找决定信息
|
384
|
+
for line in map(str.strip, user_content.split('\n')):
|
385
|
+
if not line.startswith(decision_prefix):
|
386
|
+
continue
|
387
|
+
|
388
|
+
decision_text = line[len(decision_prefix):].strip().lower()
|
389
|
+
|
390
|
+
# 判断决定类型
|
391
|
+
if any(keyword in decision_text for keyword in approve_keywords):
|
392
|
+
decision = "approved"
|
393
|
+
elif any(keyword in decision_text for keyword in reject_keywords):
|
394
|
+
decision = "rejected"
|
395
|
+
else:
|
396
|
+
decision = "rejected"
|
397
|
+
reason = self.templates["invalid_decision"]
|
398
|
+
break
|
399
|
+
|
400
|
+
# 提取理由
|
401
|
+
reason_prefix = self.templates["reason_prefix"]
|
402
|
+
if reason_prefix in line:
|
403
|
+
reason = line[line.find(reason_prefix) + len(reason_prefix):].strip()
|
404
|
+
break
|
405
|
+
parsed_response["decision"] = decision
|
406
|
+
parsed_response["reason"] = reason
|
407
|
+
|
408
|
+
|
409
|
+
# 设置状态
|
410
|
+
if decision == "approved":
|
411
|
+
status = HumanLoopStatus.APPROVED
|
412
|
+
elif decision == "rejected":
|
413
|
+
status = HumanLoopStatus.REJECTED
|
414
|
+
|
415
|
+
|
416
|
+
elif loop_type == HumanLoopType.INFORMATION:
|
417
|
+
# 解析提供的信息和备注
|
418
|
+
information = None
|
419
|
+
information_prefix = self.templates["information_prefix"]
|
420
|
+
|
421
|
+
for line in user_content.split('\n'):
|
422
|
+
line = line.strip()
|
423
|
+
if line.startswith(information_prefix):
|
424
|
+
information = line[len(information_prefix):].strip()
|
425
|
+
break
|
426
|
+
|
427
|
+
parsed_response["information"] = information
|
428
|
+
status = HumanLoopStatus.COMPLETED
|
429
|
+
|
430
|
+
elif loop_type == HumanLoopType.CONVERSATION:
|
431
|
+
conversation_end_mark = self.templates["conversation_end_mark"]
|
432
|
+
|
433
|
+
# 检查用户的实际回复内容中是否包含结束对话的标记
|
434
|
+
if user_content and conversation_end_mark in user_content:
|
435
|
+
parsed_response["user_content"] = user_content
|
436
|
+
status = HumanLoopStatus.COMPLETED
|
437
|
+
else:
|
438
|
+
parsed_response["user_content"] = user_content
|
439
|
+
status = HumanLoopStatus.INPROGRESS
|
440
|
+
else:
|
441
|
+
status = HumanLoopStatus.COMPLETED
|
442
|
+
|
443
|
+
# 更新请求状态
|
444
|
+
self._requests[request_key].update({
|
445
|
+
"status": status,
|
446
|
+
"response": parsed_response,
|
447
|
+
"responded_by": responded_by,
|
448
|
+
"responded_at": datetime.now().isoformat()
|
449
|
+
})
|
450
|
+
|
451
|
+
# 取消超时任务
|
452
|
+
if request_key in self._timeout_tasks:
|
453
|
+
self._timeout_tasks[request_key].cancel()
|
454
|
+
del self._timeout_tasks[request_key]
|
455
|
+
|
456
|
+
def _format_email_body(self, body: str, loop_type: HumanLoopType, subject: str) -> Tuple[str, str]:
|
457
|
+
"""Format email body
|
458
|
+
|
459
|
+
Args:
|
460
|
+
body: Email body content
|
461
|
+
loop_type: Loop type
|
462
|
+
conversation_id: Conversation ID (optional)
|
463
|
+
request_id: Request ID (optional)
|
464
|
+
|
465
|
+
Returns:
|
466
|
+
Tuple[str, str]: (Plain text body, HTML body)
|
467
|
+
"""
|
468
|
+
|
469
|
+
# 获取模板文本
|
470
|
+
templates = self.templates
|
471
|
+
|
472
|
+
# 构建纯文本正文
|
473
|
+
text_body = body
|
474
|
+
|
475
|
+
# 根据不同的循环类型添加回复指导
|
476
|
+
if loop_type == HumanLoopType.APPROVAL:
|
477
|
+
text_body += f"\n\n{templates['approval_instruction']}\n"
|
478
|
+
text_body += f"{templates['content_start_mark']}\n"
|
479
|
+
text_body += f"{templates['decision_prefix']}[{'/'.join(self.approve_keywords)}|{'/'.join(self.reject_keywords)}]\n"
|
480
|
+
text_body += f"{templates['reason_prefix']}[Your reason]\n"
|
481
|
+
text_body += f"{templates['content_end_mark']}\n"
|
482
|
+
elif loop_type == HumanLoopType.INFORMATION:
|
483
|
+
text_body += f"\n\n{templates['approval_instruction']}\n"
|
484
|
+
text_body += f"{templates['content_start_mark']}\n"
|
485
|
+
text_body += f"{templates['information_prefix']}[Your information]\n"
|
486
|
+
text_body += f"{templates['content_end_mark']}\n"
|
487
|
+
elif loop_type == HumanLoopType.CONVERSATION:
|
488
|
+
text_body += f"\n\n{templates['conversation_instruction']}\n"
|
489
|
+
text_body += f"{templates['content_start_mark']}\n"
|
490
|
+
text_body += f"{templates['input_placeholder']}\n"
|
491
|
+
text_body += f"{templates['content_end_mark']}\n"
|
492
|
+
|
493
|
+
# 添加纯文本版本的宣传标识
|
494
|
+
text_body += f"\n\n---\n{templates['footer']}\n"
|
495
|
+
|
496
|
+
# 构建HTML正文
|
497
|
+
html_body = ["<html><body>"]
|
498
|
+
|
499
|
+
# 将纯文本内容按行分割并转换为HTML段落
|
500
|
+
content_lines = []
|
501
|
+
instruction_lines = []
|
502
|
+
|
503
|
+
# 分离内容和指导说明
|
504
|
+
lines = text_body.split('\n')
|
505
|
+
instruction_start = -1
|
506
|
+
|
507
|
+
for i, line in enumerate(lines):
|
508
|
+
if line.strip() == templates['approval_instruction'] or line.strip() == templates['conversation_instruction']:
|
509
|
+
instruction_start = i
|
510
|
+
break
|
511
|
+
|
512
|
+
if instruction_start > -1:
|
513
|
+
content_lines = lines[:instruction_start]
|
514
|
+
instruction_lines = lines[instruction_start:]
|
515
|
+
else:
|
516
|
+
content_lines = lines
|
517
|
+
|
518
|
+
# 添加主要内容
|
519
|
+
for line in content_lines:
|
520
|
+
if line.strip():
|
521
|
+
html_body.append(f"<p>{line}</p>")
|
522
|
+
|
523
|
+
# 添加回复指导(使用不同的样式)
|
524
|
+
if instruction_lines:
|
525
|
+
html_body.append("<hr>")
|
526
|
+
html_body.append("<div style='background-color: #f5f5f5; padding: 10px; border-left: 4px solid #007bff;'>")
|
527
|
+
|
528
|
+
if loop_type == HumanLoopType.APPROVAL:
|
529
|
+
html_body.append(f"<p><strong>{templates['approval_instruction']}</strong></p>")
|
530
|
+
# 添加预格式化回复模板
|
531
|
+
html_body.append(f"""
|
532
|
+
<div style="margin-top: 15px;">
|
533
|
+
<div style="background-color: #f8f9fa; padding: 15px; border: 1px solid #ddd; border-radius: 5px;">
|
534
|
+
<p style="margin: 0 0 10px 0; font-weight: bold;">{templates['approve_template_title']}</p>
|
535
|
+
<pre style="background-color: #ffffff; padding: 10px; border: 1px solid #eee; margin: 0 0 15px 0;">
|
536
|
+
{templates['content_start_mark']}
|
537
|
+
{templates['decision_prefix']}{list(self.approve_keywords)[0]}
|
538
|
+
{templates['reason_prefix']}[Your reason]
|
539
|
+
{templates['content_end_mark']}</pre>
|
540
|
+
|
541
|
+
<p style="margin: 0 0 10px 0; font-weight: bold;">{templates['reject_template_title']}</p>
|
542
|
+
<pre style="background-color: #ffffff; padding: 10px; border: 1px solid #eee; margin: 0;">
|
543
|
+
{templates['content_start_mark']}
|
544
|
+
{templates['decision_prefix']}{list(self.reject_keywords)[0]}
|
545
|
+
{templates['reason_prefix']}[Your reason]
|
546
|
+
{templates['content_end_mark']}</pre>
|
547
|
+
</div>
|
548
|
+
<p style="margin-top: 10px; font-size: 12px; color: #666;">
|
549
|
+
{templates['template_instruction']}
|
550
|
+
</p>
|
551
|
+
</div>
|
552
|
+
""")
|
553
|
+
elif loop_type == HumanLoopType.INFORMATION:
|
554
|
+
html_body.append(f"<p><strong>{templates['approval_instruction']}</strong></p>")
|
555
|
+
html_body.append(f"""
|
556
|
+
<div style="margin-top: 15px;">
|
557
|
+
<div style="background-color: #f8f9fa; padding: 15px; border: 1px solid #ddd; border-radius: 5px;">
|
558
|
+
<p style="margin: 0 0 10px 0; font-weight: bold;">{templates['info_template_title']}</p>
|
559
|
+
<pre style="background-color: #ffffff; padding: 10px; border: 1px solid #eee; margin: 0;">
|
560
|
+
{templates['content_start_mark']}
|
561
|
+
{templates['information_prefix']}[Your information]
|
562
|
+
{templates['content_end_mark']}</pre>
|
563
|
+
</div>
|
564
|
+
<p style="margin-top: 10px; font-size: 12px; color: #666;">
|
565
|
+
{templates['info_template_instruction']}
|
566
|
+
</p>
|
567
|
+
</div>
|
568
|
+
""")
|
569
|
+
elif loop_type == HumanLoopType.CONVERSATION:
|
570
|
+
# 使用模板变量替换硬编码的中文
|
571
|
+
html_body.append(f"<p><strong>{templates['conversation_instruction']}</strong></p>")
|
572
|
+
|
573
|
+
# 添加预格式化回复模板
|
574
|
+
html_body.append(f"""
|
575
|
+
<div style="margin-top: 15px;">
|
576
|
+
<div style="background-color: #f8f9fa; padding: 15px; border: 1px solid #ddd; border-radius: 5px;">
|
577
|
+
<p style="margin: 0 0 10px 0; font-weight: bold;">{templates['continue_conversation_template']}</p>
|
578
|
+
<pre style="background-color: #ffffff; padding: 10px; border: 1px solid #eee; margin: 0 0 15px 0;">
|
579
|
+
{templates['content_start_mark']}
|
580
|
+
{templates['input_placeholder']}
|
581
|
+
{templates['content_end_mark']}</pre>
|
582
|
+
<p style="margin: 0 0 10px 0; font-weight: bold;">{templates['end_conversation_template']}</p>
|
583
|
+
<pre style="background-color: #ffffff; padding: 10px; border: 1px solid #eee; margin: 0;">
|
584
|
+
{templates['content_start_mark']}
|
585
|
+
{templates['input_placeholder']}
|
586
|
+
{templates['conversation_end_mark']}
|
587
|
+
{templates['content_end_mark']}</pre>
|
588
|
+
</div>
|
589
|
+
<p style="margin-top: 10px; font-size: 12px; color: #666;">
|
590
|
+
{templates['template_instruction']}
|
591
|
+
</p>
|
592
|
+
</div>
|
593
|
+
""")
|
594
|
+
|
595
|
+
html_body.append("</div>")
|
596
|
+
|
597
|
+
# 添加 GoHumanLoop 宣传标识
|
598
|
+
html_body.append("<hr style='margin-top: 20px; margin-bottom: 10px;'>")
|
599
|
+
html_body.append("<div style='text-align: center; color: #666; font-size: 12px; margin-bottom: 10px;'>")
|
600
|
+
html_body.append(f"<p>{'由 ' if self.language == 'zh' else 'Powered by '}<strong style='color: #007bff;'><a href=\"https://github.com/ptonlix/gohumanloop\" style=\"color: #007bff; text-decoration: none;\">GoHumanLoop</a></strong>{' 提供支持' if self.language == 'zh' else ''}</p>")
|
601
|
+
html_body.append(f"<p style='font-style: italic;'>{templates['footer'].split(' - ')[1]}</p>")
|
602
|
+
html_body.append("</div>")
|
603
|
+
|
604
|
+
return text_body, "\n".join(html_body)
|
605
|
+
|
606
|
+
async def request_humanloop(
|
607
|
+
self,
|
608
|
+
task_id: str,
|
609
|
+
conversation_id: str,
|
610
|
+
loop_type: HumanLoopType,
|
611
|
+
context: Dict[str, Any],
|
612
|
+
metadata: Optional[Dict[str, Any]] = None,
|
613
|
+
timeout: Optional[int] = None
|
614
|
+
) -> HumanLoopResult:
|
615
|
+
"""Request human-in-the-loop interaction
|
616
|
+
|
617
|
+
Args:
|
618
|
+
task_id: Task identifier
|
619
|
+
conversation_id: Conversation ID for multi-turn dialogues
|
620
|
+
loop_type: Type of loop interaction
|
621
|
+
context: Context information provided to human
|
622
|
+
metadata: Additional metadata
|
623
|
+
timeout: Request timeout in seconds
|
624
|
+
|
625
|
+
Returns:
|
626
|
+
HumanLoopResult: Result object containing request ID and initial status
|
627
|
+
"""
|
628
|
+
metadata = metadata or {}
|
629
|
+
|
630
|
+
# 生成请求ID
|
631
|
+
request_id = self._generate_request_id()
|
632
|
+
|
633
|
+
|
634
|
+
|
635
|
+
|
636
|
+
# 获取收件人邮箱
|
637
|
+
recipient_email = metadata.get("recipient_email")
|
638
|
+
|
639
|
+
# 生成邮件主题
|
640
|
+
subject_prefix = metadata.get("subject_prefix", f"[{self.name}]")
|
641
|
+
subject = metadata.get("subject", f"{subject_prefix} Task {task_id}")
|
642
|
+
|
643
|
+
# 存储请求信息
|
644
|
+
self._store_request(
|
645
|
+
conversation_id=conversation_id,
|
646
|
+
request_id=request_id,
|
647
|
+
task_id=task_id,
|
648
|
+
loop_type=loop_type,
|
649
|
+
context=context,
|
650
|
+
metadata={**metadata, "subject": subject, "recipient_email": recipient_email},
|
651
|
+
timeout=timeout
|
652
|
+
)
|
653
|
+
|
654
|
+
if not recipient_email:
|
655
|
+
self._update_request_status_error(conversation_id, request_id, "Recipient email address is missing")
|
656
|
+
return HumanLoopResult(
|
657
|
+
conversation_id=conversation_id,
|
658
|
+
request_id=request_id,
|
659
|
+
loop_type=loop_type,
|
660
|
+
status=HumanLoopStatus.ERROR,
|
661
|
+
error="Recipient email address is missing"
|
662
|
+
)
|
663
|
+
|
664
|
+
# 构建邮件内容
|
665
|
+
prompt = self.build_prompt(
|
666
|
+
task_id=task_id,
|
667
|
+
conversation_id=conversation_id,
|
668
|
+
request_id=request_id,
|
669
|
+
loop_type=loop_type,
|
670
|
+
created_at=datetime.now().isoformat(),
|
671
|
+
context=context,
|
672
|
+
metadata=metadata,
|
673
|
+
color=False
|
674
|
+
)
|
675
|
+
|
676
|
+
body, html_body = self._format_email_body(prompt, loop_type, subject)
|
677
|
+
|
678
|
+
|
679
|
+
|
680
|
+
# 发送邮件
|
681
|
+
success = await self._send_email(
|
682
|
+
to_email=recipient_email,
|
683
|
+
subject=subject,
|
684
|
+
body=body,
|
685
|
+
html_body=html_body
|
686
|
+
)
|
687
|
+
|
688
|
+
if not success:
|
689
|
+
# Update request status to error
|
690
|
+
self._update_request_status_error(conversation_id, request_id, "Failed to send email")
|
691
|
+
|
692
|
+
return HumanLoopResult(
|
693
|
+
conversation_id=conversation_id,
|
694
|
+
request_id=request_id,
|
695
|
+
loop_type=loop_type,
|
696
|
+
status=HumanLoopStatus.ERROR,
|
697
|
+
error="Failed to send email"
|
698
|
+
)
|
699
|
+
# 存储主题与请求的映射关系
|
700
|
+
self._subject_to_request[subject] = (conversation_id, request_id)
|
701
|
+
|
702
|
+
# 创建邮件检查任务
|
703
|
+
check_task = asyncio.create_task(
|
704
|
+
self._check_emails(conversation_id, request_id, recipient_email, subject)
|
705
|
+
)
|
706
|
+
self._mail_check_tasks[(conversation_id, request_id)] = check_task
|
707
|
+
|
708
|
+
# 如果设置了超时,创建超时任务
|
709
|
+
if timeout:
|
710
|
+
self._create_timeout_task(conversation_id, request_id, timeout)
|
711
|
+
|
712
|
+
return HumanLoopResult(
|
713
|
+
conversation_id=conversation_id,
|
714
|
+
request_id=request_id,
|
715
|
+
loop_type=loop_type,
|
716
|
+
status=HumanLoopStatus.PENDING
|
717
|
+
)
|
718
|
+
|
719
|
+
async def check_request_status(
|
720
|
+
self,
|
721
|
+
conversation_id: str,
|
722
|
+
request_id: str
|
723
|
+
) -> HumanLoopResult:
|
724
|
+
"""Check request status
|
725
|
+
|
726
|
+
Args:
|
727
|
+
conversation_id: Conversation identifier
|
728
|
+
request_id: Request identifier
|
729
|
+
|
730
|
+
Returns:
|
731
|
+
HumanLoopResult: Result object containing current status
|
732
|
+
"""
|
733
|
+
request_info = self._get_request(conversation_id, request_id)
|
734
|
+
if not request_info:
|
735
|
+
return HumanLoopResult(
|
736
|
+
conversation_id=conversation_id,
|
737
|
+
request_id=request_id,
|
738
|
+
loop_type=HumanLoopType.CONVERSATION,
|
739
|
+
status=HumanLoopStatus.ERROR,
|
740
|
+
error=f"Request '{request_id}' not found in conversation '{conversation_id}'"
|
741
|
+
)
|
742
|
+
|
743
|
+
# 构建结果对象
|
744
|
+
result = HumanLoopResult(
|
745
|
+
conversation_id=conversation_id,
|
746
|
+
request_id=request_id,
|
747
|
+
loop_type=request_info.get("loop_type", HumanLoopType.CONVERSATION),
|
748
|
+
status=request_info.get("status", HumanLoopStatus.PENDING),
|
749
|
+
response=request_info.get("response", {}),
|
750
|
+
feedback=request_info.get("feedback", {}),
|
751
|
+
responded_by=request_info.get("responded_by", None),
|
752
|
+
responded_at=request_info.get("responded_at", None),
|
753
|
+
error=request_info.get("error", None)
|
754
|
+
)
|
755
|
+
|
756
|
+
return result
|
757
|
+
|
758
|
+
async def continue_humanloop(
|
759
|
+
self,
|
760
|
+
conversation_id: str,
|
761
|
+
context: Dict[str, Any],
|
762
|
+
metadata: Optional[Dict[str, Any]] = None,
|
763
|
+
timeout: Optional[int] = None,
|
764
|
+
) -> HumanLoopResult:
|
765
|
+
"""Continue human-in-the-loop interaction
|
766
|
+
|
767
|
+
Args:
|
768
|
+
conversation_id: Conversation ID for multi-turn dialogues
|
769
|
+
context: Context information provided to human
|
770
|
+
metadata: Additional metadata
|
771
|
+
timeout: Request timeout in seconds
|
772
|
+
|
773
|
+
Returns:
|
774
|
+
HumanLoopResult: Result object containing request ID and status
|
775
|
+
"""
|
776
|
+
"""Continue human-in-the-loop interaction for multi-turn dialogues
|
777
|
+
|
778
|
+
Args:
|
779
|
+
conversation_id: Conversation ID
|
780
|
+
context: Context information provided to human
|
781
|
+
metadata: Additional metadata
|
782
|
+
timeout: Request timeout in seconds
|
783
|
+
|
784
|
+
Returns:
|
785
|
+
HumanLoopResult: Result object containing request ID and status
|
786
|
+
"""
|
787
|
+
metadata = metadata or {}
|
788
|
+
|
789
|
+
# 检查对话是否存在
|
790
|
+
conversation_info = self._get_conversation(conversation_id)
|
791
|
+
if not conversation_info:
|
792
|
+
return HumanLoopResult(
|
793
|
+
conversation_id=conversation_id,
|
794
|
+
request_id="",
|
795
|
+
loop_type=HumanLoopType.CONVERSATION,
|
796
|
+
status=HumanLoopStatus.ERROR,
|
797
|
+
error=f"Conversation '{conversation_id}' not found"
|
798
|
+
)
|
799
|
+
|
800
|
+
# 生成新的请求ID
|
801
|
+
request_id = self._generate_request_id()
|
802
|
+
|
803
|
+
# 获取任务ID
|
804
|
+
task_id = conversation_info.get("task_id", "unknown_task")
|
805
|
+
|
806
|
+
# 存储请求信息
|
807
|
+
self._store_request(
|
808
|
+
conversation_id=conversation_id,
|
809
|
+
request_id=request_id,
|
810
|
+
task_id=task_id,
|
811
|
+
loop_type=HumanLoopType.CONVERSATION, # 继续对话默认为对话类型
|
812
|
+
context=context,
|
813
|
+
metadata=metadata or {},
|
814
|
+
timeout=timeout
|
815
|
+
)
|
816
|
+
|
817
|
+
|
818
|
+
# 获取收件人邮箱
|
819
|
+
recipient_email = metadata.get("recipient_email")
|
820
|
+
if not recipient_email:
|
821
|
+
self._update_request_status_error(conversation_id, request_id, "Recipient email address is missing")
|
822
|
+
return HumanLoopResult(
|
823
|
+
conversation_id=conversation_id,
|
824
|
+
request_id=request_id,
|
825
|
+
loop_type=HumanLoopType.CONVERSATION,
|
826
|
+
status=HumanLoopStatus.ERROR,
|
827
|
+
error="Recipient email address is missing"
|
828
|
+
)
|
829
|
+
|
830
|
+
|
831
|
+
# 继续对话,使用相同的主题
|
832
|
+
if conversation_id in self._conversations:
|
833
|
+
conversation_info = self._get_conversation(conversation_id)
|
834
|
+
if conversation_info:
|
835
|
+
latest_request_id = conversation_info.get("latest_request_id")
|
836
|
+
last_request_key = (conversation_id, latest_request_id)
|
837
|
+
if last_request_key in self._requests:
|
838
|
+
last_metadata = self._requests[last_request_key].get("metadata", {})
|
839
|
+
if "subject" in last_metadata:
|
840
|
+
subject = last_metadata["subject"]
|
841
|
+
metadata["subject"] = subject # 保持相同主题
|
842
|
+
|
843
|
+
# 构建邮件内容
|
844
|
+
prompt = self.build_prompt(
|
845
|
+
task_id=task_id,
|
846
|
+
conversation_id=conversation_id,
|
847
|
+
request_id=request_id,
|
848
|
+
loop_type=HumanLoopType.CONVERSATION,
|
849
|
+
created_at=datetime.now().isoformat(),
|
850
|
+
context=context,
|
851
|
+
metadata=metadata,
|
852
|
+
color=False
|
853
|
+
)
|
854
|
+
|
855
|
+
body, html_body = self._format_email_body(prompt, HumanLoopType.CONVERSATION, subject)
|
856
|
+
|
857
|
+
# 发送邮件
|
858
|
+
success = await self._send_email(
|
859
|
+
to_email=recipient_email,
|
860
|
+
subject=subject,
|
861
|
+
body=body,
|
862
|
+
html_body=html_body
|
863
|
+
)
|
864
|
+
|
865
|
+
if not success:
|
866
|
+
# Update request status to error
|
867
|
+
self._update_request_status_error(conversation_id, request_id, "Failed to send email")
|
868
|
+
|
869
|
+
return HumanLoopResult(
|
870
|
+
conversation_id=conversation_id,
|
871
|
+
request_id=request_id,
|
872
|
+
loop_type=HumanLoopType.CONVERSATION,
|
873
|
+
status=HumanLoopStatus.ERROR,
|
874
|
+
error="Failed to send email"
|
875
|
+
)
|
876
|
+
|
877
|
+
# 存储主题与请求的映射关系
|
878
|
+
self._subject_to_request[subject] = (conversation_id, request_id)
|
879
|
+
|
880
|
+
# 创建邮件检查任务
|
881
|
+
check_task = asyncio.create_task(
|
882
|
+
self._check_emails(conversation_id, request_id, recipient_email, subject)
|
883
|
+
)
|
884
|
+
self._mail_check_tasks[(conversation_id, request_id)] = check_task
|
885
|
+
|
886
|
+
# 如果设置了超时,创建超时任务
|
887
|
+
if timeout:
|
888
|
+
self._create_timeout_task(conversation_id, request_id, timeout)
|
889
|
+
|
890
|
+
return HumanLoopResult(
|
891
|
+
conversation_id=conversation_id,
|
892
|
+
request_id=request_id,
|
893
|
+
loop_type=HumanLoopType.CONVERSATION,
|
894
|
+
status=HumanLoopStatus.PENDING
|
895
|
+
)
|
896
|
+
|
897
|
+
async def cancel_request(
|
898
|
+
self,
|
899
|
+
conversation_id: str,
|
900
|
+
request_id: str
|
901
|
+
) -> bool:
|
902
|
+
"""Cancel human-in-the-loop request
|
903
|
+
|
904
|
+
Args:
|
905
|
+
conversation_id: Conversation identifier for multi-turn dialogues
|
906
|
+
request_id: Request identifier for specific interaction request
|
907
|
+
|
908
|
+
Return:
|
909
|
+
bool: Whether cancellation was successful, True indicates successful cancellation,
|
910
|
+
False indicates cancellation failed
|
911
|
+
"""
|
912
|
+
# 取消邮件检查任务
|
913
|
+
request_key = (conversation_id, request_id)
|
914
|
+
if request_key in self._mail_check_tasks:
|
915
|
+
self._mail_check_tasks[request_key].cancel()
|
916
|
+
del self._mail_check_tasks[request_key]
|
917
|
+
|
918
|
+
# 调用父类方法取消请求
|
919
|
+
return await super().cancel_request(conversation_id, request_id)
|
920
|
+
|
921
|
+
async def cancel_conversation(
|
922
|
+
self,
|
923
|
+
conversation_id: str
|
924
|
+
) -> bool:
|
925
|
+
"""Cancel the entire conversation
|
926
|
+
|
927
|
+
Args:
|
928
|
+
conversation_id: Conversation identifier
|
929
|
+
|
930
|
+
Returns:
|
931
|
+
bool: Whether cancellation was successful
|
932
|
+
"""
|
933
|
+
# 取消所有相关的邮件检查任务
|
934
|
+
for request_id in self._get_conversation_requests(conversation_id):
|
935
|
+
request_key = (conversation_id, request_id)
|
936
|
+
if request_key in self._mail_check_tasks:
|
937
|
+
self._mail_check_tasks[request_key].cancel()
|
938
|
+
del self._mail_check_tasks[request_key]
|
939
|
+
|
940
|
+
# 调用父类方法取消对话
|
941
|
+
return await super().cancel_conversation(conversation_id)
|
942
|
+
|
943
|
+
def _extract_user_reply_content(self, body: str) -> str:
|
944
|
+
"""Extract the actual reply content from the email, excluding quoted original email content
|
945
|
+
|
946
|
+
Args:
|
947
|
+
body: Email body text
|
948
|
+
|
949
|
+
Returns:
|
950
|
+
str: User's actual reply content
|
951
|
+
"""
|
952
|
+
# 首先尝试使用标记提取内容
|
953
|
+
start_marker = self.templates['content_start_mark']
|
954
|
+
end_marker = self.templates['content_end_mark']
|
955
|
+
|
956
|
+
if start_marker in body and end_marker in body:
|
957
|
+
start_index = body.find(start_marker) + len(start_marker)
|
958
|
+
end_index = body.find(end_marker)
|
959
|
+
if start_index < end_index:
|
960
|
+
return body[start_index:end_index].strip()
|
961
|
+
|
962
|
+
# 常见的邮件回复分隔符模式
|
963
|
+
reply_patterns = [
|
964
|
+
# 常见的邮件客户端引用标记
|
965
|
+
r"On .* wrote:", # Gmail, Apple Mail 等
|
966
|
+
r"From: .*\n?Sent: .*\n?To: .*", # Outlook
|
967
|
+
r"-{3,}Original Message-{3,}", # 一些邮件客户端
|
968
|
+
r"From: [\w\s<>@.]+(\[mailto:[\w@.]+\])?\s+Sent:", # Outlook 变体
|
969
|
+
r"在.*写道:", # 中文邮件客户端
|
970
|
+
r"发件人: .*\n?时间: .*\n?收件人: .*", # 中文 Outlook
|
971
|
+
r">{3,}", # 多级引用
|
972
|
+
r"_{5,}", # 分隔线
|
973
|
+
r"\*{5,}", # 分隔线
|
974
|
+
r"={5,}", # 分隔线
|
975
|
+
r"原始邮件", # 中文原始邮件
|
976
|
+
r"请按以下格式回复:", # 我们自己的指导语
|
977
|
+
r"请直接回复您的内容。如需结束对话,请在回复中包含", # 我们自己的指导语
|
978
|
+
]
|
979
|
+
|
980
|
+
# 合并所有模式
|
981
|
+
combined_pattern = "|".join(reply_patterns)
|
982
|
+
|
983
|
+
# 尝试根据分隔符分割邮件内容
|
984
|
+
parts = re.split(combined_pattern, body, flags=re.IGNORECASE)
|
985
|
+
|
986
|
+
if parts and len(parts) > 1:
|
987
|
+
# 第一部分通常是用户的回复
|
988
|
+
user_content = parts[0].strip()
|
989
|
+
|
990
|
+
# 如果没有找到明确的分隔符,尝试基于行分析
|
991
|
+
lines = body.split('\n')
|
992
|
+
user_content_lines = []
|
993
|
+
|
994
|
+
for line in lines:
|
995
|
+
# 跳过引用行(以 > 开头的行)
|
996
|
+
if line.strip().startswith('>'):
|
997
|
+
continue
|
998
|
+
# 如果遇到可能的分隔符,停止收集
|
999
|
+
if any(re.search(pattern, line, re.IGNORECASE) for pattern in reply_patterns):
|
1000
|
+
break
|
1001
|
+
user_content_lines.append(line)
|
1002
|
+
|
1003
|
+
user_content = '\n'.join(user_content_lines).strip()
|
1004
|
+
|
1005
|
+
# 清理用户内容中的多余换行符和空白
|
1006
|
+
# 1. 将所有 \r\n 替换为 \n
|
1007
|
+
user_content = user_content.replace('\r\n', '\n')
|
1008
|
+
|
1009
|
+
# 2. 将连续的多个换行符替换为最多两个换行符
|
1010
|
+
user_content = re.sub(r'\n{3,}', '\n\n', user_content)
|
1011
|
+
|
1012
|
+
# 3. 去除开头和结尾的空白行
|
1013
|
+
user_content = user_content.strip()
|
1014
|
+
|
1015
|
+
# 4. 如果内容为空,返回一个友好的提示
|
1016
|
+
if not user_content or user_content.isspace():
|
1017
|
+
return "User didn't provide valid reply content"
|
1018
|
+
|
1019
|
+
return user_content
|