fix(tg_bot): 推广邮件键盘渲染退订链接 + 强化提示词提取优先级

- _summary_keyboard 推广分支忽略 links 参数,AI 提取到退订链接也不显示
  现改为退订链接排第一行(🔕),保留告诉我更多/查看原文按钮
- CALLBACK_PROMO_DETAIL 展开详情键盘同样带上退订链接
- 抽取 _is_unsub_link/_link_buttons 辅助函数,退订链接(含 URL 关键词变体)排最前
- SYSTEM_PROMPT: 退订提取规则提升为最高优先级,推广邮件必须输出退订链接
  且占 links 第一位,其余链接最多 2 条,不得挤掉退订链接
- 新增 tests/test_unsub_keyboard.py 7 个用例全部通过
This commit is contained in:
2026-09-03 23:03:55 +08:00
parent c28825ec87
commit 8dc85faf19
4 files changed
+143 -17

No files matched your search

+1
View File
@@ -3,3 +3,4 @@ __pycache__/
*.pyc
config.json
data/
*_TODO.md
+7 -2
View File
@@ -29,8 +29,13 @@ SYSTEM_PROMPT = """你是一个邮件摘要助手。请分析邮件内容并以
- 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。如果邮件底部同时有多个退订相关链接,只提取最主要的那个
- links: 提取用户需要点击的链接。其中退订/取消订阅链接按下方「退订链接提取规则」强制执行,其余仅提取邮件明确要求用户点击才能完成某操作的链接(如确认邮箱、重置密码、查看账单、审批请求、查看通知详情等)。不要提取推广性质的链接(如下载App、注册领优惠、查看更多商品等营销链接)、追踪像素、邮件签名中的社交链接。每条包含简短描述文字和完整 URL。最多 3 条。没有符合条件的链接时为空数组
- 退订链接提取规则(最高优先级,仅适用于 category 为 "promotion" 的邮件):
* 推广邮件必须在 links 中输出退订链接,且放在 links 数组的第一位;只有邮件正文确实不存在任何退订入口时,links 才可以为空
* 退订链接通常位于邮件最底部/页脚区域,表现形式多样:文字为 unsubscribe、Unsubscribe、退订、取消订阅、点击这里退订、manage preferences、email preferences、update preferences、subscription settings、opt out 等;也可能是图片链接,或包裹在 HTML 标签中的 <a href="...">,必须提取 href 属性里的完整 URL
* 同一封邮件出现多个退订相关链接时,只提取最主要的那个(优先取正文最后出现的完整 unsubscribe 链接)
* 退订链接固定占用 1 个位置,其余链接最多 2 条,不得挤掉退订链接
* 严禁编造退订 URL,确实找不到时 links 输出空数组
- plain_text: 将邮件全文转换为可读的纯文本版本。去除所有HTML标签、CSS样式、脚本代码,保留完整的文字内容和原始段落结构。不删减、不修改、不总结任何内容,完整保留原文所有信息
- summary 要简洁,把需要知道的信息浓缩成一句话,不需要分条列出
- 当 category 为 "promotion" 时,summary 直接写成一句话:「[发送方名称] 在推广 [推广的产品/服务/活动]」,例如「腾讯云在推广双十一云服务器折扣」。此时 can_reply 为 false
+49 -15
View File
@@ -191,12 +191,50 @@ CALLBACK_CANCEL = "c"
CALLBACK_PROMO_DETAIL = "pd"
def _summary_keyboard(can_reply: bool = True, is_promotion: bool = False, links: list = None) -> dict:
# ── Link buttons ────────────────────────────────────────
_UNSUB_KEYWORDS = (
"unsubscribe", "opt out", "unsub", "退订", "取消订阅",
"manage preferences", "email preferences", "subscription settings",
"subscription preferences", "preferences", "email-pref", "manage-pref",
"偏好设置", "管理偏好", "订阅设置",
)
def _is_unsub_link(link: dict) -> bool:
"""判断链接是否为退订/取消订阅类链接(按文字或 URL 关键词)"""
text = (link.get("text") or "").lower()
url = (link.get("url") or "").lower()
return any(k in text or k in url for k in _UNSUB_KEYWORDS)
def _link_buttons(links: list | None) -> list:
"""把 AI 提取的链接转成键盘按钮行;退订链接排最前并用 🔕 标识,其余用 🔗"""
if not links:
return []
unsub_links = [l for l in links if _is_unsub_link(l)]
other_links = [l for l in links if not _is_unsub_link(l)]
rows = []
for link in unsub_links + other_links:
url = link.get("url", "")
# 校验 URL:合法 http/https,无多重协议头,长度合理
if not (url.startswith("http") and url.count("://") == 1 and len(url) < 200):
continue
text = (link.get("text") or "").strip()[:30]
if not text:
text = "退订" if _is_unsub_link(link) else "打开链接"
icon = "🔕" if _is_unsub_link(link) else "🔗"
rows.append([{"text": f"{icon} {text}", "url": url}])
return rows
def _summary_keyboard(can_reply: bool = True, is_promotion: bool = False, links: list | None = None) -> dict:
if is_promotion:
return {"inline_keyboard": [
[{"text": "告诉我更多", "callback_data": CALLBACK_PROMO_DETAIL}],
[{"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG}],
]}
kb = []
kb.extend(_link_buttons(links))
kb.append([{"text": "告诉我更多", "callback_data": CALLBACK_PROMO_DETAIL}])
kb.append([{"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG}])
return {"inline_keyboard": kb}
kb = [
[
{"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG},
@@ -208,12 +246,7 @@ def _summary_keyboard(can_reply: bool = True, is_promotion: bool = False, links:
{"text": "AI完整回复", "callback_data": CALLBACK_AI_REPLY_FULL},
{"text": "AI简洁回复", "callback_data": CALLBACK_AI_REPLY_SHORT},
])
for link in (links or []):
url = link.get("url", "")
text = link.get("text", "打开链接")[:30]
# 校验 URL:合法 http/https,无多重协议头,长度合理
if url.startswith("http") and url.count("://") == 1 and len(url) < 200:
kb.append([{"text": f"🔗 {text}", "url": url}])
kb.extend(_link_buttons(links))
return {"inline_keyboard": kb}
@@ -551,13 +584,14 @@ def _handle_callback(bot_token: str, cfg: Config, cb: dict):
f"\n{_escape(summary)}\n"
f"\n*📋 详情:*\n{detail_lines}"
)
promo_kb = []
promo_kb.extend(_link_buttons(ctx.get("summary_data", {}).get("links", [])))
promo_kb.append([{"text": "收起", "callback_data": CALLBACK_VIEW_SUMM}])
promo_kb.append([{"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG}])
_tg_req(bot_token, "editMessageText", {
"chat_id": chat_id, "message_id": msg_id,
"text": expanded, "parse_mode": "MarkdownV2",
"reply_markup": {"inline_keyboard": [
[{"text": "收起", "callback_data": CALLBACK_VIEW_SUMM}],
[{"text": "查看原文", "callback_data": CALLBACK_VIEW_ORIG}],
]},
"reply_markup": {"inline_keyboard": promo_kb},
})
logger.info(" 显示推广详情")
+86
View File
@@ -0,0 +1,86 @@
"""测试摘要键盘渲染:推广邮件的退订链接按钮必须显示且排最前。"""
from src.tg_bot import _is_unsub_link, _link_buttons, _summary_keyboard
def _rows_of(kb: dict) -> list[list[dict]]:
return kb["inline_keyboard"]
def test_promotion_with_unsub_link_shows_button_first():
"""推广邮件:AI 提取到退订链接时必须显示,且排在第一行"""
links = [
{"text": "Manage Preferences", "url": "https://e.example.com/pref"},
{"text": "推广活动详情", "url": "https://e.example.com/deal"},
]
kb = _summary_keyboard(can_reply=False, is_promotion=True, links=links)
rows = _rows_of(kb)
first = rows[0][0]
assert first["url"] == "https://e.example.com/pref"
assert "🔕" in first["text"]
# 其余按钮仍然存在
texts = [b["text"] for r in rows for b in r]
assert "告诉我更多" in texts
assert "查看原文" in texts
def test_promotion_without_links_keeps_default_buttons():
"""推广邮件:没有链接时保持原有按钮"""
kb = _summary_keyboard(can_reply=False, is_promotion=True, links=None)
rows = _rows_of(kb)
assert len(rows) == 2
assert rows[0][0]["text"] == "告诉我更多"
assert rows[1][0]["text"] == "查看原文"
def test_promotion_with_only_non_unsub_link():
"""推广邮件:只有非退订链接时也正常渲染(🔗),不冒充退订按钮"""
links = [{"text": "查看活动", "url": "https://e.example.com/event"}]
kb = _summary_keyboard(can_reply=False, is_promotion=True, links=links)
rows = _rows_of(kb)
assert rows[0][0]["text"] == "🔗 查看活动"
assert rows[1][0]["text"] == "告诉我更多"
assert rows[2][0]["text"] == "查看原文"
def test_normal_email_links_still_rendered():
"""普通邮件:链接按钮行为不变"""
links = [{"text": "查看账单", "url": "https://e.example.com/bill"}]
kb = _summary_keyboard(can_reply=True, is_promotion=False, links=links)
rows = _rows_of(kb)
texts = [b["text"] for r in rows for b in r]
assert "🔗 查看账单" in texts
def test_unsub_detection_variants():
"""退订链接识别:文字与 URL 关键词变体"""
assert _is_unsub_link({"text": "Unsubscribe", "url": "https://x.com"})
assert _is_unsub_link({"text": "退订", "url": "https://x.com"})
assert _is_unsub_link({"text": "点击这里取消订阅", "url": "https://x.com"})
assert _is_unsub_link({"text": "管理偏好设置", "url": "https://x.com"})
assert _is_unsub_link({"text": "更多", "url": "https://x.com/unsubscribe"})
assert _is_unsub_link({"text": "Preferences", "url": "https://x.com/email-pref"})
assert not _is_unsub_link({"text": "查看详情", "url": "https://x.com/deal"})
def test_unsub_link_sorted_before_other_links():
"""退订链接即使不在 links 第一位也会被排到最前"""
links = [
{"text": "查看详情", "url": "https://x.com/a"},
{"text": "unsubscribe", "url": "https://x.com/b"},
]
rows = _link_buttons(links)
assert rows[0][0]["url"] == "https://x.com/b"
assert rows[1][0]["url"] == "https://x.com/a"
def test_invalid_url_filtered():
"""非法 URL(非 http、多重协议头、超长)被过滤"""
links = [
{"text": "退订", "url": "javascript:alert(1)"},
{"text": "退订", "url": "https://x.com/https://evil.com"},
{"text": "退订", "url": "https://ok.example.com/unsub"},
]
rows = _link_buttons(links)
assert len(rows) == 1
assert rows[0][0]["url"] == "https://ok.example.com/unsub"