优化斗鱼弹幕总结:新增粉丝向弹幕萃取区块并调整提示词语气
This commit is contained in:
@@ -462,7 +462,8 @@ class DouyuRedisManager:
|
|||||||
|
|
||||||
|
|
||||||
class DouyuPlugin(MessagePluginInterface):
|
class DouyuPlugin(MessagePluginInterface):
|
||||||
_DAILY_REPORT_CACHE_VERSION = 3
|
# 报告结构有新增(粉丝向弹幕萃取区块),提升缓存版本以触发重新生成。
|
||||||
|
_DAILY_REPORT_CACHE_VERSION = 4
|
||||||
FEATURE_KEY = "DOUYU_MONITOR"
|
FEATURE_KEY = "DOUYU_MONITOR"
|
||||||
FEATURE_DESCRIPTION = "🎮 斗鱼开播提醒 [订阅斗鱼 房间号, 取消订阅斗鱼 房间号]"
|
FEATURE_DESCRIPTION = "🎮 斗鱼开播提醒 [订阅斗鱼 房间号, 取消订阅斗鱼 房间号]"
|
||||||
|
|
||||||
@@ -1538,14 +1539,70 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
"请输出一段适合放在日报图片上半部分的弹幕总结,要求:\n"
|
"请输出一段适合放在日报图片上半部分的弹幕总结,要求:\n"
|
||||||
"1. 先用 1 段总述直播氛围与主线。\n"
|
"1. 先用 1 段总述直播氛围与主线。\n"
|
||||||
"2. 再用 5 条要点总结观众关注点、情绪变化、反复出现的梗、节奏变化和额外反馈,每条只写一句。\n"
|
"2. 再用 5 条要点总结观众关注点、情绪变化、反复出现的梗、节奏变化和额外反馈,每条只写一句。\n"
|
||||||
"3. 语言像运营复盘,简洁自然。\n"
|
"3. 另起一行固定写标题:`【粉丝向弹幕萃取】`。\n"
|
||||||
"4. 不要写标题,不要写“根据数据”。\n\n"
|
"4. 在该标题下输出 4-6 条短句,尽量保留弹幕原话风格(可以保留口头语、玩梗、情绪词)。\n"
|
||||||
|
"5. 整体语气要像“直播间现场记录”,不要写成运营复盘。\n"
|
||||||
|
"6. 不要写“根据数据”“建议”“策略”等词。\n\n"
|
||||||
f"主播:{meta.get('nickname') or meta.get('room_name') or meta.get('room_id')}\n"
|
f"主播:{meta.get('nickname') or meta.get('room_name') or meta.get('room_id')}\n"
|
||||||
f"日期:{meta.get('anchor_day', '')}\n"
|
f"日期:{meta.get('anchor_day', '')}\n"
|
||||||
f"材料:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
|
f"材料:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
|
||||||
)
|
)
|
||||||
return system_prompt, user_prompt
|
return system_prompt, user_prompt
|
||||||
|
|
||||||
|
def _build_fans_extract_lines(self, payload: Dict[str, Any], limit: int = 6) -> List[str]:
|
||||||
|
# 粉丝向萃取强调“可读、像现场弹幕”,优先取代表发言,再补充重复梗与情绪短词。
|
||||||
|
representative_messages = payload.get("representative_messages", []) or []
|
||||||
|
repeated_messages = payload.get("repeated_messages", []) or []
|
||||||
|
merged_templates = payload.get("merged_templates", []) or []
|
||||||
|
burst_terms = payload.get("burst_terms", []) or []
|
||||||
|
|
||||||
|
lines: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
def push(text: str) -> None:
|
||||||
|
value = str(text or "").strip()
|
||||||
|
if not value:
|
||||||
|
return
|
||||||
|
key = value.lower()
|
||||||
|
if key in seen:
|
||||||
|
return
|
||||||
|
seen.add(key)
|
||||||
|
lines.append(value)
|
||||||
|
|
||||||
|
for item in representative_messages[:10]:
|
||||||
|
nickname = str(item.get("nickname") or "").strip() or "观众"
|
||||||
|
content = str(item.get("content") or "").strip()
|
||||||
|
if content:
|
||||||
|
push(f"{nickname}:{content[:56]}")
|
||||||
|
if len(lines) >= limit:
|
||||||
|
return lines[:limit]
|
||||||
|
|
||||||
|
for item in repeated_messages[:6]:
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
count = int(item.get("count", 0) or 0)
|
||||||
|
if text:
|
||||||
|
push(f"复读梗「{text[:36]}」刷了 {count} 次。")
|
||||||
|
if len(lines) >= limit:
|
||||||
|
return lines[:limit]
|
||||||
|
|
||||||
|
for item in merged_templates[:6]:
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
count = int(item.get("count", 0) or 0)
|
||||||
|
if text:
|
||||||
|
push(f"共识弹幕「{text[:36]}」出现 {count} 次。")
|
||||||
|
if len(lines) >= limit:
|
||||||
|
return lines[:limit]
|
||||||
|
|
||||||
|
for item in burst_terms[:4]:
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
count = int(item.get("count", 0) or 0)
|
||||||
|
if text:
|
||||||
|
push(f"情绪短词「{text}」集中出现 {count} 次。")
|
||||||
|
if len(lines) >= limit:
|
||||||
|
return lines[:limit]
|
||||||
|
|
||||||
|
return lines[:limit]
|
||||||
|
|
||||||
def _build_fallback_daily_report(self, payload: Dict[str, Any]) -> str:
|
def _build_fallback_daily_report(self, payload: Dict[str, Any]) -> str:
|
||||||
meta = payload.get("report_meta", {}) or {}
|
meta = payload.get("report_meta", {}) or {}
|
||||||
title_name = str(meta.get("nickname") or meta.get("room_name") or meta.get("room_id") or "主播")
|
title_name = str(meta.get("nickname") or meta.get("room_name") or meta.get("room_id") or "主播")
|
||||||
@@ -1671,6 +1728,12 @@ class DouyuPlugin(MessagePluginInterface):
|
|||||||
lines.append("- 情绪特点:代表性发言里既有对操作和决策的即时反馈,也有大量玩梗、调侃和情绪宣泄。")
|
lines.append("- 情绪特点:代表性发言里既有对操作和决策的即时反馈,也有大量玩梗、调侃和情绪宣泄。")
|
||||||
if top_terms:
|
if top_terms:
|
||||||
lines.append(f"- 关注焦点:高频词主要落在 {'、'.join(top_terms[:6])},说明观众注意力相对集中。")
|
lines.append(f"- 关注焦点:高频词主要落在 {'、'.join(top_terms[:6])},说明观众注意力相对集中。")
|
||||||
|
# 在兜底模式下也强制补出“粉丝向弹幕萃取”,避免图片模板出现空区块。
|
||||||
|
fans_extract_lines = self._build_fans_extract_lines(payload, limit=6)
|
||||||
|
if fans_extract_lines:
|
||||||
|
lines.append("【粉丝向弹幕萃取】")
|
||||||
|
for item in fans_extract_lines:
|
||||||
|
lines.append(f"- {item}")
|
||||||
return "\n".join(lines).strip()
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
def _build_operator_summary_text(self, payload: Dict[str, Any]) -> str:
|
def _build_operator_summary_text(self, payload: Dict[str, Any]) -> str:
|
||||||
|
|||||||
@@ -24,19 +24,37 @@ def _render_list(items: List[str], item_class: str = "bullet-list") -> str:
|
|||||||
return f'<ul class="{item_class}">{lis}</ul>' if lis else ""
|
return f'<ul class="{item_class}">{lis}</ul>' if lis else ""
|
||||||
|
|
||||||
|
|
||||||
def _split_summary_blocks(danmu_summary: str) -> tuple[str, List[str]]:
|
def _split_summary_blocks(danmu_summary: str) -> tuple[str, List[str], List[str]]:
|
||||||
|
# 这里把 LLM 返回的弹幕总结拆成三部分:
|
||||||
|
# 1) lead: 顶部总述段落
|
||||||
|
# 2) insight_items: 常规的复盘要点(运营/观察视角)
|
||||||
|
# 3) fans_extract_items: 专门给粉丝看的“弹幕萃取”要点
|
||||||
|
# 约定:当检测到“【粉丝向弹幕萃取】”或同义标记后,后续条目归入 fans_extract_items。
|
||||||
lead_parts = []
|
lead_parts = []
|
||||||
insight_items = []
|
insight_items = []
|
||||||
|
fans_extract_items = []
|
||||||
|
in_fans_extract_block = False
|
||||||
for line in str(danmu_summary or "").splitlines():
|
for line in str(danmu_summary or "").splitlines():
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if not stripped:
|
if not stripped:
|
||||||
continue
|
continue
|
||||||
|
# 兼容不同模型可能产出的标题样式,尽量把粉丝向内容稳定识别出来。
|
||||||
|
if stripped.startswith("【粉丝向弹幕萃取】") or stripped.startswith("粉丝向弹幕萃取") or stripped.startswith("给粉丝看的弹幕萃取"):
|
||||||
|
in_fans_extract_block = True
|
||||||
|
continue
|
||||||
if stripped.startswith("- "):
|
if stripped.startswith("- "):
|
||||||
|
if in_fans_extract_block:
|
||||||
|
fans_extract_items.append(stripped[2:].strip())
|
||||||
|
else:
|
||||||
insight_items.append(stripped[2:].strip())
|
insight_items.append(stripped[2:].strip())
|
||||||
|
else:
|
||||||
|
# 非 bullet 文本在粉丝区块中也保留,避免模型偶发输出短段落导致信息丢失。
|
||||||
|
if in_fans_extract_block:
|
||||||
|
fans_extract_items.append(stripped)
|
||||||
else:
|
else:
|
||||||
lead_parts.append(stripped)
|
lead_parts.append(stripped)
|
||||||
lead = " ".join(lead_parts).strip()
|
lead = " ".join(lead_parts).strip()
|
||||||
return lead, insight_items
|
return lead, insight_items, fans_extract_items
|
||||||
|
|
||||||
|
|
||||||
def _normalize_summary_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 5) -> List[str]:
|
def _normalize_summary_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 5) -> List[str]:
|
||||||
@@ -75,6 +93,47 @@ def _normalize_summary_bullets(payload: Dict[str, Any], items: List[str], target
|
|||||||
return normalized[:target_count]
|
return normalized[:target_count]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_fans_extract_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 6) -> List[str]:
|
||||||
|
# 粉丝向萃取强调“现场感”,优先保留模型给出的条目;
|
||||||
|
# 不足时再从代表弹幕/重复梗中补齐,避免页面出现空区块。
|
||||||
|
normalized = [str(item or "").strip() for item in items if str(item or "").strip()]
|
||||||
|
if len(normalized) >= target_count:
|
||||||
|
return normalized[:target_count]
|
||||||
|
|
||||||
|
supplements: List[str] = []
|
||||||
|
representative_messages = payload.get("representative_messages", []) or []
|
||||||
|
repeated_messages = payload.get("repeated_messages", []) or []
|
||||||
|
burst_terms = payload.get("burst_terms", []) or []
|
||||||
|
|
||||||
|
for item in representative_messages[:8]:
|
||||||
|
nickname = str(item.get("nickname") or "").strip() or "观众"
|
||||||
|
content = str(item.get("content") or "").strip()
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
supplements.append(f"{nickname}:{content[:46]}")
|
||||||
|
|
||||||
|
for item in repeated_messages[:6]:
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
count = int(item.get("count", 0) or 0)
|
||||||
|
if text:
|
||||||
|
supplements.append(f"复读梗「{text[:34]}」出现 {count} 次。")
|
||||||
|
|
||||||
|
for item in burst_terms[:4]:
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
count = int(item.get("count", 0) or 0)
|
||||||
|
if text:
|
||||||
|
supplements.append(f"情绪短词「{text}」集中刷了 {count} 次。")
|
||||||
|
|
||||||
|
existing = set(normalized)
|
||||||
|
for item in supplements:
|
||||||
|
if item not in existing:
|
||||||
|
normalized.append(item)
|
||||||
|
existing.add(item)
|
||||||
|
if len(normalized) >= target_count:
|
||||||
|
break
|
||||||
|
return normalized[:target_count]
|
||||||
|
|
||||||
|
|
||||||
def _build_template_items(payload: Dict[str, Any], limit: int = 8) -> List[str]:
|
def _build_template_items(payload: Dict[str, Any], limit: int = 8) -> List[str]:
|
||||||
items: List[str] = []
|
items: List[str] = []
|
||||||
seen = set()
|
seen = set()
|
||||||
@@ -400,8 +459,9 @@ def render_daily_report_html(
|
|||||||
top_active_users = payload.get("operator_metrics", {}).get("top_active_users", []) or []
|
top_active_users = payload.get("operator_metrics", {}).get("top_active_users", []) or []
|
||||||
audience_trend = payload.get("audience_trend", {}) or {}
|
audience_trend = payload.get("audience_trend", {}) or {}
|
||||||
|
|
||||||
lead_summary, danmu_bullets = _split_summary_blocks(danmu_summary)
|
lead_summary, danmu_bullets, fans_extract_bullets = _split_summary_blocks(danmu_summary)
|
||||||
danmu_bullets = _normalize_summary_bullets(payload, danmu_bullets, target_count=5)
|
danmu_bullets = _normalize_summary_bullets(payload, danmu_bullets, target_count=5)
|
||||||
|
fans_extract_bullets = _normalize_fans_extract_bullets(payload, fans_extract_bullets, target_count=6)
|
||||||
|
|
||||||
html_doc = f"""<html>
|
html_doc = f"""<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -589,6 +649,30 @@ def render_daily_report_html(
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}}
|
}}
|
||||||
|
.fans-panel {{
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 14px 15px 12px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(245,250,255,0.94));
|
||||||
|
border: 1px solid rgba(73, 136, 224, 0.18);
|
||||||
|
}}
|
||||||
|
.fans-title {{
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}}
|
||||||
|
.fans-list {{
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}}
|
||||||
|
.fans-list li {{
|
||||||
|
color: #1e3a5f;
|
||||||
|
margin: 8px 0;
|
||||||
|
line-height: 1.65;
|
||||||
|
font-size: 14px;
|
||||||
|
}}
|
||||||
.insight-card {{
|
.insight-card {{
|
||||||
padding: 15px 16px;
|
padding: 15px 16px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -946,6 +1030,10 @@ def render_daily_report_html(
|
|||||||
<div class="insight-grid">
|
<div class="insight-grid">
|
||||||
{_render_insight_cards(danmu_bullets)}
|
{_render_insight_cards(danmu_bullets)}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fans-panel">
|
||||||
|
<div class="fans-title">给粉丝看的弹幕萃取</div>
|
||||||
|
{_render_list(fans_extract_bullets, item_class="fans-list")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="aside-card">
|
<div class="aside-card">
|
||||||
<div class="aside-title">高频梗</div>
|
<div class="aside-title">高频梗</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user