@@ -0,0 +1,717 @@
# -*- coding: utf-8 -*-
import json
import math
from datetime import datetime
from typing import Any , Dict , List , Optional , Tuple
from loguru import logger
from base . plugin_common . message_plugin_interface import MessagePluginInterface
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 . robot_cmd . robot_command import PermissionStatus , GroupBotManager
from utils . wechat . contact_manager import ContactManager
class ValueRankDB ( BaseDBOperator ) :
""" Value Rank 数据访问层。
说明:
1. 这里集中封装 SQL,避免插件主流程里混杂大量查询语句;
2. 所有方法都只做“数据读写”,不做业务打分,便于后续独立测试;
3. 读取失败时统一返回空结构,保证上层逻辑可降级继续执行。
"""
def get_candidate_users ( self , group_id : str , active_window_days : int , social_window_days : int ) - > List [ str ] :
""" 获取需要参与打分的候选成员列表。
候选来源采用并集策略,避免遗漏:
1. 积分表中出现过的成员;
2. 近期发过言的成员;
3. 近期社交表出现过的成员(被@或主动@)。
"""
sql = """
SELECT user_id FROM t_user_points
WHERE group_id = %s AND user_id <> ' SYSTEM '
UNION
SELECT DISTINCT sender AS user_id
FROM messages
WHERE group_id = %s
AND sender IS NOT NULL
AND sender <> ' '
AND timestamp >= DATE_SUB(NOW(), INTERVAL %s DAY)
UNION
SELECT DISTINCT user_id
FROM t_value_rank_social_daily
WHERE group_id = %s
AND stat_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)
"""
rows = self . execute_query ( sql , ( group_id , group_id , active_window_days , group_id , social_window_days ) ) or [ ]
return [ str ( r . get ( " user_id " ) or " " ) . strip ( ) for r in rows if str ( r . get ( " user_id " ) or " " ) . strip ( ) ]
def get_points_map ( self , group_id : str ) - > Dict [ str , int ] :
""" 读取群内用户积分映射: { user_id: total_points}。 """
sql = """
SELECT user_id, total_points
FROM t_user_points
WHERE group_id = %s
AND user_id <> ' SYSTEM '
"""
rows = self . execute_query ( sql , ( group_id , ) ) or [ ]
result : Dict [ str , int ] = { }
for row in rows :
user_id = str ( row . get ( " user_id " ) or " " ) . strip ( )
if not user_id :
continue
result [ user_id ] = int ( row . get ( " total_points " ) or 0 )
return result
def get_message_stats_map ( self , group_id : str , message_window_days : int , active_window_days : int ) - > Dict [ str , Dict [ str , Any ] ] :
""" 按用户读取消息指标。
返回结构示例:
{
" wxid_xxx " : {
" msg_count_window " : 123,
" active_days_window " : 20,
" last_active_date " : " 2026-04-21 "
}
}
"""
sql = """
SELECT
sender AS user_id,
SUM(CASE WHEN timestamp >= DATE_SUB(NOW(), INTERVAL %s DAY) THEN 1 ELSE 0 END) AS msg_count_window,
COUNT(DISTINCT DATE(CASE WHEN timestamp >= DATE_SUB(NOW(), INTERVAL %s DAY) THEN timestamp END)) AS active_days_window,
MAX(DATE(timestamp)) AS last_active_date
FROM messages
WHERE group_id = %s
AND sender IS NOT NULL
AND sender <> ' '
AND timestamp >= DATE_SUB(NOW(), INTERVAL %s DAY)
GROUP BY sender
"""
rows = self . execute_query ( sql , ( message_window_days , active_window_days , group_id , active_window_days ) ) or [ ]
result : Dict [ str , Dict [ str , Any ] ] = { }
for row in rows :
user_id = str ( row . get ( " user_id " ) or " " ) . strip ( )
if not user_id :
continue
result [ user_id ] = {
" msg_count_window " : int ( row . get ( " msg_count_window " ) or 0 ) ,
" active_days_window " : int ( row . get ( " active_days_window " ) or 0 ) ,
" last_active_date " : row . get ( " last_active_date " ) ,
}
return result
def get_last_active_date_map ( self , group_id : str ) - > Dict [ str , Any ] :
""" 读取群内用户最后发言日期,用于计算潜水惩罚。 """
sql = """
SELECT sender AS user_id, MAX(DATE(timestamp)) AS last_active_date
FROM messages
WHERE group_id = %s
AND sender IS NOT NULL
AND sender <> ' '
GROUP BY sender
"""
rows = self . execute_query ( sql , ( group_id , ) ) or [ ]
return {
str ( row . get ( " user_id " ) or " " ) . strip ( ) : row . get ( " last_active_date " )
for row in rows
if str ( row . get ( " user_id " ) or " " ) . strip ( )
}
def get_social_stats_map ( self , group_id : str , social_window_days : int ) - > Dict [ str , Dict [ str , Any ] ] :
""" 按用户读取社交指标(日汇总窗口)。 """
sql = """
SELECT
user_id,
SUM(mentioned_count) AS mentioned_count_window,
SUM(mention_others_count) AS mention_others_count_window,
SUM(unique_interactors) AS unique_interactors_window,
SUM(interaction_score) AS interaction_score_window
FROM t_value_rank_social_daily
WHERE group_id = %s
AND stat_date >= DATE_SUB(CURDATE(), INTERVAL %s DAY)
GROUP BY user_id
"""
rows = self . execute_query ( sql , ( group_id , social_window_days ) ) or [ ]
result : Dict [ str , Dict [ str , Any ] ] = { }
for row in rows :
user_id = str ( row . get ( " user_id " ) or " " ) . strip ( )
if not user_id :
continue
result [ user_id ] = {
" mentioned_count_window " : int ( row . get ( " mentioned_count_window " ) or 0 ) ,
" mention_others_count_window " : int ( row . get ( " mention_others_count_window " ) or 0 ) ,
" unique_interactors_window " : int ( row . get ( " unique_interactors_window " ) or 0 ) ,
" interaction_score_window " : float ( row . get ( " interaction_score_window " ) or 0.0 ) ,
}
return result
def upsert_snapshots ( self , rows : List [ Tuple [ Any , . . . ] ] ) - > bool :
""" 批量写入身价快照。 """
if not rows :
return True
sql = """
INSERT INTO t_value_rank_snapshot (
stat_date, group_id, user_id, score, rank_no, title,
points_total, msg_count_7d, active_days_30, inactive_days,
score_detail_json
)
VALUES ( %s , %s , %s , %s , %s , %s , %s , %s , %s , %s , %s )
ON DUPLICATE KEY UPDATE
score = VALUES(score),
rank_no = VALUES(rank_no),
title = VALUES(title),
points_total = VALUES(points_total),
msg_count_7d = VALUES(msg_count_7d),
active_days_30 = VALUES(active_days_30),
inactive_days = VALUES(inactive_days),
score_detail_json = VALUES(score_detail_json),
updated_at = CURRENT_TIMESTAMP
"""
return self . execute_batch ( sql , rows )
def get_user_snapshot ( self , stat_date : str , group_id : str , user_id : str ) - > Optional [ Dict [ str , Any ] ] :
""" 读取某用户在某天的身价快照。 """
sql = """
SELECT * FROM t_value_rank_snapshot
WHERE stat_date = %s AND group_id = %s AND user_id = %s
LIMIT 1
"""
return self . execute_query ( sql , ( stat_date , group_id , user_id ) , fetch_one = True )
def get_yesterday_score ( self , stat_date : str , group_id : str , user_id : str ) - > Optional [ float ] :
""" 读取昨日分数用于计算涨跌幅。 """
sql = """
SELECT score
FROM t_value_rank_snapshot
WHERE stat_date = DATE_SUB( %s , INTERVAL 1 DAY)
AND group_id = %s
AND user_id = %s
LIMIT 1
"""
row = self . execute_query ( sql , ( stat_date , group_id , user_id ) , fetch_one = True )
if not row :
return None
return float ( row . get ( " score " ) or 0 )
def get_today_rankings ( self , stat_date : str , group_id : str , limit : int ) - > List [ Dict [ str , Any ] ] :
""" 读取今日排行榜。 """
sql = """
SELECT user_id, score, rank_no, title
FROM t_value_rank_snapshot
WHERE stat_date = %s
AND group_id = %s
ORDER BY rank_no ASC
LIMIT %s
"""
return self . execute_query ( sql , ( stat_date , group_id , limit ) ) or [ ]
class ValueRankPlugin ( MessagePluginInterface ) :
""" 群成员身价排行插件。
设计目标:
1. 将“积分、发言、活跃、社交影响力”统一折算为可解释的身价分;
2. 支持手动查询与后台定时重算;
3. 通过快照持久化,支持趋势分析和涨跌说明。
"""
FEATURE_KEY = " VALUE_RANK "
FEATURE_DESCRIPTION = " 📊 身价排行 [我的身价, 身价排行, 身价说明, 重算身价] "
@property
def name ( self ) - > str :
return " 身价排行 "
@property
def version ( self ) - > str :
return " 1.0.0 "
@property
def description ( self ) - > str :
return " 根据积分、发言、活跃与社交影响力计算群成员身价。 "
@property
def author ( self ) - > str :
return " ABOT Team "
@property
def command_prefix ( self ) - > Optional [ str ] :
return " "
@property
def commands ( self ) - > List [ str ] :
return self . _commands
@property
def feature_key ( self ) - > Optional [ str ] :
return self . FEATURE_KEY
@property
def feature_description ( self ) - > Optional [ str ] :
return self . FEATURE_DESCRIPTION
def __init__ ( self ) :
super ( ) . __init__ ( )
self . feature = self . register_feature ( )
self . db : Optional [ ValueRankDB ] = None
# 配置默认值:即使未配置 config.toml,也能以保守参数运行。
self . enable = True
self . _commands = [ " 我的身价 " , " 身价排行 " , " 身价说明 " , " 重算身价 " ]
self . command_format = " 我的身价 | 身价排行 [名次] | 身价说明 | 重算身价 "
self . message_window_days = 7
self . active_window_days = 30
self . social_window_days = 7
self . points_weight = 0.30
self . message_weight = 0.35
self . active_days_weight = 0.20
self . social_weight = 0.15
self . inactivity_penalty_max = 150
self . base_score_scale = 1000
self . default_rank_limit = 10
self . max_rank_limit = 50
def initialize ( self , context : Dict [ str , Any ] ) - > bool :
""" 初始化插件与配置。 """
self . LOG = logger
cfg = self . _config . get ( " ValueRank " , { } )
self . enable = bool ( cfg . get ( " enable " , True ) )
self . _commands = cfg . get ( " command " , self . _commands )
self . command_format = cfg . get ( " command-format " , self . command_format )
self . message_window_days = int ( cfg . get ( " message_window_days " , self . message_window_days ) )
self . active_window_days = int ( cfg . get ( " active_window_days " , self . active_window_days ) )
self . social_window_days = int ( cfg . get ( " social_window_days " , self . social_window_days ) )
self . points_weight = float ( cfg . get ( " points_weight " , self . points_weight ) )
self . message_weight = float ( cfg . get ( " message_weight " , self . message_weight ) )
self . active_days_weight = float ( cfg . get ( " active_days_weight " , self . active_days_weight ) )
self . social_weight = float ( cfg . get ( " social_weight " , self . social_weight ) )
self . inactivity_penalty_max = float ( cfg . get ( " inactivity_penalty_max " , self . inactivity_penalty_max ) )
self . base_score_scale = float ( cfg . get ( " base_score_scale " , self . base_score_scale ) )
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 ) )
# 权重归一化:避免配置误差导致总权重不为 1。
weight_sum = self . points_weight + self . message_weight + self . active_days_weight + self . social_weight
if weight_sum < = 0 :
self . points_weight , self . message_weight , self . active_days_weight , self . social_weight = 0.30 , 0.35 , 0.20 , 0.15
else :
self . points_weight / = weight_sum
self . message_weight / = weight_sum
self . active_days_weight / = weight_sum
self . social_weight / = weight_sum
self . db = ValueRankDB ( DBConnectionManager . get_instance ( ) )
self . LOG . info ( f " [ { self . name } ] 初始化完成,命令: { self . _commands } " )
return True
def start ( self ) - > bool :
self . status = PluginStatus . RUNNING
return True
def stop ( self ) - > bool :
self . status = PluginStatus . STOPPED
return True
def can_process ( self , message : Dict [ str , Any ] ) - > bool :
if not self . enable :
return False
content = str ( message . get ( " content " , " " ) ) . strip ( )
if not content :
return False
command = content . split ( " " ) [ 0 ]
return command in self . _commands
@plugin_stats_decorator ( plugin_name = " 身价排行 " )
async def process_message ( self , message : Dict [ str , Any ] ) - > Tuple [ bool , Optional [ str ] ] :
""" 处理用户命令入口。 """
content = str ( message . get ( " content " , " " ) ) . strip ( )
command = content . split ( " " ) [ 0 ]
sender = message . get ( " sender " )
roomid = message . get ( " roomid " , " " )
gbm : GroupBotManager = message . get ( " gbm " )
bot = message . get ( " bot " )
if roomid and gbm . get_group_permission ( roomid , self . feature ) == PermissionStatus . DISABLED :
return False , " 没有权限 "
if not roomid :
await bot . send_text_message ( sender , " 该功能仅支持群聊使用。 " , sender )
return True , " 非群聊 "
if command == " 我的身价 " :
text = await self . _build_my_value_text ( roomid , sender )
await bot . send_text_message ( roomid , text , sender )
return True , " 查询成功 "
if command == " 身价排行 " :
limit = self . _parse_rank_limit ( content )
text = await self . _build_ranking_text ( roomid , limit )
await bot . send_text_message ( roomid , text , sender )
return True , " 查询成功 "
if command == " 身价说明 " :
await bot . send_text_message ( roomid , self . _build_explain_text ( ) , sender )
return True , " 查询成功 "
if command == " 重算身价 " :
# 重算属于管理动作,只允许机器人管理员或群管理员执行,避免被滥用。
if not GroupBotManager . is_admin_for_group ( sender , roomid ) :
await bot . send_text_message ( roomid , " 仅管理员可执行重算身价。 " , sender )
return True , " 权限不足 "
stat_date = datetime . now ( ) . strftime ( " % Y- % m- %d " )
user_count = self . _recompute_group_snapshot ( roomid , stat_date )
await bot . send_text_message ( roomid , f " ✅ 重算完成,已更新 { user_count } 名成员。 " , sender )
return True , " 重算成功 "
await bot . send_text_message ( roomid , f " ❌命令格式错误 \n { self . command_format } " , sender )
return True , " 命令错误 "
def get_schedule_actions ( self ) - > List [ Dict [ str , Any ] ] :
""" 声明可调度动作:每日凌晨全量重算。 """
return [
{
" action_key " : " value_rank_daily_recompute " ,
" name " : " 身价每日重算 " ,
" description " : " 每天凌晨重算群成员身价快照 " ,
" trigger_type " : " at_times " ,
" trigger_config " : { " time_list " : [ " 04:00 " ] } ,
" target_scope " : " all_enabled_groups " ,
" target_config " : { } ,
" payload " : { } ,
" default_enabled " : True ,
}
]
async def run_scheduled_action ( self , action_key : str , context : Dict [ str , Any ] ) - > Dict [ str , Any ] :
""" 执行调度动作。 """
if action_key != " value_rank_daily_recompute " :
return { " success " : False , " summary " : f " 不支持动作: { action_key } " , " detail " : { } }
stat_date = datetime . now ( ) . strftime ( " % Y- % m- %d " )
target_groups = [ str ( g ) . strip ( ) for g in ( context . get ( " target_groups " ) or [ ] ) if str ( g ) . strip ( ) ]
if not target_groups :
target_groups = [
gid for gid in GroupBotManager . get_group_list ( )
if GroupBotManager . get_group_permission ( gid , self . feature ) . value == " enabled "
]
success_groups : List [ str ] = [ ]
failed_groups : Dict [ str , str ] = { }
updated_users = 0
for gid in target_groups :
try :
updated_users + = self . _recompute_group_snapshot ( gid , stat_date )
success_groups . append ( gid )
except Exception as e :
failed_groups [ gid ] = str ( e )
return {
" success " : len ( failed_groups ) == 0 ,
" summary " : f " 身价重算完成:成功 { len ( success_groups ) } 群,失败 { len ( failed_groups ) } 群 " ,
" detail " : {
" stat_date " : stat_date ,
" updated_users " : updated_users ,
" success_groups " : success_groups ,
" failed_groups " : failed_groups ,
} ,
}
def _recompute_group_snapshot ( self , group_id : str , stat_date : str ) - > int :
""" 重算单群指定日期快照。
这是插件核心逻辑:
1. 聚合各维度原始指标;
2. 归一化并计算分数;
3. 生成排名与称号;
4. 持久化到快照表。
"""
if not self . db :
return 0
candidates = self . db . get_candidate_users ( group_id , self . active_window_days , self . social_window_days )
if not candidates :
return 0
points_map = self . db . get_points_map ( group_id )
msg_map = self . db . get_message_stats_map ( group_id , self . message_window_days , self . active_window_days )
last_active_map = self . db . get_last_active_date_map ( group_id )
social_map = self . db . get_social_stats_map ( group_id , self . social_window_days )
# 组装原始指标,后续统一归一化处理。
metrics : List [ Dict [ str , Any ] ] = [ ]
for user_id in candidates :
points_total = int ( points_map . get ( user_id , 0 ) )
msg_stats = msg_map . get ( user_id , { } )
social_stats = social_map . get ( user_id , { } )
msg_count_7d = int ( msg_stats . get ( " msg_count_window " , 0 ) )
active_days_30 = int ( msg_stats . get ( " active_days_window " , 0 ) )
interaction_score_7d = float ( social_stats . get ( " interaction_score_window " , 0.0 ) )
# inactive_days 优先使用全量最后发言日期,若无记录视为长期未活跃。
inactive_days = 365
last_active_date = last_active_map . get ( user_id )
if last_active_date :
try :
if isinstance ( last_active_date , datetime ) :
dt = last_active_date
else :
dt = datetime . strptime ( str ( last_active_date ) , " % Y- % m- %d " )
inactive_days = max ( ( datetime . now ( ) . date ( ) - dt . date ( ) ) . days , 0 )
except Exception :
inactive_days = 365
metrics . append (
{
" user_id " : user_id ,
" points_total " : points_total ,
" msg_count_7d " : msg_count_7d ,
" active_days_30 " : active_days_30 ,
" interaction_score_7d " : interaction_score_7d ,
" inactive_days " : inactive_days ,
}
)
if not metrics :
return 0
# 计算分位阈值,避免极端值主导。
p95_points = self . _percentile95 ( [ m [ " points_total " ] for m in metrics ] )
p95_msg = self . _percentile95 ( [ m [ " msg_count_7d " ] for m in metrics ] )
p95_social = self . _percentile95 ( [ m [ " interaction_score_7d " ] for m in metrics ] )
scored_rows : List [ Dict [ str , Any ] ] = [ ]
for m in metrics :
p_norm = self . _normalize_log ( m [ " points_total " ] , p95_points )
m_norm = self . _normalize_linear ( m [ " msg_count_7d " ] , p95_msg )
a_norm = self . _normalize_linear ( m [ " active_days_30 " ] , self . active_window_days )
s_norm = self . _normalize_linear ( m [ " interaction_score_7d " ] , p95_social )
i_penalty = self . _normalize_linear ( m [ " inactive_days " ] , 30 )
base_score = self . base_score_scale * (
self . points_weight * p_norm
+ self . message_weight * m_norm
+ self . active_days_weight * a_norm
+ self . social_weight * s_norm
)
penalty_score = self . inactivity_penalty_max * i_penalty
final_score = max ( round ( base_score - penalty_score , 2 ) , 0.0 )
scored_rows . append (
{
* * m ,
" score " : final_score ,
" p_norm " : p_norm ,
" m_norm " : m_norm ,
" a_norm " : a_norm ,
" s_norm " : s_norm ,
" penalty " : penalty_score ,
}
)
# 分数高到低排序,同分时按活跃和积分补充排序,提升稳定性。
scored_rows . sort (
key = lambda x : (
- float ( x [ " score " ] ) ,
- int ( x [ " msg_count_7d " ] ) ,
- float ( x [ " interaction_score_7d " ] ) ,
- int ( x [ " points_total " ] ) ,
)
)
total = len ( scored_rows )
batch_rows : List [ Tuple [ Any , . . . ] ] = [ ]
for idx , row in enumerate ( scored_rows , start = 1 ) :
title = self . _build_title ( idx , total , int ( row [ " inactive_days " ] ) )
score_detail = {
" points_norm " : round ( float ( row [ " p_norm " ] ) , 6 ) ,
" message_norm " : round ( float ( row [ " m_norm " ] ) , 6 ) ,
" active_norm " : round ( float ( row [ " a_norm " ] ) , 6 ) ,
" social_norm " : round ( float ( row [ " s_norm " ] ) , 6 ) ,
" penalty_score " : round ( float ( row [ " penalty " ] ) , 2 ) ,
" weights " : {
" points " : self . points_weight ,
" message " : self . message_weight ,
" active " : self . active_days_weight ,
" social " : self . social_weight ,
} ,
}
batch_rows . append (
(
stat_date ,
group_id ,
row [ " user_id " ] ,
row [ " score " ] ,
idx ,
title ,
row [ " points_total " ] ,
row [ " msg_count_7d " ] ,
row [ " active_days_30 " ] ,
row [ " inactive_days " ] ,
json . dumps ( score_detail , ensure_ascii = False ) ,
)
)
self . db . upsert_snapshots ( batch_rows )
return len ( batch_rows )
async def _build_my_value_text ( self , group_id : str , user_id : str ) - > str :
""" 构建“我的身价”输出文本。 """
if not self . db :
return " ❌ 身价模块未初始化 "
stat_date = datetime . now ( ) . strftime ( " % Y- % m- %d " )
row = self . db . get_user_snapshot ( stat_date , group_id , user_id )
# 若当天还没有快照,按需触发一次当前群重算,保证命令可用性。
if not row :
self . _recompute_group_snapshot ( group_id , stat_date )
row = self . db . get_user_snapshot ( stat_date , group_id , user_id )
if not row :
return " 📊 暂无你的身价数据,请先在群里发言后再试。 "
yesterday_score = self . db . get_yesterday_score ( stat_date , group_id , user_id )
current_score = float ( row . get ( " score " ) or 0 )
if yesterday_score is None :
change_text = " 新上榜(暂无昨日对比) "
else :
delta = current_score - float ( yesterday_score )
if abs ( yesterday_score ) < 1e-9 :
change_text = f " 较昨日变化: { delta : +.2f } "
else :
pct = delta / float ( yesterday_score ) * 100
change_text = f " 较昨日: { pct : +.2f } % "
nick = ContactManager . get_instance ( ) . get_group_name ( group_id , user_id ) or user_id
lines = [
f " 📊 { nick } 的身价报告( { stat_date } ) " ,
f " 总身价: { current_score : .2f } " ,
f " 群内排名:第 { int ( row . get ( ' rank_no ' ) or 0 ) } 名( { row . get ( ' title ' ) or ' 未定级 ' } ) " ,
" " ,
f " 💰 积分资产: { int ( row . get ( ' points_total ' ) or 0 ) } " ,
f " 🗣️ 7日发言: { int ( row . get ( ' msg_count_7d ' ) or 0 ) } " ,
f " 📅 30日活跃: { int ( row . get ( ' active_days_30 ' ) or 0 ) } 天 " ,
f " 🌊 潜水天数: { int ( row . get ( ' inactive_days ' ) or 0 ) } 天 " ,
" " ,
change_text ,
]
return " \n " . join ( lines )
async def _build_ranking_text ( self , group_id : str , limit : int ) - > str :
""" 构建“身价排行”输出文本。 """
if not self . db :
return " ❌ 身价模块未初始化 "
stat_date = datetime . now ( ) . strftime ( " % Y- % m- %d " )
rows = self . db . get_today_rankings ( stat_date , group_id , limit )
if not rows :
self . _recompute_group_snapshot ( group_id , stat_date )
rows = self . db . get_today_rankings ( stat_date , group_id , limit )
if not rows :
return " 📊 今日暂无排行数据。 "
cm = ContactManager . get_instance ( )
lines = [ f " 🏆 身价排行榜(Top { len ( rows ) } | { stat_date } ) " ]
for idx , row in enumerate ( rows , start = 1 ) :
user_id = str ( row . get ( " user_id " ) or " " )
score = float ( row . get ( " score " ) or 0 )
title = str ( row . get ( " title " ) or " " )
nick = cm . get_group_name ( group_id , user_id ) or user_id
medal = " 🥇 " if idx == 1 else " 🥈 " if idx == 2 else " 🥉 " if idx == 3 else f " { idx } . "
lines . append ( f " { medal } { nick } | { score : .2f } | { title } " )
return " \n " . join ( lines )
def _build_explain_text ( self ) - > str :
""" 输出算法说明文本。 """
return (
" 📘 身价算法说明 \n "
f " - 积分权重: { self . points_weight : .2f } \n "
f " - 发言权重: { self . message_weight : .2f } ( { self . message_window_days } 天) \n "
f " - 活跃权重: { self . active_days_weight : .2f } ( { self . active_window_days } 天) \n "
f " - 社交权重: { self . social_weight : .2f } ( { self . social_window_days } 天) \n "
f " - 潜水惩罚上限: { self . inactivity_penalty_max : .0f } \n "
" - 所有维度先归一化再加权,极端值按95分位截断,减少刷屏与极值干扰。 "
)
def _parse_rank_limit ( self , content : str ) - > int :
""" 解析排行条数参数。 """
parts = content . split ( )
if len ( parts ) < 2 :
return self . default_rank_limit
try :
limit = int ( parts [ 1 ] )
except Exception :
return self . default_rank_limit
limit = max ( 1 , limit )
return min ( limit , self . max_rank_limit )
@staticmethod
def _normalize_linear ( value : float , ceiling : float ) - > float :
""" 线性归一化并截断到 [0, 1]。 """
if ceiling < = 0 :
return 0.0
return max ( 0.0 , min ( float ( value ) / float ( ceiling ) , 1.0 ) )
@staticmethod
def _normalize_log ( value : float , ceiling : float ) - > float :
""" 对高离散指标做对数归一化,避免大户统治。 """
if ceiling < = 0 :
return 0.0
safe_value = max ( float ( value ) , 0.0 )
return max ( 0.0 , min ( math . log1p ( safe_value ) / math . log1p ( float ( ceiling ) ) , 1.0 ) )
@staticmethod
def _percentile95 ( values : List [ float ] ) - > float :
""" 计算95分位数(最小返回1,避免除零)。 """
if not values :
return 1.0
sorted_vals = sorted ( [ max ( float ( v ) , 0.0 ) for v in values ] )
n = len ( sorted_vals )
idx = max ( 0 , min ( n - 1 , math . ceil ( 0.95 * n ) - 1 ) )
return max ( sorted_vals [ idx ] , 1.0 )
@staticmethod
def _build_title ( rank_no : int , total : int , inactive_days : int ) - > str :
""" 根据分位区间生成称号。 """
if total < = 0 :
return " 未定级 "
ratio = rank_no / total
if ratio < = 0.01 :
return " 群之巨鳄 "
if ratio < = 0.05 :
return " 社交名流 "
if ratio < = 0.20 :
return " 活跃中产 "
if ratio < = 0.80 :
return " 稳定居民 "
if ratio > = 0.90 and inactive_days > = 30 :
return " 潜水观察员 "
return " 潜力新人 "