feat(value_rank): 新增社交关系图命令并接入md2img渲染

- 新增社交关系图命令,支持社交关系图 [人数] 生成群友关系图

- 基于 t_social_edges_daily 聚合边数据,构建核心节点和关系边用于可视化

- 使用 markdown2image 的 html_to_image 能力渲染 HTML/SVG 并输出图片

- 补充图谱参数配置:default_graph_nodes、max_graph_nodes、graph_edge_pool_limit
This commit is contained in:
liuwei
2026-04-21 14:21:45 +08:00
parent 2c90bc2ebe
commit 46ee371a76
2 changed files with 277 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
[ValueRank]
enable = true
command = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "我的趋势", "身价周报", "身价说明", "重算身价"]
command = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "社交关系图", "我的趋势", "身价周报", "身价说明", "重算身价"]
command-format = """
📊 身价系统命令:
1. 我的身价
@@ -8,10 +8,11 @@ command-format = """
3. 社交热度榜 [名次]
4. 搭子榜 [名次]
5. 社交桥梁榜 [名次]
6. 我的趋势 [数]
7. 身价周报
8. 身价说明
9. 重算身价(管理员)
6. 社交关系图 [数]
7. 我的趋势 [天数]
8. 身价周报
9. 身价说明
10. 重算身价(管理员)
"""
# 统计窗口(天)
@@ -30,6 +31,9 @@ base_score_scale = 1000
# 排行默认展示数量
default_rank_limit = 10
max_rank_limit = 50
default_graph_nodes = 12
max_graph_nodes = 24
graph_edge_pool_limit = 300
default_trend_days = 7
max_trend_days = 30

View File

@@ -4,6 +4,7 @@ import math
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from loguru import logger
@@ -13,6 +14,7 @@ from base.plugin_common.plugin_interface import PluginStatus
from db.base import BaseDBOperator
from db.connection import DBConnectionManager
from utils.decorator.plugin_decorators import plugin_stats_decorator
from utils.markdown_to_image import html_to_image
from utils.robot_cmd.robot_command import PermissionStatus, GroupBotManager
from utils.wechat.contact_manager import ContactManager
@@ -307,6 +309,23 @@ class ValueRankDB(BaseDBOperator):
"""
return self.execute_query(sql, (group_id, user_id, days)) or []
def get_social_edges_for_graph(self, group_id: str, social_window_days: int, limit: int) -> List[Dict[str, Any]]:
"""读取用于绘制社交关系图的边数据。"""
sql = """
SELECT
from_user_id,
to_user_id,
SUM(mention_count) AS mention_count_window,
SUM(interaction_score) AS interaction_score_window
FROM t_social_edges_daily
WHERE group_id = %s
AND stat_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)
GROUP BY from_user_id, to_user_id
ORDER BY interaction_score_window DESC, mention_count_window DESC
LIMIT %s
"""
return self.execute_query(sql, (group_id, social_window_days, limit)) or []
def get_snapshot_score_map(self, stat_date: str, group_id: str) -> Dict[str, float]:
"""读取某天群内所有用户分数字典:{user_id: score}。"""
sql = """
@@ -474,7 +493,7 @@ class ValueRankPlugin(MessagePluginInterface):
"""
FEATURE_KEY = "VALUE_RANK"
FEATURE_DESCRIPTION = "📊 身价排行 [我的身价, 身价排行, 社交热度榜, 搭子榜, 社交桥梁榜, 我的趋势, 身价周报, 身价说明, 重算身价]"
FEATURE_DESCRIPTION = "📊 身价排行 [我的身价, 身价排行, 社交热度榜, 搭子榜, 社交桥梁榜, 社交关系图, 我的趋势, 身价周报, 身价说明, 重算身价]"
@property
def name(self) -> str:
@@ -515,10 +534,10 @@ class ValueRankPlugin(MessagePluginInterface):
# 配置默认值:即使未配置 config.toml也能以保守参数运行。
self.enable = True
self._commands = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "我的趋势", "身价周报", "身价说明", "重算身价"]
self._commands = ["我的身价", "身价排行", "社交热度榜", "搭子榜", "社交桥梁榜", "社交关系图", "我的趋势", "身价周报", "身价说明", "重算身价"]
self.command_format = (
"我的身价 | 身价排行 [名次] | 社交热度榜 [名次] | 搭子榜 [名次] | "
"社交桥梁榜 [名次] | 我的趋势 [天数] | 身价周报 | 身价说明 | 重算身价"
"社交桥梁榜 [名次] | 社交关系图 [人数] | 我的趋势 [天数] | 身价周报 | 身价说明 | 重算身价"
)
self.message_window_days = 7
@@ -535,6 +554,9 @@ class ValueRankPlugin(MessagePluginInterface):
self.default_rank_limit = 10
self.max_rank_limit = 50
self.default_graph_nodes = 12
self.max_graph_nodes = 24
self.graph_edge_pool_limit = 300
self.default_trend_days = 7
self.max_trend_days = 30
self.mention_batch_size = 200
@@ -564,6 +586,9 @@ class ValueRankPlugin(MessagePluginInterface):
self.default_rank_limit = int(cfg.get("default_rank_limit", self.default_rank_limit))
self.max_rank_limit = int(cfg.get("max_rank_limit", self.max_rank_limit))
self.default_graph_nodes = int(cfg.get("default_graph_nodes", self.default_graph_nodes))
self.max_graph_nodes = int(cfg.get("max_graph_nodes", self.max_graph_nodes))
self.graph_edge_pool_limit = int(cfg.get("graph_edge_pool_limit", self.graph_edge_pool_limit))
self.default_trend_days = int(cfg.get("default_trend_days", self.default_trend_days))
self.max_trend_days = int(cfg.get("max_trend_days", self.max_trend_days))
self.mention_batch_size = int(cfg.get("mention_batch_size", self.mention_batch_size))
@@ -647,6 +672,15 @@ class ValueRankPlugin(MessagePluginInterface):
await bot.send_text_message(roomid, text, sender)
return True, "查询成功"
if command == "社交关系图":
graph_nodes = self._parse_graph_nodes(content)
image_path = await self._render_social_graph_image(roomid, graph_nodes)
if image_path:
await bot.send_image_message(roomid, Path(image_path))
return True, "查询成功"
await bot.send_text_message(roomid, "📊 近期社交关系数据不足,暂时无法绘制关系图。", sender)
return True, "数据不足"
if command == "我的趋势":
days = self._parse_trend_days(content)
text = await self._build_my_trend_text(roomid, sender, days)
@@ -1118,6 +1152,225 @@ class ValueRankPlugin(MessagePluginInterface):
lines.append(f"趋势结论:{trend_flag} {delta:+.2f}")
return "\n".join(lines)
async def _render_social_graph_image(self, group_id: str, max_nodes: int) -> Optional[str]:
"""渲染群友社交关系图并返回图片路径。
设计说明:
1. 数据层使用近窗口期社交边聚合结果,避免扫全量消息;
2. 先做无向边合并,再筛选“连接度高”的核心节点,控制图复杂度;
3. 通过 markdown2image 的 HTML 截图能力输出图片,统一渲染链路。
"""
if not self.db:
return None
edge_rows = self.db.get_social_edges_for_graph(
group_id=group_id,
social_window_days=self.social_window_days,
limit=max(20, int(self.graph_edge_pool_limit)),
)
if not edge_rows:
return None
# 先将有向边规整成无向边,防止 A->B 和 B->A 分裂成两条线导致视觉噪声。
edge_map: Dict[Tuple[str, str], Dict[str, float]] = {}
partner_map: Dict[str, set] = {}
node_score_map: Dict[str, float] = {}
for row in edge_rows:
uid_a = str(row.get("from_user_id") or "").strip()
uid_b = str(row.get("to_user_id") or "").strip()
if not uid_a or not uid_b or uid_a == uid_b:
continue
a, b = (uid_a, uid_b) if uid_a < uid_b else (uid_b, uid_a)
pair_key = (a, b)
if pair_key not in edge_map:
edge_map[pair_key] = {"mention_count": 0.0, "score": 0.0}
edge_map[pair_key]["mention_count"] += float(row.get("mention_count_window") or 0.0)
edge_map[pair_key]["score"] += float(row.get("interaction_score_window") or 0.0)
partner_map.setdefault(a, set()).add(b)
partner_map.setdefault(b, set()).add(a)
node_score_map[a] = float(node_score_map.get(a, 0.0)) + float(row.get("interaction_score_window") or 0.0)
node_score_map[b] = float(node_score_map.get(b, 0.0)) + float(row.get("interaction_score_window") or 0.0)
if not edge_map:
return None
# 使用“伙伴去重数 + 互动分”混合排序选核心节点,让图既看覆盖面也看互动强度。
sorted_nodes = sorted(
list(partner_map.keys()),
key=lambda uid: (
-len(partner_map.get(uid, set())),
-float(node_score_map.get(uid, 0.0)),
uid,
),
)
selected_nodes = sorted_nodes[:max_nodes]
selected_set = set(selected_nodes)
if len(selected_nodes) < 2:
return None
selected_edges: List[Tuple[str, str, float, float]] = []
for (a, b), data in edge_map.items():
if a in selected_set and b in selected_set:
selected_edges.append((a, b, float(data.get("mention_count", 0.0)), float(data.get("score", 0.0))))
if not selected_edges:
return None
html_content = self._build_social_graph_html(group_id, selected_nodes, selected_edges, partner_map, node_score_map)
if not html_content:
return None
output_dir = Path("temp") / "value_rank"
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"social_graph_{group_id}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}.png"
try:
await html_to_image(html_content, str(output_path))
except Exception as e:
self.LOG.error(f"[{self.name}] 社交关系图渲染失败: group={group_id}, error={e}")
return None
return str(output_path.resolve()) if output_path.exists() else None
def _build_social_graph_html(
self,
group_id: str,
selected_nodes: List[str],
selected_edges: List[Tuple[str, str, float, float]],
partner_map: Dict[str, set],
node_score_map: Dict[str, float],
) -> str:
"""构建社交关系图 HTML含 SVG 节点和边)。"""
if not selected_nodes:
return ""
import html
width = 1300
height = 900
cx, cy = width // 2, height // 2 + 10
radius = min(width, height) * 0.33
node_count = len(selected_nodes)
# 计算节点坐标:使用圆形布局,稳定且不依赖额外图算法库。
pos_map: Dict[str, Tuple[float, float]] = {}
for idx, uid in enumerate(selected_nodes):
angle = (2.0 * math.pi * idx) / max(node_count, 1)
x = cx + radius * math.cos(angle)
y = cy + radius * math.sin(angle)
pos_map[uid] = (x, y)
max_edge_score = max([edge[3] for edge in selected_edges] + [1.0])
max_partner_count = max([len(partner_map.get(uid, set())) for uid in selected_nodes] + [1])
cm = ContactManager.get_instance()
edge_svg_parts: List[str] = []
for a, b, mention_count, score in selected_edges:
ax, ay = pos_map[a]
bx, by = pos_map[b]
normalized = max(0.12, min(score / max_edge_score, 1.0))
stroke_width = 1.0 + 7.0 * normalized
opacity = 0.20 + 0.55 * normalized
edge_svg_parts.append(
f'<line x1="{ax:.1f}" y1="{ay:.1f}" x2="{bx:.1f}" y2="{by:.1f}" '
f'stroke="rgba(33, 150, 243, {opacity:.3f})" stroke-width="{stroke_width:.2f}" />'
)
node_svg_parts: List[str] = []
for uid in selected_nodes:
x, y = pos_map[uid]
partner_count = len(partner_map.get(uid, set()))
score = float(node_score_map.get(uid, 0.0))
size_norm = max(0.15, min(partner_count / max_partner_count, 1.0))
node_radius = 18.0 + 24.0 * size_norm
nick = cm.get_group_name(group_id, uid) or uid
safe_nick = html.escape(str(nick))
node_svg_parts.append(
f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{node_radius:.1f}" fill="rgba(255, 193, 7, 0.90)" '
f'stroke="rgba(255, 152, 0, 0.95)" stroke-width="2.2"></circle>'
)
node_svg_parts.append(
f'<text x="{x:.1f}" y="{y + node_radius + 22:.1f}" text-anchor="middle" '
f'font-size="19" fill="#2F3B52" font-weight="700">{safe_nick}</text>'
)
node_svg_parts.append(
f'<text x="{x:.1f}" y="{y + node_radius + 44:.1f}" text-anchor="middle" '
f'font-size="14" fill="#6C7A96">连接{partner_count}人 · 互动{score:.1f}</text>'
)
group_title = html.escape(ContactManager.get_instance().get_nickname(group_id) or group_id)
now_text = datetime.now().strftime("%Y-%m-%d %H:%M")
summary_text = (
f"统计窗口:近{self.social_window_days} 节点数:{len(selected_nodes)} "
f"关系边:{len(selected_edges)} 生成时间:{now_text}"
)
return f"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<style>
body {{
margin: 0;
background: linear-gradient(135deg, #f7fbff 0%, #f2f7ff 45%, #fff8ed 100%);
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
}}
.card {{
width: {width}px;
margin: 0 auto;
padding: 26px 26px 20px 26px;
box-sizing: border-box;
}}
.title {{
font-size: 38px;
color: #1f2d46;
font-weight: 800;
letter-spacing: 1px;
}}
.subtitle {{
margin-top: 8px;
font-size: 17px;
color: #5f6f8a;
}}
.graph-wrap {{
margin-top: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(207, 221, 246, 0.9);
box-shadow: 0 8px 24px rgba(58, 82, 130, 0.12);
overflow: hidden;
}}
.legend {{
margin-top: 14px;
font-size: 14px;
color: #6f7d96;
line-height: 1.7;
}}
</style>
</head>
<body>
<div class="card">
<div class="title">群友社交关系图</div>
<div class="subtitle">{group_title}</div>
<div class="subtitle">{summary_text}</div>
<div class="graph-wrap">
<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}">
<rect x="0" y="0" width="{width}" height="{height}" fill="rgba(247,251,255,0.72)"></rect>
{''.join(edge_svg_parts)}
{''.join(node_svg_parts)}
</svg>
</div>
<div class="legend">
说明:节点越大代表连接群友越多;连线越粗代表互动越强。该图仅基于 @ 关系统计,不含纯文本对话引用关系。
</div>
</div>
</body>
</html>
"""
async def _build_weekly_report_text(self, group_id: str) -> str:
"""构建“身价周报”文本。
@@ -1403,6 +1656,18 @@ class ValueRankPlugin(MessagePluginInterface):
days = max(1, days)
return min(days, self.max_trend_days)
def _parse_graph_nodes(self, content: str) -> int:
"""解析社交关系图节点数量参数。"""
parts = content.split()
if len(parts) < 2:
return self.default_graph_nodes
try:
node_count = int(parts[1])
except Exception:
return self.default_graph_nodes
node_count = max(6, node_count)
return min(node_count, self.max_graph_nodes)
@staticmethod
def _normalize_linear(value: float, ceiling: float) -> float:
"""线性归一化并截断到 [0, 1]。"""