171 lines
8.3 KiB
Python
171 lines
8.3 KiB
Python
import json
|
||
import logging
|
||
import time
|
||
from typing import Any
|
||
import requests
|
||
from src.config import AIConfig
|
||
from src.retry import retry
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
SYSTEM_PROMPT = """你是一个邮件摘要助手。请分析邮件内容并以 JSON 格式返回结构化摘要。所有输出(subject、summary 等)必须使用简体中文。
|
||
|
||
返回格式必须严格遵循以下 JSON schema:
|
||
{
|
||
"subject": "你概括的简短标题(10字以内)",
|
||
"recipient": "收件邮箱地址",
|
||
"sender": "发件人",
|
||
"category": "normal/promotion/notification",
|
||
"plain_text": "邮件全文的纯文本版本,去除HTML标签,保留所有文字内容,保持原始段落结构,不删减不修改任何内容",
|
||
"summary": "50字以内的核心摘要",
|
||
"verification_code": "",
|
||
"app_name": "",
|
||
"links": [{"text": "按钮文字", "url": "https://..."}],
|
||
"can_reply": true/false
|
||
}
|
||
|
||
其中:
|
||
- subject 不能照抄原邮件主题,必须是你总结生成的简短标题
|
||
- category: 邮件类型。推广邮件(营销、促销、广告、 newsletter 推广等)标记为 "promotion";验证码邮件标记为 "notification";普通邮件标记为 "normal"
|
||
- verification_code: 仅当 category 为 "notification" 且邮件包含验证码时填写,提取完整的验证码字符串(纯数字、字母数字混合等均可,如「123456」「abc-789」「701383」)。非验证码邮件留空字符串
|
||
- app_name: 仅当 category 为 "notification" 且邮件包含验证码时填写,填写发送验证码的应用/平台名称(如「GitHub」「微信」「Kraken」)。非验证码邮件留空字符串
|
||
- links: 仅提取以下两类链接:1)邮件明确要求用户点击才能完成某操作的链接(如确认邮箱、重置密码、查看账单、审批请求、查看通知详情等);2)退订/取消订阅链接。不要提取推广性质的链接(如下载App、注册领优惠、查看更多商品等营销链接)、追踪像素、邮件签名中的社交链接。每条包含简短描述文字和完整 URL。最多 3 条。没有符合条件的链接时为空数组
|
||
- 退订链接提取规则:推广邮件必须提取退订链接。退订链接通常在邮件最底部,包含以下关键词之一:unsubscribe、退订、取消订阅、opt out、manage preferences、email preferences。即使链接被包裹在 HTML 标签中,也要提取 href 属性里的完整 URL。如果邮件底部同时有多个退订相关链接,只提取最主要的那个
|
||
- plain_text: 将邮件全文转换为可读的纯文本版本。去除所有HTML标签、CSS样式、脚本代码,保留完整的文字内容和原始段落结构。不删减、不修改、不总结任何内容,完整保留原文所有信息
|
||
- summary 要简洁,把需要知道的信息浓缩成一句话,不需要分条列出
|
||
- 当 category 为 "promotion" 时,summary 直接写成一句话:「[发送方名称] 在推广 [推广的产品/服务/活动]」,例如「腾讯云在推广双十一云服务器折扣」。此时 can_reply 为 false
|
||
- can_reply 为 false 表示无需回复或不适合建议回复
|
||
只返回 JSON,不要包含任何其他文字。"""
|
||
|
||
|
||
@retry()
|
||
def summarize_email(ai_cfg: AIConfig, recipient: str, subject: str, sender: str, body: str, account: str = "") -> dict[str, Any]:
|
||
body_preview = body[:80].replace("\n", " ")
|
||
logger.info("AI 摘要请求: sender=%s subject=%s body_preview=%s", sender, subject, body_preview)
|
||
content = f"收件人: {recipient}\n发件人: {sender}\n主题: {subject}\n账号: {account}\n正文:\n{body}"
|
||
|
||
payload = {
|
||
"model": ai_cfg.model,
|
||
"messages": [
|
||
{"role": "system", "content": SYSTEM_PROMPT},
|
||
{"role": "user", "content": content},
|
||
],
|
||
"response_format": {"type": "json_object"},
|
||
}
|
||
|
||
return _call_deepseek(ai_cfg, payload)
|
||
|
||
|
||
PROMO_DETAIL_PROMPT = """你是一个邮件分析助手。用户想了解更多关于这封推广邮件的详情。
|
||
请根据邮件原文,输出 3-5 条关键信息,每条一句话,简洁明了。包括:优惠力度、适用条件、截止时间、核心卖点等用户做决策需要知道的信息。所有输出使用简体中文。
|
||
只返回一个 JSON: {"details": ["信息1", "信息2", ...]}
|
||
只返回 JSON,不要包含任何其他文字。"""
|
||
|
||
|
||
@retry()
|
||
def expand_promo_detail(ai_cfg: AIConfig, sender: str, subject: str, body: str) -> list[str]:
|
||
logger.info("AI 推广详情请求: sender=%s subject=%s", sender, subject)
|
||
content = f"发件人: {sender}\n主题: {subject}\n正文:\n{body}"
|
||
payload = {
|
||
"model": ai_cfg.model,
|
||
"messages": [
|
||
{"role": "system", "content": PROMO_DETAIL_PROMPT},
|
||
{"role": "user", "content": content},
|
||
],
|
||
"response_format": {"type": "json_object"},
|
||
}
|
||
result = _call_deepseek(ai_cfg, payload)
|
||
return result.get("details", [])
|
||
|
||
|
||
MORE_REPLIES_PROMPT_SHORT = """你是一个邮件回复助手。根据以下邮件内容生成3个完全不同的简洁回复。
|
||
|
||
规则:
|
||
- 简洁模式:直接说要点,不需要称呼、落款、邮件格式。像微信/短信一样简短。
|
||
- 必须使用与邮件相同的语言回复(英文邮件用英文,中文邮件用中文)。
|
||
- 不要添加任何占位符如 [Your Name]。
|
||
|
||
以下是一些已经生成过的回复,请生成全新的回复,不要与已有回复重复。
|
||
|
||
返回 JSON:
|
||
{
|
||
"suggestions": [
|
||
{"text": "回复内容"},
|
||
{"text": "回复内容"},
|
||
{"text": "回复内容"}
|
||
]
|
||
}
|
||
每条回复控制在 50 字以内。
|
||
只返回 JSON,不要包含任何其他文字。"""
|
||
|
||
MORE_REPLIES_PROMPT_FULL = """你是一个邮件回复助手。根据以下邮件内容生成3个不同的正式邮件回复。
|
||
|
||
规则:
|
||
- 完整模式:生成完整的邮件格式,包括称呼(如 Dear xxx,)和正文,结尾只写问候语逗号,不写落款,用户会自己编辑添加。
|
||
- 必须使用与邮件相同的语言回复(英文邮件用英文,中文邮件用中文)。
|
||
- 语气正式专业,适合商务邮件。
|
||
|
||
以下是一些已经生成过的回复,请生成全新的回复,不要与已有回复重复。
|
||
|
||
返回 JSON:
|
||
{
|
||
"suggestions": [
|
||
{"text": "回复内容"},
|
||
{"text": "回复内容"},
|
||
{"text": "回复内容"}
|
||
]
|
||
}
|
||
每条回复控制在 300 字以内。
|
||
只返回 JSON,不要包含任何其他文字。"""
|
||
|
||
|
||
@retry()
|
||
def generate_more_replies(ai_cfg: AIConfig, sender: str, subject: str, body: str,
|
||
previous_suggestions: list[dict], user_hint: str = "",
|
||
style: str = "short") -> list[dict]:
|
||
logger.info("AI 换批请求: sender=%s subject=%s user_hint=%s prev=%d 条 style=%s",
|
||
sender, subject, user_hint or "(无)", len(previous_suggestions), style)
|
||
content = f"发件人: {sender}\n主题: {subject}\n正文:\n{body}\n\n已生成过的回复:\n"
|
||
for i, s in enumerate(previous_suggestions, 1):
|
||
content += f"{i}. {s['text']}\n"
|
||
if user_hint:
|
||
content += f"\n用户要求: {user_hint}"
|
||
|
||
prompt = MORE_REPLIES_PROMPT_FULL if style == "full" else MORE_REPLIES_PROMPT_SHORT
|
||
payload = {
|
||
"model": ai_cfg.model,
|
||
"messages": [
|
||
{"role": "system", "content": prompt},
|
||
{"role": "user", "content": content},
|
||
],
|
||
"response_format": {"type": "json_object"},
|
||
}
|
||
result = _call_deepseek(ai_cfg, payload)
|
||
return result.get("suggestions", [])
|
||
|
||
|
||
def _call_deepseek(ai_cfg: AIConfig, payload: dict) -> dict:
|
||
model = payload.get("model", ai_cfg.model)
|
||
logger.info("DeepSeek API 请求: model=%s", model)
|
||
t0 = time.time()
|
||
headers = {
|
||
"Authorization": f"Bearer {ai_cfg.api_key}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
resp = requests.post(
|
||
f"{ai_cfg.base_url.rstrip('/')}/chat/completions",
|
||
headers=headers,
|
||
json=payload,
|
||
timeout=120,
|
||
)
|
||
elapsed = time.time() - t0
|
||
resp.raise_for_status()
|
||
result = resp.json()
|
||
usage = result.get("usage", {})
|
||
prompt_tokens = usage.get("prompt_tokens", "?")
|
||
completion_tokens = usage.get("completion_tokens", "?")
|
||
raw = result["choices"][0]["message"]["content"]
|
||
logger.info("DeepSeek API 响应: %.2fs, token=%d+%d, 输出长度=%d",
|
||
elapsed, prompt_tokens, completion_tokens, len(raw))
|
||
return json.loads(raw)
|