增强群运营分析2.0首版展示能力
变更项: 1. 在现有群详情接口中追加群画像摘要、成员分层、动作建议和最近群总结数据,保留原有健康度、趋势、排行与运营建议结构。 2. 为后台服务补充 message_summary 数据访问对象,复用现有群总结数据作为群运营分析输入。 3. 在通讯录管理的群详情面板中新增群画像摘要、成员分层和可执行动作建议卡片,保持旧页面内容不删除,仅做加法增强。
This commit is contained in:
@@ -4,11 +4,336 @@ from loguru import logger
|
||||
from utils.robot_cmd.robot_command import GroupBotManager, Feature, PermissionStatus
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from db.base import BaseDBOperator
|
||||
from plugins.ai_auto_response.memory.group_memory_profile import GroupMemoryService
|
||||
|
||||
# 创建群管理兼容蓝图(旧入口保留做跳转,实际功能已并入通讯录)
|
||||
robot_bp = Blueprint('robot', __name__, url_prefix='/robot')
|
||||
LOG = logger
|
||||
|
||||
|
||||
def _build_group_ops_profile(server, group_id: str, group_name: str, peak_hours: list, plugin_stats: list) -> dict:
|
||||
"""构建群运营 2.0 所需的群画像摘要。
|
||||
|
||||
设计说明:
|
||||
1. 这里优先复用现有群画像快照与消息总结能力,不重新发明一套分析链路;
|
||||
2. 若历史画像数据不足,也必须返回可展示的兜底结构,避免前端出现大片空白;
|
||||
3. 输出结构专门面向后台运营视图,强调“群是什么群、最近聊什么、适合怎么运营”。
|
||||
"""
|
||||
profile_service = GroupMemoryService(server.db_manager, {})
|
||||
profile = profile_service.build_group_memory_profile(group_id, group_name=group_name) or {}
|
||||
|
||||
style_profile = profile.get("style_profile", {}) or {}
|
||||
focus_topics = [str(item).strip() for item in (profile.get("focus_topics") or []) if str(item).strip()]
|
||||
structured_summary = profile.get("structured_summary", {}) or {}
|
||||
recent_key_points = [
|
||||
str(item).strip() for item in (structured_summary.get("recent_key_points") or []) if str(item).strip()
|
||||
][:4]
|
||||
unresolved_points = [
|
||||
str(item).strip() for item in (structured_summary.get("unresolved_points") or []) if str(item).strip()
|
||||
][:3]
|
||||
stable_topics = [
|
||||
str(item).strip() for item in (structured_summary.get("stable_topics") or []) if str(item).strip()
|
||||
][:4]
|
||||
|
||||
peak_hour_labels = [str(item.get("label") or "").strip() for item in peak_hours if str(item.get("label") or "").strip()]
|
||||
dominant_plugin_names = []
|
||||
for item in plugin_stats[:3]:
|
||||
plugin_name = str(item.get("plugin_name") or "").strip()
|
||||
if plugin_name and plugin_name not in dominant_plugin_names:
|
||||
dominant_plugin_names.append(plugin_name)
|
||||
|
||||
# 群定位摘要优先使用群画像服务的领域判断,再结合真实主题给出一段更像“运营结论”的描述。
|
||||
inferred_domain = str(profile.get("inferred_domain") or "general").strip()
|
||||
domain_label_map = {
|
||||
"openclaw": "工作流 / 编排讨论型群",
|
||||
"robotics": "机器人 / 自动化协作型群",
|
||||
"dota": "Dota / 游戏讨论型群",
|
||||
"tech": "技术排障 / 工具协作型群",
|
||||
"casual": "熟人闲聊 / 日常水群型群",
|
||||
"general": "综合交流型群",
|
||||
}
|
||||
group_identity = domain_label_map.get(inferred_domain, "综合交流型群")
|
||||
|
||||
profile_tags = [
|
||||
tag for tag in [
|
||||
group_identity,
|
||||
str(style_profile.get("interaction_tone") or "").strip(),
|
||||
str(style_profile.get("humor_style") or "").strip(),
|
||||
str(style_profile.get("expressiveness_style") or "").strip(),
|
||||
] if tag
|
||||
]
|
||||
|
||||
summary_lines = []
|
||||
if focus_topics:
|
||||
summary_lines.append(f"近期高频主题集中在:{'、'.join(focus_topics[:4])}")
|
||||
if peak_hour_labels:
|
||||
summary_lines.append(f"活跃时段主要落在:{'、'.join(peak_hour_labels[:2])}")
|
||||
if dominant_plugin_names:
|
||||
summary_lines.append(f"当前最容易被接受的机器人能力偏向:{'、'.join(dominant_plugin_names[:2])}")
|
||||
if not summary_lines:
|
||||
summary_lines.append("当前历史数据仍偏少,建议先继续积累群消息、总结与插件使用记录。")
|
||||
|
||||
return {
|
||||
"group_identity": group_identity,
|
||||
"profile_tags": profile_tags,
|
||||
"focus_topics": focus_topics[:6],
|
||||
"stable_topics": stable_topics,
|
||||
"recent_key_points": recent_key_points,
|
||||
"unresolved_points": unresolved_points,
|
||||
"summary_text": ";".join(summary_lines),
|
||||
"interaction_tone": str(style_profile.get("interaction_tone") or "").strip(),
|
||||
"humor_style": str(style_profile.get("humor_style") or "").strip(),
|
||||
"expressiveness_style": str(style_profile.get("expressiveness_style") or "").strip(),
|
||||
"peak_hours": peak_hour_labels[:3],
|
||||
"dominant_plugins": dominant_plugin_names[:3],
|
||||
"cache_status": str(profile.get("cache_status") or "").strip(),
|
||||
"last_generated_at": str(profile.get("last_generated_at") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _load_recent_group_summaries(server, group_id: str, limit: int = 3) -> list:
|
||||
"""读取最近几条群总结,供运营面板直接展示。
|
||||
|
||||
设计说明:
|
||||
1. 群画像适合给“结论”,但运营者往往还想看到最近几期总结到底说了什么;
|
||||
2. 这里不把整段长文原样吐给前端,而是裁成可扫读的短摘要,避免弹窗过重;
|
||||
3. 若没有总结记录,返回空列表,由前端优雅降级。
|
||||
"""
|
||||
summary_db = getattr(server, "message_summary_db", None)
|
||||
if summary_db is None:
|
||||
return []
|
||||
|
||||
rows = summary_db.execute_query(
|
||||
"""
|
||||
SELECT period_key, summary_type, source_message_count, summary_text, last_generated_at
|
||||
FROM t_message_summary
|
||||
WHERE chatroom_id = %s
|
||||
ORDER BY period_end DESC, update_time DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(group_id, int(limit)),
|
||||
) or []
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
raw_summary = str(row.get("summary_text") or "").strip()
|
||||
normalized_summary = " ".join(raw_summary.split())
|
||||
result.append({
|
||||
"period_key": str(row.get("period_key") or "").strip(),
|
||||
"summary_type": str(row.get("summary_type") or "").strip(),
|
||||
"source_message_count": int(row.get("source_message_count") or 0),
|
||||
"last_generated_at": str(row.get("last_generated_at") or "").strip(),
|
||||
"summary_excerpt": normalized_summary[:180] + ("..." if len(normalized_summary) > 180 else ""),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _load_value_rank_top_members(server, group_id: str, limit: int = 5) -> list:
|
||||
"""读取群内最近一期高价值成员快照。
|
||||
|
||||
说明:
|
||||
1. 该榜单直接复用已落库的身价快照,而不是运行时重新算分;
|
||||
2. 若群里尚未开启或尚未产出这类数据,则静默返回空列表;
|
||||
3. 返回结构会补齐成员展示名,避免前端出现一串 wxid。
|
||||
"""
|
||||
db = BaseDBOperator(server.db_manager)
|
||||
latest_row = db.execute_query(
|
||||
"""
|
||||
SELECT MAX(stat_date) AS latest_stat_date
|
||||
FROM t_value_rank_snapshot
|
||||
WHERE group_id = %s
|
||||
""",
|
||||
(group_id,),
|
||||
fetch_one=True,
|
||||
) or {}
|
||||
latest_stat_date = latest_row.get("latest_stat_date")
|
||||
if not latest_stat_date:
|
||||
return []
|
||||
|
||||
rows = db.execute_query(
|
||||
"""
|
||||
SELECT user_id, score, rank_no, title, points_total, msg_count_7d, active_days_30, inactive_days
|
||||
FROM t_value_rank_snapshot
|
||||
WHERE group_id = %s AND stat_date = %s
|
||||
ORDER BY rank_no ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
(group_id, latest_stat_date, int(limit)),
|
||||
) or []
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
user_id = str(row.get("user_id") or "").strip()
|
||||
result.append({
|
||||
"wxid": user_id,
|
||||
"display_name": server.contact_manager.get_group_name(group_id, user_id) or user_id,
|
||||
"score": float(row.get("score") or 0.0),
|
||||
"rank_no": int(row.get("rank_no") or 0),
|
||||
"title": str(row.get("title") or "").strip(),
|
||||
"points_total": int(row.get("points_total") or 0),
|
||||
"msg_count_7d": int(row.get("msg_count_7d") or 0),
|
||||
"active_days_30": int(row.get("active_days_30") or 0),
|
||||
"inactive_days": int(row.get("inactive_days") or 0),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _load_activation_candidates(server, group_id: str, limit: int = 6) -> list:
|
||||
"""识别“待激活成员”。
|
||||
|
||||
口径选择:
|
||||
1. 不直接拿最沉默的人,因为那类成员很多已经处于长期静默,激活收益未必最高;
|
||||
2. 这里优先找“7~30 天未发言,但仍不是完全流失”的边缘成员,更适合做召回动作;
|
||||
3. 这类成员通常对低门槛互动、固定时段提醒更敏感,运营价值更高。
|
||||
"""
|
||||
db = BaseDBOperator(server.db_manager)
|
||||
rows = db.execute_query(
|
||||
"""
|
||||
SELECT
|
||||
wxid,
|
||||
COALESCE(NULLIF(display_name, ''), nick_name, wxid) AS display_name,
|
||||
latest_active_time,
|
||||
TIMESTAMPDIFF(DAY, latest_active_time, NOW()) AS inactivity_days
|
||||
FROM t_chatroom_member
|
||||
WHERE chatroom_id = %s
|
||||
AND status = 1
|
||||
AND latest_active_time IS NOT NULL
|
||||
AND latest_active_time < DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
AND latest_active_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
ORDER BY latest_active_time DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(group_id, int(limit)),
|
||||
) or []
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
result.append({
|
||||
"wxid": str(row.get("wxid") or "").strip(),
|
||||
"display_name": str(row.get("display_name") or "").strip(),
|
||||
"latest_active_time": str(row.get("latest_active_time") or "").strip(),
|
||||
"inactivity_days": int(row.get("inactivity_days") or 0),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _build_ops_member_segments(server, group_id: str, speaker_ranking: list, inactive_members: list) -> dict:
|
||||
"""构建群成员分层结果。
|
||||
|
||||
设计目标:
|
||||
1. 保留原有“沉默成员 / 发言排行”表格;
|
||||
2. 在此基础上额外产出“核心成员 / 高价值成员 / 待激活成员”三类运营视角;
|
||||
3. 前端展示时以卡片为主,让运营者更快找到“该看谁”。
|
||||
"""
|
||||
value_rank_members = _load_value_rank_top_members(server, group_id, limit=5)
|
||||
activation_candidates = _load_activation_candidates(server, group_id, limit=6)
|
||||
member_context_map = {
|
||||
str(item.get("wxid") or "").strip(): item
|
||||
for item in (server.member_context_db.list_group_member_contexts(group_id) or [])
|
||||
}
|
||||
|
||||
core_members = []
|
||||
for item in speaker_ranking[:5]:
|
||||
wxid = str(item.get("sender") or "").strip()
|
||||
context = member_context_map.get(wxid, {}) or {}
|
||||
core_members.append({
|
||||
"wxid": wxid,
|
||||
"display_name": str(item.get("display_name") or wxid).strip(),
|
||||
"message_count": int(item.get("message_count") or 0),
|
||||
"last_message_time": str(item.get("last_message_time") or "").strip(),
|
||||
"activity_level": str(context.get("activity_level") or "").strip(),
|
||||
"summary_text": str(context.get("summary_text") or "").strip(),
|
||||
})
|
||||
|
||||
dormant_members = []
|
||||
for item in inactive_members[:5]:
|
||||
wxid = str(item.get("wxid") or "").strip()
|
||||
context = member_context_map.get(wxid, {}) or {}
|
||||
dormant_members.append({
|
||||
"wxid": wxid,
|
||||
"display_name": str(item.get("display_name") or wxid).strip(),
|
||||
"inactivity_days": int(item.get("inactivity_days") or 0),
|
||||
"latest_active_time": str(item.get("latest_active_time") or "").strip(),
|
||||
"response_style_hint": str(context.get("response_style_hint") or "").strip(),
|
||||
})
|
||||
|
||||
return {
|
||||
"core_members": core_members,
|
||||
"value_rank_members": value_rank_members,
|
||||
"activation_candidates": activation_candidates,
|
||||
"dormant_members": dormant_members,
|
||||
}
|
||||
|
||||
|
||||
def _build_ops_action_cards(group_name: str, ops_profile: dict, ops_members: dict, plugin_stats: list) -> list:
|
||||
"""生成更偏“可执行动作”的运营建议卡片。
|
||||
|
||||
与旧版 `operation_suggestions` 的区别:
|
||||
1. 旧版更像诊断提示;新版更强调“下一步怎么做”;
|
||||
2. 仍然采用规则组合,避免引入高成本、不可控的 LLM 依赖;
|
||||
3. 结构尽量稳定,方便前端做卡片展示与后续继续增强。
|
||||
"""
|
||||
action_cards = []
|
||||
peak_hours = ops_profile.get("peak_hours") or []
|
||||
focus_topics = ops_profile.get("focus_topics") or []
|
||||
dominant_plugins = ops_profile.get("dominant_plugins") or []
|
||||
activation_candidates = ops_members.get("activation_candidates") or []
|
||||
core_members = ops_members.get("core_members") or []
|
||||
|
||||
if peak_hours:
|
||||
action_cards.append({
|
||||
"type": "time",
|
||||
"title": "建议把互动动作放到高峰时段",
|
||||
"summary": f"优先考虑在 {peak_hours[0]} 发起签到、话题或菜单引导。",
|
||||
"detail": f"{group_name} 最近高峰主要集中在 {'、'.join(peak_hours[:2])},在这个时间段发起动作更容易拿到首轮反馈。",
|
||||
})
|
||||
|
||||
if dominant_plugins:
|
||||
action_cards.append({
|
||||
"type": "feature",
|
||||
"title": "建议优先强化已被接受的功能入口",
|
||||
"summary": f"先围绕 {dominant_plugins[0]} 做菜单、公告或欢迎语曝光。",
|
||||
"detail": f"当前群里真实被用起来的功能更偏向 {'、'.join(dominant_plugins[:3])},说明这类能力更容易被接受,适合作为下一阶段主推入口。",
|
||||
})
|
||||
|
||||
if activation_candidates:
|
||||
candidate_names = [str(item.get("display_name") or "").strip() for item in activation_candidates[:3] if str(item.get("display_name") or "").strip()]
|
||||
action_cards.append({
|
||||
"type": "activation",
|
||||
"title": "建议优先激活边缘活跃成员",
|
||||
"summary": f"可以先针对 {'、'.join(candidate_names) if candidate_names else '近 7~30 天边缘成员'} 做轻量召回。",
|
||||
"detail": "这批成员不是长期沉默用户,仍有回流可能,更适合通过低门槛互动、固定提醒或轻福利重新拉回讨论。",
|
||||
})
|
||||
|
||||
if focus_topics:
|
||||
action_cards.append({
|
||||
"type": "topic",
|
||||
"title": "建议围绕当前高频主题设计话题入口",
|
||||
"summary": f"优先从 {focus_topics[0]}、{focus_topics[1] if len(focus_topics) > 1 else focus_topics[0]} 这类主题切入。",
|
||||
"detail": "与其硬推通用活动,不如顺着群里原本就在讨论的内容做问题、投票、接龙或功能引导,转化更自然。",
|
||||
})
|
||||
|
||||
if core_members:
|
||||
core_names = [str(item.get("display_name") or "").strip() for item in core_members[:3] if str(item.get("display_name") or "").strip()]
|
||||
action_cards.append({
|
||||
"type": "core",
|
||||
"title": "建议借核心成员带动扩散",
|
||||
"summary": f"可以优先让 {'、'.join(core_names) if core_names else '核心活跃成员'} 参与首轮互动。",
|
||||
"detail": "核心成员往往能决定首轮响应速度和讨论氛围,先让他们接住话题,比单纯增加曝光更有效。",
|
||||
})
|
||||
|
||||
if not action_cards:
|
||||
action_cards.append({
|
||||
"type": "fallback",
|
||||
"title": "建议先继续积累群画像与使用数据",
|
||||
"summary": "当前数据不足以给出更细的动作建议。",
|
||||
"detail": "可以先保持基础运营动作,等群总结、成员画像和插件使用数据更完整后,再做更细的策略推荐。",
|
||||
})
|
||||
|
||||
return action_cards[:4]
|
||||
|
||||
|
||||
# 旧群权限页入口兼容跳转
|
||||
@robot_bp.route('/')
|
||||
@login_required
|
||||
@@ -373,6 +698,31 @@ def api_group_detail(group_id):
|
||||
"desc": "当前活跃、消息量、插件调用都比较均衡,建议继续围绕高峰时段和高频插件做强化。"
|
||||
})
|
||||
|
||||
# 群运营分析 2.0 采用“增量增强”策略:
|
||||
# 1. 现有 overview / diagnosis / operation_suggestions 等结构继续保留,避免影响旧页面;
|
||||
# 2. 新增 ops_profile / ops_members / ops_actions 三类字段,供现有详情页逐步增强使用;
|
||||
# 3. 即使某些增强数据暂时为空,也不应影响老内容展示。
|
||||
ops_profile = _build_group_ops_profile(
|
||||
server=server,
|
||||
group_id=group_id,
|
||||
group_name=group_name,
|
||||
peak_hours=peak_hours,
|
||||
plugin_stats=plugin_stats,
|
||||
)
|
||||
ops_members = _build_ops_member_segments(
|
||||
server=server,
|
||||
group_id=group_id,
|
||||
speaker_ranking=speaker_ranking,
|
||||
inactive_members=inactive_members,
|
||||
)
|
||||
recent_summaries = _load_recent_group_summaries(server, group_id, limit=3)
|
||||
ops_actions = _build_ops_action_cards(
|
||||
group_name=group_name,
|
||||
ops_profile=ops_profile,
|
||||
ops_members=ops_members,
|
||||
plugin_stats=plugin_stats,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
@@ -442,6 +792,10 @@ def api_group_detail(group_id):
|
||||
"plugin_summary": plugin_summary,
|
||||
"plugin_stats": plugin_stats,
|
||||
"operation_suggestions": operation_suggestions,
|
||||
"ops_profile": ops_profile,
|
||||
"ops_members": ops_members,
|
||||
"ops_actions": ops_actions,
|
||||
"recent_summaries": recent_summaries,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user