Files
InsightRadar/backend/app/prompts/digest_email_template.py
stardrophere f4d9b2075c 改名
2026-04-02 01:25:30 +08:00

265 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 推送邮件 HTML 模板
# 用于生成定时推送给用户的热点摘要邮件
# 邮件客户端不支持 Font Awesome,改用 Emoji 代替平台图标
PLATFORM_EMOJI: dict[str, str] = {
"微博热搜": "🔴",
"微博": "🔴",
"知乎热榜": "🔵",
"知乎": "🔵",
"百度热搜": "🔍",
"今日头条": "📰",
"抖音热榜": "🎵",
"抖音": "🎵",
"bilibili 热搜": "📺",
"B站热搜": "📺",
"华尔街见闻": "📈",
"澎湃新闻": "🌊",
"财联社热门": "💰",
"凤凰网": "🦅",
"贴吧": "💬",
}
DIGEST_HTML_TEMPLATE = """\
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body{{margin:0;padding:0;background:#0d1117;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;}}
.container{{max-width:640px;margin:0 auto;padding:32px 16px;}}
.header{{text-align:center;padding:10px 0 30px;margin-bottom:24px;border-bottom:1px solid rgba(255,255,255,0.06);}}
.header h1{{font-size:26px;font-weight:800;margin:0 0 10px;color:#ffffff;text-shadow:0 0 16px rgba(139,92,246,0.5);letter-spacing:0.5px;}}
.header p{{font-size:14px;color:#8b949e;margin:0;}}
.mode-badge{{display:inline-block;margin-top:12px;padding:4px 14px;border-radius:20px;font-size:12px;font-weight:600;letter-spacing:0.5px;}}
.mode-default{{background:rgba(59,130,246,0.15);color:#7dd3fc;border:1px solid rgba(59,130,246,0.3);}}
.mode-keyword{{background:rgba(168,85,247,0.15);color:#e879f9;border:1px solid rgba(168,85,247,0.3);}}
.event-card{{background:#161b22;border:1px solid #30363d;border-radius:16px;padding:20px;margin-bottom:20px;box-shadow:0 4px 12px rgba(0,0,0,0.2);}}
.event-card.is-hot{{border-left:4px solid #f85149;background:linear-gradient(90deg, rgba(248,81,73,0.03) 0%, transparent 100%), #161b22;}}
.event-title{{font-size:18px;font-weight:700;margin:0 0 14px;color:#ffffff;line-height:1.5;}}
.event-meta{{margin-bottom:12px;}}
.badge{{display:inline-block;padding:3px 10px;border-radius:6px;font-size:12px;font-weight:600;margin-right:6px;margin-bottom:6px;}}
.badge-hot{{background:rgba(248,81,73,0.15);color:#ff7b72;border:1px solid rgba(248,81,73,0.3);}}
.badge-warm{{background:rgba(210,153,34,0.15);color:#d29922;border:1px solid rgba(210,153,34,0.3);}}
.badge-normal{{background:rgba(56,139,253,0.15);color:#58a6ff;border:1px solid rgba(56,139,253,0.3);}}
.badge-tag{{background:rgba(139,148,158,0.15);color:#8b949e;border:1px solid rgba(139,148,158,0.2);}}
.summary{{font-size:14px;line-height:1.6;color:#c9d1d9;padding:12px 16px;background:rgba(139,92,246,0.06);border-radius:0 8px 8px 0;border-left:3px solid #a78bfa;margin-bottom:16px;}}
.summary strong{{color:#a78bfa;font-weight:600;}}
.platforms-list{{margin:0;padding:0;list-style:none;background:rgba(255,255,255,0.02);border-radius:10px;padding:12px;}}
.platform-item{{padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.05);}}
.platform-item:last-child{{border-bottom:none;padding-bottom:0;}}
.platform-item:first-child{{padding-top:0;}}
.platform-source{{font-size:12px;color:#8b949e;margin-bottom:4px;display:flex;align-items:center;}}
.platform-rank{{display:inline-block;padding:2px 6px;border-radius:4px;background:rgba(210,153,34,0.15);color:#d29922;font-size:10px;font-weight:700;margin-left:6px;}}
.platform-link{{font-size:14px;color:#79c0ff;text-decoration:none;line-height:1.5;display:block;transition:color 0.2s;}}
.platform-link:hover{{text-decoration:underline;color:#a5d6ff;}}
.platform-text{{font-size:14px;color:#e6edf3;line-height:1.5;}}
/* 匹配信息底部栏 */
.match-info{{font-size:12px;color:#8b949e;margin-top:16px;padding-top:12px;border-top:1px dashed #30363d;}}
.hit{{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;margin-right:4px;margin-top:4px;}}
.hit-exact{{background:rgba(46,160,67,0.15);color:#3fb950;}}
.hit-semantic{{background:rgba(163,113,247,0.15);color:#d2a8ff;}}
/* 页脚 */
.footer{{text-align:center;padding:30px 0 10px;margin-top:20px;font-size:12px;color:#484f58;}}
.footer a{{color:#79c0ff;text-decoration:none;}}
.footer a:hover{{text-decoration:underline;}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>聚势智见 · 热点快报</h1>
<p>{delivery_time} · 为你精选了 {event_count} 条事件</p>
<span class="mode-badge {mode_badge_class}">{mode_label}</span>
</div>
{event_cards_html}
<div class="footer">
<p>此邮件由 聚势智见自动推送。</p>
<p>如需调整推送设置,请登录 <a href="{app_url}">聚势智见 控制台</a></p>
</div>
</div>
</body>
</html>
"""
EVENT_CARD_TEMPLATE = """\
<div class="event-card{hot_class}">
<div class="event-meta">
<span class="badge {badge_class}">{hot_label} {hot_score}</span>
{tags_html}
</div>
<div class="event-title">{title}</div>
{summary_html}
{platforms_html}
{match_html}
</div>
"""
def _hot_level(score: int) -> tuple[str, str, str]:
"""返回 (label, badge_class, hot_class)"""
if score >= 10:
return "全网沸腾", "badge-hot", " is-hot"
if score >= 5:
return "高度关注", "badge-warm", ""
if score >= 3:
return "上升中", "badge-normal", ""
return "一般关注", "badge-tag", ""
def _get_event_summary(ev) -> str:
"""
兼容 ORM 字段名(ai_comprehensive_summary)和 schema 字段名(summary)。
"""
return (
getattr(ev, "summary", None)
or getattr(ev, "ai_comprehensive_summary", None)
or ""
)
def _build_platforms_html(platform_list: list[dict]) -> str:
"""
将平台数据列表渲染为 HTML。
每条包含:emoji 图标 + 来源名 + 排名徽章 + 可点击标题链接。
"""
if not platform_list:
return ""
rows = []
seen_sources: set[str] = set()
for p in platform_list[:8]:
source_name = p.get("source_name", "未知")
# 同一来源只显示第一条(通常是排名最靠前的那条)
if source_name in seen_sources:
continue
seen_sources.add(source_name)
headline = p.get("headline", "")
url = p.get("url", "")
ranking = p.get("ranking")
emoji = PLATFORM_EMOJI.get(source_name, "🔗")
rank_html = ""
if ranking:
rank_html = f'<span class="platform-rank">TOP {ranking}</span>'
if url:
title_html = (
f'<a href="{url}" class="platform-link">{headline}</a>'
)
else:
title_html = f'<span class="platform-text">{headline}</span>'
rows.append(
f'<li class="platform-item">'
f'<div class="platform-source">{emoji} {source_name}{rank_html}</div>'
f'{title_html}'
f'</li>'
)
if not rows:
return ""
return '<div class="platforms-list-wrapper"><ul class="platforms-list">' + "".join(rows) + "</ul></div>"
def build_digest_html(
items: list,
delivery_time_str: str,
platforms_map: dict[int, list[dict]] | None = None,
app_url: str = "http://localhost:5173",
is_default_push: bool = False,
) -> str:
"""
根据事件列表生成推送邮件 HTML 正文。
items 元素可以是 MatchedEventResult 或 _DefaultEventItem
二者均有 .event / .tags / .exact_hits / .semantic_hits / .match_score 属性。
platforms_map: event_id → [{source_name, headline, url, ranking}]
"""
if platforms_map is None:
platforms_map = {}
mode_label = "全网热点推送" if is_default_push else "个性化关键词匹配"
mode_badge_class = "mode-default" if is_default_push else "mode-keyword"
cards = []
for item in items:
ev = item.event
hot_label, badge_class, hot_class = _hot_level(ev.hot_score)
tags_html = "".join(
f'<span class="badge badge-tag">{t}</span>'
for t in item.tags[:4]
)
summary_text = _get_event_summary(ev)
summary_html = ""
if summary_text:
summary_html = (
f'<div class="summary"><strong>AI 洞察:</strong>{summary_text}</div>'
)
platform_list = platforms_map.get(ev.id, [])
platforms_html = _build_platforms_html(platform_list)
match_parts = []
# 仅个性化模式才显示匹配信息
if not getattr(item, "is_default", False):
for h in item.exact_hits[:3]:
match_parts.append(f'<span class="hit hit-exact">精确 {h}</span>')
for s in item.semantic_hits[:2]:
sim_pct = int(s.get("similarity", 0) * 100)
match_parts.append(
f'<span class="hit hit-semantic">语义 {s.get("topic_keyword", "")} {sim_pct}%</span>'
)
match_html = ""
if match_parts:
match_html = (
f'<div class="match-info">匹配度 {item.match_score:.0f} · '
+ " ".join(match_parts)
+ "</div>"
)
cards.append(
EVENT_CARD_TEMPLATE.format(
hot_class=hot_class,
badge_class=badge_class,
hot_label=hot_label,
hot_score=ev.hot_score,
tags_html=tags_html,
title=ev.unified_title,
summary_html=summary_html,
platforms_html=platforms_html,
match_html=match_html,
)
)
return DIGEST_HTML_TEMPLATE.format(
delivery_time=delivery_time_str,
event_count=len(items),
event_cards_html="\n".join(cards),
app_url=app_url,
mode_label=mode_label,
mode_badge_class=mode_badge_class,
)