feat(话题分析): 用户头像引用展示

- TopicAnalyzer: Prompt中包含 [user_id],引导LLM使用ID引用用户
- ReportGenerator: 解析话题详情中的 [uid] 并替换为内联头像HTML
- Templates: 更新所有主题的 topic_item.html 以支持富文本详情展示
- _conf_schema.json: 更新默认 Prompt,指导 LLM 使用 [用户ID] 引用用户
- ReportGenerator: 实现胶囊样式渲染 (头像+昵称),提升视觉体验
This commit is contained in:
SXP-Simon
2026-02-10 20:57:51 +08:00
parent c793858f59
commit 472c78e622
11 changed files with 108 additions and 9 deletions
+1
View File
@@ -11,3 +11,4 @@ data/cmd_config.json
data/t2i_templates/astrbot_powershell.html
data/t2i_templates/base.html
.sisyphus/
group-insight-main/
+1 -1
View File
@@ -316,7 +316,7 @@
"type": "text",
"editor_mode": true,
"editor_language": "markdown",
"default": "你是一个帮我进行群聊信息总结的助手,生成总结内容时,你需要严格遵守下面的几个准则:\n\n请分析接下来提供的群聊记录,提取出最多 **{max_topics}** 个主要话题。\n\n## 对于每个话题,请提供:\n\n1. **话题名称**(突出主题内容,尽量简明扼要)\n2. **主要参与者**(最多5人)\n3. **话题详细描述**(包含关键信息和结论)\n\n## 注意事项:\n\n- 对于比较有价值的点,稍微用一两句话详细讲讲,比如不要生成 \"Nolan 和 SOV 讨论了 galgame 中关于性符号的衍生情况\" 这种宽泛的内容,而是生成更加具体的讨论内容,让其他人只看这个消息就能知道讨论中有价值的、有营养的信息。\n- 对于其中的部分信息,你需要特意提到主题施加的主体是谁,是哪个群友做了什么事情,而不要直接生成和群友没有关系的语句。\n- 对于每一条总结,尽量讲清楚前因后果,以及话题的结论:是什么、为什么、怎么做。如果用户没有讲到细节,则可以不用这么做。\n\n## 群聊记录:\n\n{messages_text}\n\n---\n\n## 重要:必须返回标准 JSON 格式\n\n严格遵守以下规则:\n\n1. 只使用英文双引号 `\"` ,不要使用中文引号 `\"` `\"`\n2. 字符串内容中的引号必须转义为 `\\\"`\n3. 多个对象之间用逗号分隔\n4. 数组元素之间用逗号分隔\n5. 不要在 JSON 外添加任何文字说明\n6. 描述内容避免使用特殊符号,用普通文字表达\n\n### 返回格式示例:\n\n```json\n[\n {{\n \"topic\": \"话题名称\",\n \"contributors\": [\"用户1\", \"用户2\"],\n \"detail\": \"话题描述内容\"\n }},\n {{\n \"topic\": \"另一个话题\",\n \"contributors\": [\"用户3\", \"用户4\"],\n \"detail\": \"另一个话题的描述\"\n }}\n]\n```\n\n**注意**:返回的内容必须是纯 JSON,不要包含 markdown 代码块标记或其他格式。"
"default": "你是一个帮我进行群聊信息总结的助手,生成总结内容时,你需要严格遵守下面的几个准则:\n\n请分析接下来提供的群聊记录,提取出最多 **{max_topics}** 个主要话题。\n\n## 对于每个话题,请提供:\n\n1. **话题名称**(突出主题内容,尽量简明扼要)\n2. **主要参与者**(最多5人)\n3. **话题详细描述**(包含关键信息和结论)\n\n## 注意事项:\n\n- 对于比较有价值的点,稍微用一两句话详细讲讲,比如不要生成 \"Nolan 和 SOV 讨论了 galgame 中关于性符号的衍生情况\" 这种宽泛的内容,而是生成更加具体的讨论内容,让其他人只看这个消息就能知道讨论中有价值的、有营养的信息。\n- 对于其中的部分信息,你需要特意提到主题施加的主体是谁,是哪个群友做了什么事情,而不要直接生成和群友没有关系的语句。\n- 对于每一条总结,尽量讲清楚前因后果,以及话题的结论:是什么、为什么、怎么做。如果用户没有讲到细节,则可以不用这么做。\\n- **用户引用**:在话题详情描述中,如果提到了具体用户,请使用 `[用户ID]` 的格式来指代(例如 `[123456]`)。不要只写昵称。我们会自动渲染头像。\\n\\n## 群聊记录:\n\n{messages_text}\n\n---\n\n## 重要:必须返回标准 JSON 格式\n\n严格遵守以下规则:\n\n1. 只使用英文双引号 `\"` ,不要使用中文引号 `\"` `\"`\n2. 字符串内容中的引号必须转义为 `\\\"`\n3. 多个对象之间用逗号分隔\n4. 数组元素之间用逗号分隔\n5. 不要在 JSON 外添加任何文字说明\n6. 描述内容避免使用特殊符号,用普通文字表达\n\n### 返回格式示例:\n\n```json\n[\n {{\n \"topic\": \"话题名称\",\n \"contributors\": [\"用户1\", \"用户2\"],\n \"detail\": \"话题描述内容\"\n }},\n {{\n \"topic\": \"另一个话题\",\n \"contributors\": [\"用户3\", \"用户4\"],\n \"detail\": \"另一个话题的描述\"\n }}\n]\n```\n\n**注意**:返回的内容必须是纯 JSON,不要包含 markdown 代码块标记或其他格式。"
}
}
},
@@ -309,7 +309,12 @@ class AnalysisApplicationService:
# 8. 构建 IncrementalBatch
# 8a. 转换话题: SummaryTopic -> dict
new_topics = [
{"topic": t.topic, "contributors": t.contributors, "detail": t.detail}
{
"topic": t.topic,
"contributors": t.contributors,
"detail": t.detail,
"contributor_ids": t.contributor_ids,
}
for t in topics
]
+1
View File
@@ -13,6 +13,7 @@ class SummaryTopic:
topic: str
contributors: list[str]
detail: str
contributor_ids: list[str] = field(default_factory=list) # 贡献者ID列表 (用于显示头像)
@dataclass
@@ -237,6 +237,7 @@ class IncrementalMergeService:
topic=topic_dict.get("topic", "未知话题"),
contributors=topic_dict.get("contributors", []),
detail=topic_dict.get("detail", ""),
contributor_ids=topic_dict.get("contributor_ids", []),
)
topics.append(topic)
@@ -130,7 +130,12 @@ class TopicAnalyzer(BaseAnalyzer):
cleaned_text = re.sub(r"[\x00-\x1f\x7f-\x9f]", "", cleaned_text)
text_messages.append(
{"sender": nickname, "time": msg_time, "content": cleaned_text}
{
"sender": nickname,
"time": msg_time,
"content": cleaned_text,
"user_id": str(user_id),
}
)
except Exception as e:
logger.error(
@@ -145,7 +150,7 @@ class TopicAnalyzer(BaseAnalyzer):
# 构建消息文本
messages_text = "\n".join(
[
f"[{msg['time']}] {msg['sender']}: {msg['content']}"
f"[{msg['time']}] [{msg['user_id']}] {msg['sender']}: {msg['content']}"
for msg in text_messages
]
)
@@ -289,6 +294,7 @@ class TopicAnalyzer(BaseAnalyzer):
"sender": nickname,
"time": msg_time,
"content": cleaned_text.strip(),
"user_id": str(sender.get("user_id", "")),
}
)
return text_messages
@@ -332,8 +338,34 @@ class TopicAnalyzer(BaseAnalyzer):
logger.debug(f"第一条文本消息类型: {type(text_messages[0])}")
logger.debug(f"第一条文本消息内容: {text_messages[0]}")
# 建立昵称到ID的映射表
nickname_to_id = {}
for msg in text_messages:
sender = msg.get("sender")
user_id = msg.get("user_id")
if sender and user_id:
nickname_to_id[sender] = user_id
# 直接传入原始消息,让 build_prompt 方法处理
return await self.analyze(messages, umo, session_id)
topics, usage = await self.analyze(messages, umo, session_id)
# 回填贡献者 ID
for topic in topics:
ids = set()
for contributor in topic.contributors:
# 尝试精确匹配
if contributor in nickname_to_id:
ids.add(nickname_to_id[contributor])
else:
# 尝试模糊匹配(LLM可能会简化名字)
for nick, uid in nickname_to_id.items():
if contributor in nick or nick in contributor:
ids.add(uid)
# 找到一个匹配即可,避免过度匹配
break
topic.contributor_ids = list(ids)
return topics, usage
except Exception as e:
logger.error(f"话题分析失败: {e}", exc_info=True)
@@ -237,12 +237,19 @@ class ReportGenerator(IReportGenerator):
# 使用Jinja2模板构建话题HTML(批量渲染)
max_topics = self.config_manager.get_max_topics()
topics_list = []
user_analysis = analysis_result.get("user_analysis")
for i, topic in enumerate(topics[:max_topics], 1):
# 处理话题详情中的用户引用头像
processed_detail = await self._process_topic_detail(
topic.detail, avatar_getter, user_analysis
)
topics_list.append(
{
"index": i,
"topic": topic,
"contributors": "".join(topic.contributors),
"detail": processed_detail,
}
)
@@ -330,6 +337,58 @@ class ReportGenerator(IReportGenerator):
logger.info(f"渲染数据准备完成,包含 {len(render_data)} 个字段")
return render_data
async def _process_topic_detail(
self, detail: str, avatar_getter, user_analysis: dict = None
) -> str:
"""
处理话题详情,将 [123456] 格式的用户引用替换为头像+名称的胶囊样式
"""
import re
pattern = r"\[(\d+)\]"
matches = re.findall(pattern, detail)
if not matches:
return detail
unique_ids = set(matches)
avatars = {}
# 并发获取头像
for uid in unique_ids:
avatars[uid] = await self._get_user_avatar(uid, avatar_getter)
def replacer(match):
uid = match.group(1)
url = avatars.get(uid)
name = None
if user_analysis and uid in user_analysis:
name = user_analysis[uid].get("name")
if url and name:
# 胶囊样式 (Capsule Style)
capsule_style = (
"display:inline-flex;align-items:center;background:rgba(0,0,0,0.05);"
"padding:2px 6px 2px 2px;border-radius:12px;margin:0 2px;"
"vertical-align:middle;border:1px solid rgba(0,0,0,0.1);text-decoration:none;"
)
img_style = "width:18px;height:18px;border-radius:50%;margin-right:4px;display:block;"
name_style = "font-size:0.85em;color:inherit;font-weight:500;line-height:1;"
return (
f'<span class="user-capsule" style="{capsule_style}">'
f'<img src="{url}" style="{img_style}">'
f'<span style="{name_style}">{name}</span>'
f'</span>'
)
elif url:
# 仅有头像回退
return f'<img class="user-avatar-inline" src="{url}" style="width:1.3em;height:1.3em;border-radius:50%;vertical-align:text-bottom;margin:0 2px;">'
return match.group(0)
return re.sub(pattern, replacer, detail)
def _render_html_template(self, template: str, data: dict) -> str:
"""HTML模板渲染,使用 {{key}} 占位符格式
@@ -9,7 +9,7 @@
<span class="topic-title">{{ topic.topic.topic }}</span>
</div>
<div class="topic-contributors">参与者: {{ topic.contributors }}</div>
<div class="topic-detail">{{ topic.topic.detail }}</div>
<div class="topic-detail">{{ topic.detail }}</div>
</div>
{% endfor %}
</div>
@@ -10,7 +10,7 @@
<div class="topic-content">
<h3 class="topic-title">{{ topic.topic.topic }}</h3>
<p class="topic-meta">PARTICIPANTS: {{ topic.contributors }}</p>
<p class="topic-detail">{{ topic.topic.detail }}</p>
<p class="topic-detail">{{ topic.detail }}</p>
</div>
</article>
{% endfor %}
@@ -26,7 +26,7 @@
style="font-family: var(--font-hand); font-size: 1.05em; color: var(--ink-secondary); margin-bottom: 8px;">
🙋‍♀️ 参与者: {{ topic.contributors }}
</div>
<div class="topic-detail">{{ topic.topic.detail }}</div>
<div class="topic-detail">{{ topic.detail }}</div>
</div>
</div>
{% endfor %}
@@ -5,7 +5,7 @@
<div style="margin-bottom: 10px; border-bottom: 1px dashed #ccc; padding-bottom: 5px;">
<strong>#{{ item.index }} {{ item.topic.topic }}</strong>
<span style="color: #666; font-size: 0.8em;">(参与者: {{ item.contributors }})</span>
<p style="margin: 5px 0;">{{ item.topic.detail }}</p>
<p style="margin: 5px 0;">{{ item.detail }}</p>
</div>
{% endfor %}
</div>