diff --git a/admin/dashboard/blueprints/robot.py b/admin/dashboard/blueprints/robot.py index f5c21cd..438a31a 100644 --- a/admin/dashboard/blueprints/robot.py +++ b/admin/dashboard/blueprints/robot.py @@ -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: diff --git a/admin/dashboard/server.py b/admin/dashboard/server.py index 02c2b6b..9248d2d 100644 --- a/admin/dashboard/server.py +++ b/admin/dashboard/server.py @@ -18,6 +18,7 @@ from db.admin_account_db import AdminAccountDBOperator from db.emoji_asset_db import EmojiAssetDB from db.member_context_db import MemberContextDBOperator from db.message_storage import MessageStorageDB +from db.message_summary_db import MessageSummaryDBOperator from db.stats_db import StatsDBOperator from db.task_db import TaskDBOperator from db.fun_command_rule_db import FunCommandRuleDBOperator @@ -61,6 +62,11 @@ class DashboardServer: # 3. 因此这里优先复用 Robot 已初始化的 message_storage,没有则再安全回退到 DB 层对象。 self.message_storage = getattr(robot_instance, "message_storage", None) or MessageStorageDB(self.db_manager) self.emoji_asset_db = getattr(self.message_storage, "emoji_asset_db", None) or EmojiAssetDB(self.db_manager) + # 群运营分析 2.0 会直接复用群消息总结表: + # 1. 这类数据已经由现有插件产出,不需要另起一套采集逻辑; + # 2. 统一在 DashboardServer 上挂载,便于多个后台蓝图复用; + # 3. 即使对应插件未在当前请求时运行,数据库读能力也应保持可用。 + self.message_summary_db = MessageSummaryDBOperator(self.db_manager) self.contact_db: ContactsDBOperator = ContactsDBOperator(self.db_manager) self.member_context_db = MemberContextDBOperator(self.db_manager) self.task_db: TaskDBOperator = TaskDBOperator(self.db_manager) diff --git a/admin/dashboard/templates/contacts_management.html b/admin/dashboard/templates/contacts_management.html index a3f226d..5788610 100644 --- a/admin/dashboard/templates/contacts_management.html +++ b/admin/dashboard/templates/contacts_management.html @@ -262,6 +262,140 @@ + +