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:
4 files changed
+143
-17
No files matched your search
@@ -3,3 +3,4 @@ __pycache__/
|
||||
*.pyc
|
||||
config.json
|
||||
data/
|
||||
*_TODO.md
|
||||
+7
-2
@@ -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
@@ -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(" 显示推广详情")
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user