feat: add pluginized member context profiling
This commit is contained in:
@@ -210,10 +210,27 @@ def api_group_members(roomid):
|
||||
try:
|
||||
server = current_app.dashboard_server
|
||||
group_members = server.contact_db.get_chatroom_small_member_list(roomid)
|
||||
context_enabled = bool(server.member_context_service) and server.member_context_service.is_group_enabled(roomid)
|
||||
if context_enabled:
|
||||
contexts = server.member_context_db.list_group_member_contexts(roomid)
|
||||
context_map = {item.get("wxid"): item for item in contexts}
|
||||
for member in group_members:
|
||||
context = context_map.get(member.get("wxid"), {})
|
||||
member["activity_level"] = context.get("activity_level", "")
|
||||
member["response_style_hint"] = context.get("response_style_hint", "")
|
||||
member["summary_text"] = context.get("summary_text", "")
|
||||
member["last_profiled_at"] = context.get("last_profiled_at", "")
|
||||
else:
|
||||
for member in group_members:
|
||||
member["activity_level"] = ""
|
||||
member["response_style_hint"] = ""
|
||||
member["summary_text"] = ""
|
||||
member["last_profiled_at"] = ""
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"members": group_members
|
||||
"members": group_members,
|
||||
"member_context_enabled": context_enabled
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -221,6 +238,56 @@ def api_group_members(roomid):
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@contacts_bp.route('/api/group_member_context/<roomid>/<wxid>', methods=['GET'])
|
||||
@login_required
|
||||
def api_group_member_context(roomid, wxid):
|
||||
"""获取群成员交互摘要"""
|
||||
try:
|
||||
server = current_app.dashboard_server
|
||||
if not server.member_context_service:
|
||||
return jsonify({"success": False, "error": "成员交互摘要插件未加载"}), 503
|
||||
if not server.member_context_service.is_group_enabled(roomid):
|
||||
return jsonify({"success": False, "error": "该群未启用成员交互摘要功能"}), 403
|
||||
context = server.member_context_db.get_member_context(roomid, wxid)
|
||||
if not context:
|
||||
context = server.member_context_service.refresh_member_context(roomid, wxid)
|
||||
return jsonify({"success": True, "data": {"context": context}})
|
||||
except Exception as e:
|
||||
logger.error(f"获取群成员交互摘要失败: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@contacts_bp.route('/api/group_member_context/refresh', methods=['POST'])
|
||||
@login_required
|
||||
def api_refresh_group_member_context():
|
||||
"""刷新群成员交互摘要"""
|
||||
try:
|
||||
server = current_app.dashboard_server
|
||||
if not server.member_context_service:
|
||||
return jsonify({"success": False, "error": "成员交互摘要插件未加载"}), 503
|
||||
data = request.json or {}
|
||||
roomid = data.get("roomid")
|
||||
wxid = data.get("wxid")
|
||||
|
||||
if roomid and wxid:
|
||||
if not server.member_context_service.is_group_enabled(roomid):
|
||||
return jsonify({"success": False, "error": "该群未启用成员交互摘要功能"}), 403
|
||||
context = server.member_context_service.refresh_member_context(roomid, wxid)
|
||||
return jsonify({"success": True, "data": {"context": context}})
|
||||
|
||||
if roomid:
|
||||
if not server.member_context_service.is_group_enabled(roomid):
|
||||
return jsonify({"success": False, "error": "该群未启用成员交互摘要功能"}), 403
|
||||
result = server.member_context_service.refresh_group_contexts(roomid)
|
||||
return jsonify({"success": True, "data": result})
|
||||
|
||||
result = server.member_context_service.refresh_all_chatrooms()
|
||||
return jsonify({"success": True, "data": result})
|
||||
except Exception as e:
|
||||
logger.error(f"刷新群成员交互摘要失败: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@contacts_bp.route('/api/update', methods=['POST'])
|
||||
@login_required
|
||||
def api_contacts_update():
|
||||
|
||||
@@ -11,6 +11,7 @@ from flask import Flask, send_from_directory
|
||||
from loguru import logger
|
||||
|
||||
from db.contacts_db import ContactsDBOperator
|
||||
from db.member_context_db import MemberContextDBOperator
|
||||
from db.message_storage import MessageStorageDB
|
||||
from db.stats_db import StatsDBOperator
|
||||
from db.task_db import TaskDBOperator
|
||||
@@ -43,6 +44,7 @@ class DashboardServer:
|
||||
self.stats_db = StatsDBOperator(self.db_manager)
|
||||
self.message_storage = MessageStorageDB(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)
|
||||
# 获取联系人管理器实例
|
||||
self.contact_manager = robot_instance.contact_manager
|
||||
@@ -50,6 +52,8 @@ class DashboardServer:
|
||||
self.plugin_registry = robot_instance.plugin_registry
|
||||
self.client: WechatAPIClient = robot_instance.ipad_bot
|
||||
self.robot = robot_instance
|
||||
self.member_context_plugin = self.plugin_manager.plugins.get("成员交互摘要")
|
||||
self.member_context_service = getattr(self.member_context_plugin, "service", None)
|
||||
|
||||
self.LOG.info("使用Robot实例的对象进行初始化")
|
||||
else:
|
||||
|
||||
@@ -185,7 +185,10 @@
|
||||
<div class="group-members-section">
|
||||
<div class="section-title">
|
||||
<h3>群成员列表</h3>
|
||||
<el-input placeholder="搜索群成员..." v-model="groupMemberSearchQuery" class="group-search" clearable></el-input>
|
||||
<div class="section-actions">
|
||||
<el-input placeholder="搜索群成员..." v-model="groupMemberSearchQuery" class="group-search" clearable></el-input>
|
||||
<el-button size="mini" type="success" @click="refreshCurrentGroupContexts">刷新本群摘要</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="filteredGroupMembers" style="width: 100%" v-loading="groupMembersLoading">
|
||||
<el-table-column type="index" width="54"></el-table-column>
|
||||
@@ -213,6 +216,15 @@
|
||||
<el-table-column prop="latest_active_time" label="活跃时间">
|
||||
<template slot-scope="scope">{% raw %}{{ scope.row.latest_active_time || '-' }}{% endraw %}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="activity_level" label="互动强度" width="110"></el-table-column>
|
||||
<el-table-column label="回复建议" min-width="220" show-overflow-tooltip>
|
||||
<template slot-scope="scope">{% raw %}{{ scope.row.response_style_hint || '-' }}{% endraw %}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="后台摘要" width="130" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="primary" plain @click="openMemberContextDialog(scope.row)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-container" v-if="groupMembersList.length > 10">
|
||||
@@ -233,6 +245,51 @@
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="成员交互摘要" :visible.sync="memberContextDialogVisible" width="52%">
|
||||
<div v-loading="memberContextLoading">
|
||||
<el-alert
|
||||
title="该摘要仅供后台调优参考,不会在群内对用户显式展示。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon>
|
||||
</el-alert>
|
||||
|
||||
<div class="member-context-toolbar">
|
||||
<div class="member-context-title">
|
||||
<strong>{% raw %}{{ currentContextMember.name || currentContextMember.wxid || '成员' }}{% endraw %}</strong>
|
||||
<span>{% raw %}{{ currentContextMember.wxid || '' }}{% endraw %}</span>
|
||||
</div>
|
||||
<el-button size="mini" type="success" @click="refreshMemberContext">刷新摘要</el-button>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="1" border v-if="memberContext">
|
||||
<el-descriptions-item label="互动强度">{% raw %}{{ memberContext.activity_level || '-' }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="表达特征">{% raw %}{{ memberContext.message_pattern || '-' }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="互动风格">{% raw %}{{ memberContext.interaction_style || '-' }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="回复建议">{% raw %}{{ memberContext.response_style_hint || '-' }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="长期关注">
|
||||
<el-tag v-for="item in (memberContext.topics_of_interest || [])" :key="item" size="mini" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
|
||||
<span v-if="!(memberContext.topics_of_interest || []).length">-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="近期话题">
|
||||
<el-tag v-for="item in (memberContext.recent_focus || [])" :key="item" size="mini" type="success" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
|
||||
<span v-if="!(memberContext.recent_focus || []).length">-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="显著特征">
|
||||
<el-tag v-for="item in ((memberContext.meta || {}).engagement_traits || [])" :key="item" size="mini" type="warning" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
|
||||
<span v-if="!(((memberContext.meta || {}).engagement_traits || []).length)">-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="回复避坑">
|
||||
<el-tag v-for="item in ((memberContext.meta || {}).reply_taboos || [])" :key="item" size="mini" type="danger" class="context-tag">{% raw %}{{ item }}{% endraw %}</el-tag>
|
||||
<span v-if="!(((memberContext.meta || {}).reply_taboos || []).length)">-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="摘要说明">{% raw %}{{ memberContext.summary_text || '-' }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="样本消息">{% raw %}{{ memberContext.source_message_count || 0 }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后更新">{% raw %}{{ memberContext.last_profiled_at || '-' }}{% endraw %}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="公众号详情" :visible.sync="officialDetailDialogVisible" width="50%">
|
||||
<div class="detail-avatar-wrap">
|
||||
<el-avatar size="large" :src="getHeadImage(currentOfficial.wxid)" @error="() => true" class="detail-avatar">
|
||||
@@ -323,6 +380,8 @@
|
||||
publicDetailDialogVisible: false,
|
||||
currentGroup: {}, currentUser: {}, currentOfficial: {}, currentPublic: {},
|
||||
groupMembersList: [], groupMembersCurrentPage: 1, groupMembersPageSize: 10, groupMemberSearchQuery: '', groupMembersLoading: false,
|
||||
memberContextDialogVisible: false, memberContextLoading: false, memberContext: null, currentContextMember: {},
|
||||
memberContextEnabled: false,
|
||||
chatDialogVisible: false, currentChatUser: null, messageInput: '', chatMessages: [],
|
||||
linkDialogVisible: false,
|
||||
linkForm: { url: '', title: '', description: '' }
|
||||
@@ -386,12 +445,80 @@
|
||||
axios.get(`/contacts/api/group_members/${roomid}`).then(response => {
|
||||
if (response.data.success) {
|
||||
const members = response.data.data.members;
|
||||
this.groupMembersList = members.map(item => ({ wxid: item.wxid, name: item.nick_name, display_name: item.display_name, status: item.status, latest_active_time: item.latest_active_time, small_head_img_url: item.small_head_img_url }));
|
||||
this.memberContextEnabled = !!response.data.data.member_context_enabled;
|
||||
this.groupMembersList = members.map(item => ({ wxid: item.wxid, name: item.nick_name, display_name: item.display_name, status: item.status, latest_active_time: item.latest_active_time, small_head_img_url: item.small_head_img_url, activity_level: item.activity_level, response_style_hint: item.response_style_hint, summary_text: item.summary_text, last_profiled_at: item.last_profiled_at }));
|
||||
} else { this.$message.error('获取群成员失败'); }
|
||||
}).catch(error => { console.error('加载群成员数据失败:', error); this.$message.error('加载群成员数据失败'); }).finally(() => { this.groupMembersLoading = false; });
|
||||
},
|
||||
handleGroupMembersSizeChange(size) { this.groupMembersPageSize = size; },
|
||||
handleGroupMembersCurrentChange(page) { this.groupMembersCurrentPage = page; },
|
||||
refreshCurrentGroupContexts() {
|
||||
if (!this.currentGroup.wxid) return;
|
||||
if (!this.memberContextEnabled) {
|
||||
this.$message.warning('该群未启用成员交互摘要功能');
|
||||
return;
|
||||
}
|
||||
this.groupMembersLoading = true;
|
||||
axios.post('/contacts/api/group_member_context/refresh', { roomid: this.currentGroup.wxid })
|
||||
.then(response => {
|
||||
if (response.data.success) {
|
||||
this.$message.success('本群成员交互摘要已刷新');
|
||||
this.loadGroupMembers(this.currentGroup.wxid);
|
||||
} else {
|
||||
this.$message.error('刷新本群成员交互摘要失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('刷新本群成员交互摘要失败:', error);
|
||||
this.$message.error('刷新本群成员交互摘要失败');
|
||||
})
|
||||
.finally(() => { this.groupMembersLoading = false; });
|
||||
},
|
||||
openMemberContextDialog(member) {
|
||||
if (!this.memberContextEnabled) {
|
||||
this.$message.warning('该群未启用成员交互摘要功能');
|
||||
return;
|
||||
}
|
||||
this.currentContextMember = member;
|
||||
this.memberContextDialogVisible = true;
|
||||
this.loadMemberContext();
|
||||
},
|
||||
loadMemberContext() {
|
||||
if (!this.currentGroup.wxid || !this.currentContextMember.wxid) return;
|
||||
this.memberContextLoading = true;
|
||||
this.memberContext = null;
|
||||
axios.get(`/contacts/api/group_member_context/${this.currentGroup.wxid}/${this.currentContextMember.wxid}`)
|
||||
.then(response => {
|
||||
if (response.data.success) {
|
||||
this.memberContext = response.data.data.context;
|
||||
} else {
|
||||
this.$message.error('加载成员交互摘要失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('加载成员交互摘要失败:', error);
|
||||
this.$message.error('加载成员交互摘要失败');
|
||||
})
|
||||
.finally(() => { this.memberContextLoading = false; });
|
||||
},
|
||||
refreshMemberContext() {
|
||||
if (!this.currentGroup.wxid || !this.currentContextMember.wxid) return;
|
||||
this.memberContextLoading = true;
|
||||
axios.post('/contacts/api/group_member_context/refresh', {
|
||||
roomid: this.currentGroup.wxid,
|
||||
wxid: this.currentContextMember.wxid
|
||||
}).then(response => {
|
||||
if (response.data.success) {
|
||||
this.memberContext = response.data.data.context;
|
||||
this.$message.success('成员交互摘要已刷新');
|
||||
} else {
|
||||
this.$message.error('刷新成员交互摘要失败');
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('刷新成员交互摘要失败:', error);
|
||||
this.$message.error('刷新成员交互摘要失败');
|
||||
}).finally(() => { this.memberContextLoading = false; });
|
||||
},
|
||||
openChatDialog(user) { this.currentChatUser = user; this.chatDialogVisible = true; this.chatMessages = []; },
|
||||
async sendTextMessage() {
|
||||
if (!this.messageInput.trim()) return;
|
||||
@@ -462,10 +589,16 @@
|
||||
margin: 20px 0 15px 0; border-bottom: 1px solid rgba(148,163,184,0.12); padding-bottom: 10px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.section-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.section-title h3 { margin: 0; font-size: 18px; color: #0f172a; }
|
||||
.group-search { width: 220px; }
|
||||
.detail-avatar-wrap { text-align: center; margin-bottom: 20px; }
|
||||
.detail-avatar { width: 100px; height: 100px; }
|
||||
.member-context-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 16px 0;
|
||||
}
|
||||
.member-context-title { display: flex; flex-direction: column; gap: 4px; color: #475569; }
|
||||
.context-tag { margin-right: 8px; margin-bottom: 8px; }
|
||||
.chat-container { display: flex; flex-direction: column; height: 500px; }
|
||||
.message-list {
|
||||
flex: 1; overflow-y: auto; padding: 20px; background: rgba(248,250,252,0.82); border: 1px solid rgba(148,163,184,0.12);
|
||||
|
||||
@@ -35,4 +35,4 @@ glances:
|
||||
wx_config:
|
||||
#微信管理账号,用于接收部分管理员指令
|
||||
#菜单调整和系统更新
|
||||
admin: [ "Jyunere" ]
|
||||
admin: [ "Jyunere" ]
|
||||
|
||||
134
db/member_context_db.py
Normal file
134
db/member_context_db.py
Normal file
@@ -0,0 +1,134 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from db.base import BaseDBOperator
|
||||
from db.connection import DBConnectionManager
|
||||
|
||||
|
||||
class MemberContextDBOperator(BaseDBOperator):
|
||||
"""群成员交互摘要数据库操作"""
|
||||
|
||||
def __init__(self, db_manager: DBConnectionManager):
|
||||
super().__init__(db_manager)
|
||||
self._create_tables()
|
||||
|
||||
def _create_tables(self):
|
||||
try:
|
||||
self.execute_update("""
|
||||
CREATE TABLE IF NOT EXISTS t_member_context (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
chatroom_id VARCHAR(64) NOT NULL COMMENT '群聊ID',
|
||||
wxid VARCHAR(64) NOT NULL COMMENT '成员微信ID',
|
||||
display_name VARCHAR(128) COMMENT '成员展示名',
|
||||
activity_level VARCHAR(32) COMMENT '活跃等级',
|
||||
message_pattern VARCHAR(255) COMMENT '发言模式',
|
||||
interaction_style VARCHAR(255) COMMENT '互动风格',
|
||||
response_style_hint VARCHAR(255) COMMENT '回复建议',
|
||||
topics_of_interest TEXT COMMENT '兴趣主题(JSON)',
|
||||
recent_focus TEXT COMMENT '近期关注(JSON)',
|
||||
summary_text TEXT COMMENT '交互摘要',
|
||||
confidence DECIMAL(4, 2) DEFAULT 0.00 COMMENT '摘要置信度',
|
||||
source_message_count INT DEFAULT 0 COMMENT '样本消息数',
|
||||
source_days INT DEFAULT 30 COMMENT '采样天数',
|
||||
meta_json LONGTEXT COMMENT '附加元数据(JSON)',
|
||||
last_profiled_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最后生成时间',
|
||||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY idx_member_context (chatroom_id, wxid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群成员交互摘要表';
|
||||
""")
|
||||
# 兼容已存在旧表的场景,补齐新增列
|
||||
self.execute_update("""
|
||||
ALTER TABLE t_member_context
|
||||
ADD COLUMN IF NOT EXISTS interaction_style VARCHAR(255) COMMENT '互动风格'
|
||||
""")
|
||||
except Exception as e:
|
||||
self.LOG.error(f"创建群成员交互摘要表失败: {e}")
|
||||
|
||||
def save_member_context(self, context: Dict) -> bool:
|
||||
try:
|
||||
data = {
|
||||
"chatroom_id": context.get("chatroom_id", ""),
|
||||
"wxid": context.get("wxid", ""),
|
||||
"display_name": context.get("display_name", ""),
|
||||
"activity_level": context.get("activity_level", ""),
|
||||
"message_pattern": context.get("message_pattern", ""),
|
||||
"interaction_style": context.get("interaction_style", ""),
|
||||
"response_style_hint": context.get("response_style_hint", ""),
|
||||
"topics_of_interest": json.dumps(context.get("topics_of_interest", []), ensure_ascii=False),
|
||||
"recent_focus": json.dumps(context.get("recent_focus", []), ensure_ascii=False),
|
||||
"summary_text": context.get("summary_text", ""),
|
||||
"confidence": context.get("confidence", 0),
|
||||
"source_message_count": context.get("source_message_count", 0),
|
||||
"source_days": context.get("source_days", 30),
|
||||
"meta_json": json.dumps(context.get("meta", {}), ensure_ascii=False),
|
||||
"last_profiled_at": context.get("last_profiled_at", datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||||
}
|
||||
|
||||
fields = ", ".join(data.keys())
|
||||
placeholders = ", ".join(["%s"] * len(data))
|
||||
update_clause = ", ".join(
|
||||
[f"{k}=VALUES({k})" for k in data.keys() if k not in ("chatroom_id", "wxid")]
|
||||
)
|
||||
sql = f"""
|
||||
INSERT INTO t_member_context ({fields})
|
||||
VALUES ({placeholders})
|
||||
ON DUPLICATE KEY UPDATE {update_clause}
|
||||
"""
|
||||
return self.execute_update(sql, tuple(data.values()))
|
||||
except Exception as e:
|
||||
self.LOG.error(f"保存群成员交互摘要失败: {e}")
|
||||
return False
|
||||
|
||||
def get_member_context(self, chatroom_id: str, wxid: str) -> Optional[Dict]:
|
||||
try:
|
||||
sql = """
|
||||
SELECT *
|
||||
FROM t_member_context
|
||||
WHERE chatroom_id = %s AND wxid = %s
|
||||
LIMIT 1
|
||||
"""
|
||||
result = self.execute_query(sql, (chatroom_id, wxid), fetch_one=True)
|
||||
return self._deserialize_row(result)
|
||||
except Exception as e:
|
||||
self.LOG.error(f"获取群成员交互摘要失败: {e}")
|
||||
return None
|
||||
|
||||
def list_group_member_contexts(self, chatroom_id: str) -> List[Dict]:
|
||||
try:
|
||||
sql = """
|
||||
SELECT *
|
||||
FROM t_member_context
|
||||
WHERE chatroom_id = %s
|
||||
ORDER BY last_profiled_at DESC
|
||||
"""
|
||||
results = self.execute_query(sql, (chatroom_id,)) or []
|
||||
return [self._deserialize_row(row) for row in results]
|
||||
except Exception as e:
|
||||
self.LOG.error(f"获取群成员交互摘要列表失败: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _deserialize_row(row: Optional[Dict]) -> Optional[Dict]:
|
||||
if not row:
|
||||
return row
|
||||
|
||||
for key in ("topics_of_interest", "recent_focus", "meta_json"):
|
||||
value = row.get(key)
|
||||
if not value:
|
||||
row[key] = [] if key != "meta_json" else {}
|
||||
continue
|
||||
try:
|
||||
row[key] = json.loads(value)
|
||||
except Exception:
|
||||
row[key] = [] if key != "meta_json" else {}
|
||||
|
||||
last_profiled_at = row.get("last_profiled_at")
|
||||
if isinstance(last_profiled_at, datetime):
|
||||
row["last_profiled_at"] = last_profiled_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
row["meta"] = row.get("meta_json", {})
|
||||
|
||||
return row
|
||||
@@ -43,6 +43,23 @@ class MessageStorageDB(BaseDBOperator):
|
||||
params = (hours_ago, group_id, min_content_length)
|
||||
return self.execute_query(sql, params) or []
|
||||
|
||||
def get_member_recent_messages(self, group_id: str, wxid: str, days: int = 30, limit: int = 200) -> List[Dict]:
|
||||
"""获取指定群成员近期消息"""
|
||||
sql = """
|
||||
SELECT timestamp, sender, content, message_type
|
||||
FROM messages
|
||||
WHERE timestamp >= DATE_SUB(NOW(), INTERVAL %s DAY)
|
||||
AND group_id = %s
|
||||
AND sender = %s
|
||||
AND message_type IN (1, 49)
|
||||
AND CHAR_LENGTH(content) BETWEEN 2 AND 500
|
||||
AND content NOT LIKE '/%%'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
results = self.execute_query(sql, (days, group_id, wxid, limit)) or []
|
||||
return list(reversed(results))
|
||||
|
||||
def get_message_count_by_date(self, date: str) -> List[Dict]:
|
||||
"""获取指定日期的消息统计"""
|
||||
sql = """
|
||||
|
||||
6
plugins/member_context/__init__.py
Normal file
6
plugins/member_context/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from plugins.member_context.main import MemberContextPlugin
|
||||
|
||||
|
||||
def get_plugin():
|
||||
"""获取插件实例"""
|
||||
return MemberContextPlugin()
|
||||
20
plugins/member_context/config.toml
Normal file
20
plugins/member_context/config.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[general]
|
||||
enable = true
|
||||
|
||||
[api]
|
||||
enable = true
|
||||
base_url = "http://192.168.2.240/v1"
|
||||
api_key = "app-URBzTCyx2VB10cTalurJNkcz"
|
||||
endpoint = "completion-messages"
|
||||
request_timeout = 60
|
||||
|
||||
[profile]
|
||||
sample_days = 30
|
||||
sample_message_limit = 80
|
||||
refresh_limit_per_member = 200
|
||||
|
||||
[schedule]
|
||||
refresh_times = ["04:20"]
|
||||
only_recent_active_groups = true
|
||||
active_hours = 72
|
||||
min_group_messages = 20
|
||||
78
plugins/member_context/main.py
Normal file
78
plugins/member_context/main.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from typing import Dict, Any, Tuple, Optional, List
|
||||
|
||||
from base.plugin_common.message_plugin_interface import MessagePluginInterface
|
||||
from base.plugin_common.plugin_interface import PluginStatus
|
||||
from plugins.member_context.service import MemberContextService
|
||||
from utils.decorator.async_job import async_job
|
||||
|
||||
|
||||
class MemberContextPlugin(MessagePluginInterface):
|
||||
"""群成员交互摘要后台插件"""
|
||||
|
||||
FEATURE_KEY = "MEMBER_CONTEXT_CAPABILITY"
|
||||
FEATURE_DESCRIPTION = "🧠 成员交互摘要 [后台AI提取,仅对启用群生效]"
|
||||
|
||||
@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 commands(self) -> List[str]:
|
||||
return []
|
||||
|
||||
@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.service: Optional[MemberContextService] = None
|
||||
self._job_registered = False
|
||||
|
||||
def initialize(self, context: Dict[str, Any]) -> bool:
|
||||
self.LOG.debug(f"正在初始化 {self.name} 插件...")
|
||||
self.service = MemberContextService(context["db_manager"], self._config)
|
||||
refresh_times = self._config.get("schedule", {}).get("refresh_times", [])
|
||||
if refresh_times and not self._job_registered:
|
||||
@async_job.at_times(refresh_times)
|
||||
async def refresh_member_context_job():
|
||||
if self.service:
|
||||
self.LOG.info("开始刷新成员交互摘要")
|
||||
self.service.refresh_all_chatrooms()
|
||||
self.LOG.info("成员交互摘要刷新完成")
|
||||
self._job_registered = True
|
||||
self.LOG.debug(f"{self.name} 插件初始化完成")
|
||||
return True
|
||||
|
||||
def start(self) -> bool:
|
||||
self.LOG.debug(f"[{self.name}] 插件已启动")
|
||||
self.status = PluginStatus.RUNNING
|
||||
return True
|
||||
|
||||
def stop(self) -> bool:
|
||||
self.LOG.info(f"[{self.name}] 插件已停止")
|
||||
self.status = PluginStatus.STOPPED
|
||||
return True
|
||||
|
||||
def can_process(self, message: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
async def process_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
||||
return False, None
|
||||
452
plugins/member_context/service.py
Normal file
452
plugins/member_context/service.py
Normal file
@@ -0,0 +1,452 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from db.connection import DBConnectionManager
|
||||
from db.contacts_db import ContactsDBOperator
|
||||
from db.member_context_db import MemberContextDBOperator
|
||||
from db.message_storage import MessageStorageDB
|
||||
from utils.robot_cmd.robot_command import Feature, GroupBotManager, PermissionStatus
|
||||
|
||||
|
||||
class MemberContextService:
|
||||
"""成员交互摘要插件内部服务"""
|
||||
|
||||
FEATURE_KEY = "MEMBER_CONTEXT_CAPABILITY"
|
||||
|
||||
STOPWORDS = {
|
||||
"这个", "那个", "就是", "然后", "怎么", "什么", "你们", "我们", "他们", "是不是", "可以",
|
||||
"一下", "一个", "已经", "还有", "没有", "因为", "所以", "如果", "但是", "还是", "今天",
|
||||
"昨天", "现在", "时候", "感觉", "真的", "应该", "知道", "觉得", "问题", "老师", "老板",
|
||||
"群里", "大家", "一下子", "自己", "东西", "这里", "那里", "进行", "需要", "关于"
|
||||
}
|
||||
|
||||
def __init__(self, db_manager: DBConnectionManager, plugin_config: Optional[Dict] = None):
|
||||
self.db_manager = db_manager
|
||||
self.contacts_db = ContactsDBOperator(self.db_manager)
|
||||
self.message_db = MessageStorageDB(self.db_manager)
|
||||
self.member_context_db = MemberContextDBOperator(self.db_manager)
|
||||
self.LOG = logger
|
||||
self.plugin_config = plugin_config or {}
|
||||
|
||||
api_config = self.plugin_config.get("api", {})
|
||||
profile_config = self.plugin_config.get("profile", {})
|
||||
|
||||
self.ai_enabled = bool(api_config.get("enabled", False))
|
||||
self.ai_base_url = (api_config.get("base_url") or "").rstrip("/")
|
||||
self.ai_api_key = api_config.get("api_key", "")
|
||||
self.ai_endpoint = str(api_config.get("endpoint", "completion-messages")).lstrip("/")
|
||||
self.ai_timeout = int(api_config.get("request_timeout", 60))
|
||||
self.sample_days = int(profile_config.get("sample_days", 30))
|
||||
self.ai_sample_limit = int(profile_config.get("sample_message_limit", 80))
|
||||
self.refresh_limit_per_member = int(profile_config.get("refresh_limit_per_member", 200))
|
||||
schedule_config = self.plugin_config.get("schedule", {})
|
||||
self.only_recent_active_groups = bool(schedule_config.get("only_recent_active_groups", False))
|
||||
self.active_hours = int(schedule_config.get("active_hours", 72))
|
||||
self.min_group_messages = int(schedule_config.get("min_group_messages", 20))
|
||||
|
||||
def build_member_context(self, chatroom_id: str, wxid: str, days: Optional[int] = None,
|
||||
limit: Optional[int] = None) -> Dict:
|
||||
days = days or self.sample_days
|
||||
limit = limit or self.refresh_limit_per_member
|
||||
|
||||
member = self.contacts_db.get_chatroom_member_info(chatroom_id, wxid) or {}
|
||||
messages = self.message_db.get_member_recent_messages(chatroom_id, wxid, days=days, limit=limit)
|
||||
recent_messages = self.message_db.get_member_recent_messages(chatroom_id, wxid, days=min(days, 7), limit=100)
|
||||
|
||||
display_name = member.get("display_name") or member.get("nick_name") or wxid
|
||||
activity_level = self._calc_activity_level(len(messages), days)
|
||||
message_pattern = self._build_message_pattern(messages)
|
||||
response_style_hint = self._build_response_style_hint(messages)
|
||||
topics = self._extract_keywords(messages, limit=5)
|
||||
recent_focus = self._extract_keywords(recent_messages, limit=4)
|
||||
confidence = self._calc_confidence(len(messages))
|
||||
|
||||
context = {
|
||||
"chatroom_id": chatroom_id,
|
||||
"wxid": wxid,
|
||||
"display_name": display_name,
|
||||
"activity_level": activity_level,
|
||||
"message_pattern": message_pattern,
|
||||
"interaction_style": self._build_interaction_style(messages),
|
||||
"response_style_hint": response_style_hint,
|
||||
"topics_of_interest": topics,
|
||||
"recent_focus": recent_focus,
|
||||
"summary_text": self._build_summary_text(activity_level, message_pattern, response_style_hint, topics, recent_focus),
|
||||
"confidence": confidence,
|
||||
"source_message_count": len(messages),
|
||||
"source_days": days,
|
||||
"last_profiled_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"meta": self._build_meta(messages, recent_messages),
|
||||
}
|
||||
|
||||
ai_context = self._generate_ai_context(chatroom_id, wxid, display_name, context, messages)
|
||||
if ai_context:
|
||||
context.update({
|
||||
"activity_level": ai_context.get("activity_level") or context["activity_level"],
|
||||
"message_pattern": ai_context.get("message_pattern") or context["message_pattern"],
|
||||
"interaction_style": ai_context.get("interaction_style") or context["interaction_style"],
|
||||
"response_style_hint": ai_context.get("response_style_hint") or context["response_style_hint"],
|
||||
"topics_of_interest": ai_context.get("topics_of_interest") or context["topics_of_interest"],
|
||||
"recent_focus": ai_context.get("recent_focus") or context["recent_focus"],
|
||||
"summary_text": ai_context.get("summary_text") or context["summary_text"],
|
||||
"confidence": ai_context.get("confidence", context["confidence"]),
|
||||
})
|
||||
context["meta"].update(ai_context.get("meta", {}))
|
||||
return context
|
||||
|
||||
def refresh_member_context(self, chatroom_id: str, wxid: str, days: Optional[int] = None,
|
||||
limit: Optional[int] = None) -> Dict:
|
||||
if not self.is_group_enabled(chatroom_id):
|
||||
raise ValueError(f"群 {chatroom_id} 未启用成员交互摘要功能")
|
||||
context = self.build_member_context(chatroom_id, wxid, days=days, limit=limit)
|
||||
self.member_context_db.save_member_context(context)
|
||||
return context
|
||||
|
||||
def refresh_group_contexts(self, chatroom_id: str, days: Optional[int] = None,
|
||||
limit_per_member: Optional[int] = None) -> Dict:
|
||||
days = days or self.sample_days
|
||||
limit_per_member = limit_per_member or self.refresh_limit_per_member
|
||||
|
||||
if not self.is_group_enabled(chatroom_id):
|
||||
self.LOG.info(f"群 {chatroom_id} 未启用成员交互摘要功能,跳过刷新")
|
||||
return {"refreshed": 0, "skipped": 0, "disabled": True}
|
||||
|
||||
members = self.contacts_db.get_chatroom_member_list(chatroom_id) or []
|
||||
refreshed = 0
|
||||
skipped = 0
|
||||
|
||||
for member in members:
|
||||
if member.get("status", 1) != 1:
|
||||
continue
|
||||
wxid = member.get("wxid")
|
||||
if not wxid:
|
||||
continue
|
||||
context = self.build_member_context(chatroom_id, wxid, days=days, limit=limit_per_member)
|
||||
if context["source_message_count"] <= 0:
|
||||
skipped += 1
|
||||
continue
|
||||
self.member_context_db.save_member_context(context)
|
||||
refreshed += 1
|
||||
|
||||
return {"refreshed": refreshed, "skipped": skipped}
|
||||
|
||||
def refresh_all_chatrooms(self, days: Optional[int] = None, limit_per_member: Optional[int] = None) -> Dict:
|
||||
days = days or self.sample_days
|
||||
limit_per_member = limit_per_member or self.refresh_limit_per_member
|
||||
|
||||
groups = self.contacts_db.get_chatroom_list() or []
|
||||
active_group_ids = self._get_recent_active_chatrooms() if self.only_recent_active_groups else None
|
||||
group_count = 0
|
||||
member_count = 0
|
||||
skipped = 0
|
||||
disabled = 0
|
||||
inactive = 0
|
||||
|
||||
for group in groups:
|
||||
chatroom_id = group.get("chatroom_id")
|
||||
if not chatroom_id:
|
||||
continue
|
||||
if active_group_ids is not None and chatroom_id not in active_group_ids:
|
||||
inactive += 1
|
||||
continue
|
||||
result = self.refresh_group_contexts(chatroom_id, days=days, limit_per_member=limit_per_member)
|
||||
if result.get("disabled"):
|
||||
disabled += 1
|
||||
continue
|
||||
group_count += 1
|
||||
member_count += result["refreshed"]
|
||||
skipped += result["skipped"]
|
||||
|
||||
self.LOG.info(f"成员交互摘要刷新完成: 启用活跃群={group_count}, 成员={member_count}, 跳过={skipped}, 未启用群={disabled}, 非活跃群={inactive}")
|
||||
return {"groups": group_count, "members": member_count, "skipped": skipped, "disabled_groups": disabled, "inactive_groups": inactive}
|
||||
|
||||
def is_group_enabled(self, chatroom_id: str) -> bool:
|
||||
feature = Feature.get_feature(self.FEATURE_KEY)
|
||||
if feature is None:
|
||||
return True
|
||||
return GroupBotManager.get_group_permission(chatroom_id, feature) == PermissionStatus.ENABLED
|
||||
|
||||
def _calc_activity_level(self, message_count: int, days: int) -> str:
|
||||
daily_avg = message_count / max(days, 1)
|
||||
if message_count >= 80 or daily_avg >= 3:
|
||||
return "高活跃"
|
||||
if message_count >= 25 or daily_avg >= 1:
|
||||
return "中活跃"
|
||||
if message_count > 0:
|
||||
return "低活跃"
|
||||
return "观察中"
|
||||
|
||||
def _build_message_pattern(self, messages: List[Dict]) -> str:
|
||||
if not messages:
|
||||
return "样本较少,暂不做明显模式判断"
|
||||
|
||||
contents = [m.get("content", "") for m in messages if m.get("content")]
|
||||
if not contents:
|
||||
return "样本较少,暂不做明显模式判断"
|
||||
|
||||
avg_len = sum(len(c) for c in contents) / len(contents)
|
||||
question_ratio = sum(1 for c in contents if "?" in c or "?" in c) / len(contents)
|
||||
link_ratio = sum(1 for c in contents if "http://" in c or "https://" in c) / len(contents)
|
||||
|
||||
traits = []
|
||||
if avg_len <= 12:
|
||||
traits.append("短句居多")
|
||||
elif avg_len >= 35:
|
||||
traits.append("表达较完整")
|
||||
else:
|
||||
traits.append("表达中等长度")
|
||||
|
||||
if question_ratio >= 0.35:
|
||||
traits.append("问题导向明显")
|
||||
elif question_ratio >= 0.15:
|
||||
traits.append("偶尔连续追问")
|
||||
|
||||
if link_ratio >= 0.15:
|
||||
traits.append("常分享链接或资料")
|
||||
|
||||
if not traits:
|
||||
traits.append("发言较平稳")
|
||||
return ",".join(traits)
|
||||
|
||||
def _build_response_style_hint(self, messages: List[Dict]) -> str:
|
||||
if not messages:
|
||||
return "样本不足时保持中性、简洁、避免过度熟络"
|
||||
|
||||
contents = [m.get("content", "") for m in messages if m.get("content")]
|
||||
avg_len = sum(len(c) for c in contents) / max(len(contents), 1)
|
||||
question_ratio = sum(1 for c in contents if "?" in c or "?" in c) / max(len(contents), 1)
|
||||
|
||||
if question_ratio >= 0.35:
|
||||
return "优先给明确结论,再补充步骤或依据,避免空泛回应"
|
||||
if avg_len <= 12:
|
||||
return "回复尽量简洁直接,先回答核心点,减少铺垫"
|
||||
if avg_len >= 35:
|
||||
return "可以给稍完整的解释,但保持结构清楚,避免冗长"
|
||||
return "保持自然口语化,结论和解释尽量平衡"
|
||||
|
||||
def _build_interaction_style(self, messages: List[Dict]) -> str:
|
||||
if not messages:
|
||||
return "互动样本较少"
|
||||
contents = [m.get("content", "") for m in messages if m.get("content")]
|
||||
question_ratio = sum(1 for c in contents if "?" in c or "?" in c) / max(len(contents), 1)
|
||||
emoji_ratio = sum(1 for c in contents if re.search(r"[\U0001F300-\U0001FAFF\u2600-\u27BF]", c)) / max(len(contents), 1)
|
||||
mention_ratio = sum(1 for c in contents if "@" in c) / max(len(contents), 1)
|
||||
|
||||
parts = []
|
||||
if question_ratio >= 0.3:
|
||||
parts.append("偏提问推进")
|
||||
if emoji_ratio >= 0.15:
|
||||
parts.append("表情互动感较强")
|
||||
if mention_ratio >= 0.1:
|
||||
parts.append("会主动点名互动")
|
||||
if not parts:
|
||||
parts.append("自然跟随式互动")
|
||||
return ",".join(parts)
|
||||
|
||||
def _extract_keywords(self, messages: List[Dict], limit: int = 5) -> List[str]:
|
||||
counter = Counter()
|
||||
for message in messages:
|
||||
content = message.get("content", "")
|
||||
for token in self._tokenize(content):
|
||||
if token in self.STOPWORDS:
|
||||
continue
|
||||
counter[token] += 1
|
||||
return [word for word, _ in counter.most_common(limit)]
|
||||
|
||||
def _tokenize(self, text: str) -> List[str]:
|
||||
chinese_words = re.findall(r"[\u4e00-\u9fff]{2,6}", text)
|
||||
english_words = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,20}", text)
|
||||
return chinese_words + [word.lower() for word in english_words]
|
||||
|
||||
def _calc_confidence(self, message_count: int) -> float:
|
||||
return round(min(0.95, math.log(message_count + 1, 10)), 2) if message_count > 0 else 0.1
|
||||
|
||||
def _build_summary_text(self, activity_level: str, message_pattern: str,
|
||||
response_style_hint: str, topics: List[str], recent_focus: List[str]) -> str:
|
||||
parts = [
|
||||
f"近期互动强度:{activity_level}",
|
||||
f"表达特征:{message_pattern}",
|
||||
f"回复建议:{response_style_hint}",
|
||||
]
|
||||
if topics:
|
||||
parts.append(f"长期关注:{'、'.join(topics)}")
|
||||
if recent_focus:
|
||||
parts.append(f"近期话题:{'、'.join(recent_focus)}")
|
||||
return ";".join(parts)
|
||||
|
||||
def _build_meta(self, messages: List[Dict], recent_messages: List[Dict]) -> Dict:
|
||||
latest_time = None
|
||||
if recent_messages:
|
||||
latest = recent_messages[-1].get("timestamp")
|
||||
if isinstance(latest, datetime):
|
||||
latest_time = latest.strftime("%Y-%m-%d %H:%M:%S")
|
||||
elif latest:
|
||||
latest_time = str(latest)
|
||||
|
||||
return {
|
||||
"message_count_30d": len(messages),
|
||||
"message_count_7d": len(recent_messages),
|
||||
"latest_message_time": latest_time,
|
||||
}
|
||||
|
||||
def _get_recent_active_chatrooms(self) -> set:
|
||||
sql = """
|
||||
SELECT group_id, COUNT(*) AS msg_count
|
||||
FROM messages
|
||||
WHERE group_id LIKE %s
|
||||
AND timestamp >= DATE_SUB(NOW(), INTERVAL %s HOUR)
|
||||
GROUP BY group_id
|
||||
HAVING COUNT(*) >= %s
|
||||
"""
|
||||
rows = self.message_db.execute_query(sql, ("%@chatroom", self.active_hours, self.min_group_messages)) or []
|
||||
return {row.get("group_id") for row in rows if row.get("group_id")}
|
||||
|
||||
def _generate_ai_context(self, chatroom_id: str, wxid: str, display_name: str,
|
||||
base_context: Dict, messages: List[Dict]) -> Optional[Dict]:
|
||||
if not self.ai_enabled or not self.ai_base_url or not self.ai_api_key:
|
||||
return None
|
||||
if len(messages) < 8:
|
||||
return None
|
||||
|
||||
prompt = self._build_ai_prompt(chatroom_id, wxid, display_name, base_context, messages[-self.ai_sample_limit:])
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.ai_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"inputs": {"query": prompt},
|
||||
"response_mode": "blocking",
|
||||
"user": f"member-context:{chatroom_id}:{wxid}",
|
||||
}
|
||||
url = f"{self.ai_base_url}/{self.ai_endpoint}"
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=self.ai_timeout)
|
||||
response.raise_for_status()
|
||||
response_data = response.json()
|
||||
parsed = self._parse_ai_answer(response_data.get("answer", ""))
|
||||
if not parsed:
|
||||
return None
|
||||
usage = (response_data.get("metadata") or {}).get("usage", {}) or {}
|
||||
parsed["meta"] = {
|
||||
"ai_provider": "dify",
|
||||
"ai_mode": "completion",
|
||||
"ai_tokens": usage.get("total_tokens"),
|
||||
"ai_latency": usage.get("latency"),
|
||||
}
|
||||
return parsed
|
||||
except Exception as e:
|
||||
self.LOG.warning(f"成员交互摘要 AI 生成失败,回退到本地摘要: chatroom={chatroom_id}, wxid={wxid}, error={e}")
|
||||
return None
|
||||
|
||||
def _build_ai_prompt(self, chatroom_id: str, wxid: str, display_name: str,
|
||||
base_context: Dict, messages: List[Dict]) -> str:
|
||||
message_lines = []
|
||||
for msg in messages[-40:]:
|
||||
ts = msg.get("timestamp")
|
||||
if isinstance(ts, datetime):
|
||||
ts = ts.strftime("%m-%d %H:%M")
|
||||
content = (msg.get("content") or "").replace("\n", " ").strip()
|
||||
content = content[:160]
|
||||
if content:
|
||||
message_lines.append(f"[{ts}] {content}")
|
||||
|
||||
topics = "、".join(base_context.get("topics_of_interest", [])) or "无明显长期话题"
|
||||
recent_focus = "、".join(base_context.get("recent_focus", [])) or "无明显近期话题"
|
||||
|
||||
return (
|
||||
"你是一个微信群运营后台的成员交互摘要提取器。\n"
|
||||
"你的任务不是做人设分析,也不是性格判断,而是基于公开聊天记录,提取对后续回复策略有帮助的“交互特征摘要”。\n"
|
||||
"你只能依据给定聊天样本输出保守结论,不能脑补,不能做敏感推断,不能写负面标签,不能输出隐私猜测。\n"
|
||||
"请根据以下成员近30天公开发言,输出一个严格 JSON 对象,不要 markdown,不要解释,不要代码块。\n"
|
||||
"JSON schema:\n"
|
||||
"{"
|
||||
"\"activity_level\":\"高活跃|中活跃|低活跃|观察中\","
|
||||
"\"message_pattern\":\"一句中文,描述表达特点\","
|
||||
"\"interaction_style\":\"一句中文,描述他在群里如何与人互动\","
|
||||
"\"response_style_hint\":\"一句中文,描述适合怎样回应\","
|
||||
"\"topics_of_interest\":[\"主题1\",\"主题2\"],"
|
||||
"\"recent_focus\":[\"近期主题1\",\"近期主题2\"],"
|
||||
"\"summary_text\":\"一段不超过120字的后台交互摘要\","
|
||||
"\"confidence\":0.0,"
|
||||
"\"engagement_traits\":[\"特征1\",\"特征2\"],"
|
||||
"\"reply_taboos\":[\"避坑1\",\"避坑2\"]"
|
||||
"}\n"
|
||||
"要求:\n"
|
||||
"1. 只总结群内公开行为特征,不要输出性格诊断、负面标签或敏感结论。\n"
|
||||
"2. topics_of_interest 表示相对稳定的话题偏好,最多5个;recent_focus 表示近期频繁提及的话题,最多4个。\n"
|
||||
"3. message_pattern 只能描述可观察到的表达方式,例如:短句居多、问题导向、爱发链接、解释较完整、常接梗互动。\n"
|
||||
"4. interaction_style 要描述他在群里的参与方式,例如:偏围观后插话、喜欢接梗、会连续追问、偏一对一回应。\n"
|
||||
"5. response_style_hint 只能写对回复策略有帮助的建议,例如:先给结论再补步骤、保持简洁直接、可以适度接梗;不要写成评价语。\n"
|
||||
"6. engagement_traits 最多4个,写成中性的短标签,例如:节奏快、爱追问细节、接梗自然、偏结果导向。\n"
|
||||
"7. reply_taboos 最多3个,只写回复时应避免的方式,例如:避免长篇铺垫、避免过度说教、避免太官方。\n"
|
||||
"8. summary_text 要像后台备注,客观、中性、克制,不要让人一眼看出是在给用户贴标签。\n"
|
||||
"9. confidence 取值 0 到 1;如果样本较少或不稳定,必须降低 confidence。\n"
|
||||
"10. 如果证据不足,宁可输出更弱、更泛化的结论,也不要瞎猜。\n\n"
|
||||
"下面是正反例参考。\n"
|
||||
"坏例子:这个人情绪化、爱抬杠、虚荣、玻璃心。\n"
|
||||
"好例子:常用短句直接表达观点;遇到问题时更适合先给明确结论,再补充解释。\n\n"
|
||||
f"成员标识: {display_name} ({wxid})\n"
|
||||
f"群ID: {chatroom_id}\n"
|
||||
f"样本消息数: {base_context.get('source_message_count', 0)}\n"
|
||||
f"本地活跃度估计: {base_context.get('activity_level', '')}\n"
|
||||
f"本地表达特征: {base_context.get('message_pattern', '')}\n"
|
||||
f"本地互动风格: {base_context.get('interaction_style', '')}\n"
|
||||
f"本地回复建议: {base_context.get('response_style_hint', '')}\n"
|
||||
f"本地长期关注: {topics}\n"
|
||||
f"本地近期话题: {recent_focus}\n"
|
||||
"最近消息样本:\n"
|
||||
+ "\n".join(message_lines)
|
||||
)
|
||||
|
||||
def _parse_ai_answer(self, answer: str) -> Optional[Dict]:
|
||||
if not answer:
|
||||
return None
|
||||
text = answer.strip()
|
||||
match = re.search(r"\{.*\}", text, re.S)
|
||||
if match:
|
||||
text = match.group(0)
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
topics = data.get("topics_of_interest") or []
|
||||
recent_focus = data.get("recent_focus") or []
|
||||
engagement_traits = data.get("engagement_traits") or []
|
||||
reply_taboos = data.get("reply_taboos") or []
|
||||
if not isinstance(topics, list):
|
||||
topics = []
|
||||
if not isinstance(recent_focus, list):
|
||||
recent_focus = []
|
||||
if not isinstance(engagement_traits, list):
|
||||
engagement_traits = []
|
||||
if not isinstance(reply_taboos, list):
|
||||
reply_taboos = []
|
||||
|
||||
try:
|
||||
confidence = float(data.get("confidence", 0))
|
||||
except Exception:
|
||||
confidence = 0.0
|
||||
|
||||
return {
|
||||
"activity_level": str(data.get("activity_level", "")).strip(),
|
||||
"message_pattern": str(data.get("message_pattern", "")).strip(),
|
||||
"interaction_style": str(data.get("interaction_style", "")).strip(),
|
||||
"response_style_hint": str(data.get("response_style_hint", "")).strip(),
|
||||
"topics_of_interest": [str(item).strip() for item in topics[:5] if str(item).strip()],
|
||||
"recent_focus": [str(item).strip() for item in recent_focus[:4] if str(item).strip()],
|
||||
"summary_text": str(data.get("summary_text", "")).strip(),
|
||||
"confidence": max(0.0, min(1.0, confidence)),
|
||||
"meta": {
|
||||
"engagement_traits": [str(item).strip() for item in engagement_traits[:4] if str(item).strip()],
|
||||
"reply_taboos": [str(item).strip() for item in reply_taboos[:3] if str(item).strip()],
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user