feat(chat_quality_analysis): chat_quality_analysis 模块

This commit is contained in:
SXP-Simon
2026-03-23 00:42:18 +08:00
committed by Helian Nuits
parent f40243492f
commit 9a35ed9600
26 changed files with 1655 additions and 178 deletions
+34 -1
View File
@@ -148,6 +148,13 @@
"default": "", "default": "",
"hint": "专门用于金句分析的 Provider。留空则使用主 LLM Provider" "hint": "专门用于金句分析的 Provider。留空则使用主 LLM Provider"
}, },
"quality_provider_id": {
"type": "string",
"description": "聊天质量分析专用 Provider ID",
"_special": "select_provider",
"default": "",
"hint": "专门用于聊天质量锐评的 Provider。留空则使用主 LLM Provider"
},
"llm_retries": { "llm_retries": {
"type": "int", "type": "int",
"description": "LLM 请求重试次数", "description": "LLM 请求重试次数",
@@ -177,6 +184,12 @@
"description": "(兼容部分提供商)用户称号分析最大 Token 数", "description": "(兼容部分提供商)用户称号分析最大 Token 数",
"default": 4096, "default": 4096,
"hint": "(兼容部分提供商,实测大部分模型调整后没有明显效果)用户称号分析时 LLM 能生成的最大 token 数量。当分析内容较多或者分析提示词复杂时,建议适当调大此值以保证输出质量。" "hint": "(兼容部分提供商,实测大部分模型调整后没有明显效果)用户称号分析时 LLM 能生成的最大 token 数量。当分析内容较多或者分析提示词复杂时,建议适当调大此值以保证输出质量。"
},
"quality_max_tokens": {
"type": "int",
"description": "(兼容部分提供商)聊天质量分析最大 Token 数",
"default": 4096,
"hint": "聊天质量分析时 LLM 能生成的最大 token 数量。"
} }
} }
}, },
@@ -203,6 +216,12 @@
"default": true, "default": true,
"hint": "是否使用LLM进行金句分析" "hint": "是否使用LLM进行金句分析"
}, },
"chat_quality_analysis_enabled": {
"type": "bool",
"description": "启用聊天质量锐评",
"default": true,
"hint": "是否使用LLM进行聊天质量锐评(维度化分析)"
},
"max_topics": { "max_topics": {
"type": "int", "type": "int",
"description": "最大话题数量", "description": "最大话题数量",
@@ -407,7 +426,21 @@
"type": "text", "type": "text",
"editor_mode": true, "editor_mode": true,
"editor_language": "markdown", "editor_language": "markdown",
"default": "请从以下群聊记录中挑选出 **{max_golden_quotes}** 句最具冲击力、最令人惊叹的「金句」。\n\n## 金句标准:\n\n- **核心标准**:**逆天的神人发言**,即具备颠覆常识的脑洞、逻辑跳脱的表达或强烈反差感的原创内容\n- **典型特征**:包含某些争议话题元素、夸张类比、反常规结论、一本正经的「胡说八道」或突破语境的清奇思路,并且具备一定的冲击力,让人印象深刻\n\n## 对于每个金句,请提供:\n\n1. **原文内容**(完整保留发言细节)\n2. **发言人用户ID**(必须严格使用消息记录中提供的 [用户ID])\n3. **选择理由**(具体说明其「逆天」之处,如逻辑颠覆点/脑洞角度/反差感/争议话题元素)\n\n## 严格约束:\n\n- 优先筛选 **逆天指数最高** 的内容:\n - 发情、性压抑话题 > 争议话题 > 元素级 > 颠覆认知级 > 逻辑跳脱级 > 趣味调侃级\n - 剔除单纯玩梗或网络热词堆砌的普通发言\n- **用户引用**:在选择理由(reason)中,如果提到了具体用户,请使用 `[用户ID]` 的格式来指代(例如 `[123456]`)。不要只写昵称。我们会自动渲染头像。\n- **身份对齐**:返回的 `sender` 字段必须是 `[用户ID]` 格式(例如 `[123456]`)。我们会根据 ID 自动还原昵称和头像。\n\n## 群聊记录格式: [HH:MM] [用户ID]: 消息内容\n\n## 群聊记录:\n\n{messages_text}\n\n---\n\n### 返回格式示例:\n\n```json\n[\n {{\n \"content\": \"金句原文\",\n \"sender\": \"[123456789]\",\n \"reason\": \"这句话太逆天了,尤其是对 [987654321] 的逻辑降维打击。\"\n }}\n]\n```\n\n**注意**请以纯 JSON 格式返回,不要包含 markdown 代码块标记。" "default": "请从以下群聊记录中挑选出 **{max_golden_quotes}** 句最具冲击力、最令人惊叹的「金句」。\n\n## 金句标准:\n\n- **核心标准**:**逆天的神人发言**,即具备颠覆常识的脑洞、逻辑跳脱的表达或强烈反差感的原创内容\n- **典型特征**:包含某些争议话题元素、夸张类比、反常规结论、一本正经的「胡说八道」或突破语境的清奇思路,并且具备一定的冲击力,让人印象深刻\n\n## 对于每个金句,请提供:\n\n1. **原文内容**(完整保留发言细节)\n2. **发言人用户ID**(必须严格使用消息记录中提供的 [用户ID])\n3. **选择理由**(具体说明其「逆天」之处,如逻辑颠覆点/脑洞角度/反差感/争议话题元素)\n\n## 严格约束:\n\n- 优先筛选 **逆天指数最高** 的内容:\n - 发情、性压抑话题 > 争议话题 > 元素级 > 颠覆认知级 > 逻辑跳脱级 > 趣味调侃级\n - 剔除单纯玩梗或网络热词堆砌的普通发言\n- **用户引用**:在选择理由(reason)中,如果提到了具体用户,请使用 `[用户ID]` 的格式来指代(例如 `[123456]`)。不要只写昵称。我们会自动渲染头像。\n- **身份对齐**:返回的 `sender` 字段必须是 `[用户ID]` 格式(例如 `[123456]`)。我们会根据 ID 自动还原昵称和头像。\n\n## 群聊记录格式: [HH:MM] [用户ID]: 消息内容\n\n## 群聊记录:\n\n{messages_text}\n\n---\n\n### 返回格式示例:\n\n```json\n[\n {{\n \"content\": \"金句原文\",\n \"sender\": \"[123456789]\",\n \"reason\": \"这句话太逆天了,尤其是对 [987654321] 的逻辑降维打击。\"\n }}\n]\n```\n\n**注意**返回的内容必须是纯 JSON,不要包含 markdown 代码块标记或其他格式。"
}
}
},
"quality_analysis_prompts": {
"description": "聊天质量分析提示词模板",
"type": "object",
"hint": "聊天质量分析提示词模板,可自定义修改",
"items": {
"quality_prompt": {
"description": "默认聊天质量分析提示词",
"type": "text",
"editor_mode": true,
"editor_language": "markdown",
"default": "你是一个毒舌且幽默的群聊质量分析师,请生成聊天质量锐评内容。你需要按照下面的格式返回结果。\n\n分析接下来的群聊内容,给出多个维度的分析(3-6个)和汇总内容。\n\n群聊记录:\n{messages_text}\n\n### 返回格式示例:\n\n```json\n{{\n \"title\": \"群聊质量分析报告\",\n \"subtitle\": \"今天的群里发生了什么?\",\n \"dimensions\": [\n {{\n \"name\": \"水群闲聊\",\n \"percentage\": 50.0,\n \"comment\": \"群友们今天也在努力划水呢。\"\n }}\n ],\n \"summary\": \"今天也是元气满满的一天。\"\n}}\n```"
} }
} }
} }
+54 -17
View File
@@ -49,17 +49,18 @@ astrbot_path.get_astrbot_data_path = lambda: Path(".")
sys.modules["astrbot.core.utils"] = astrbot_core_utils sys.modules["astrbot.core.utils"] = astrbot_core_utils
sys.modules["astrbot.core.utils.astrbot_path"] = astrbot_path sys.modules["astrbot.core.utils.astrbot_path"] = astrbot_path
from src.domain.entities.analysis_result import ( # noqa: E402 from src.domain.models.data_models import ( # noqa: E402
ActivityVisualization, ActivityVisualization,
EmojiStatistics, EmojiStatistics,
GoldenQuote, GoldenQuote,
GroupStatistics, GroupStatistics,
QualityDimension,
QualityReview,
SummaryTopic, SummaryTopic,
TokenUsage, TokenUsage,
UserTitle, UserTitle,
) )
from src.infrastructure.reporting.generators import ReportGenerator # noqa: E402 from src.infrastructure.reporting.generators import ReportGenerator # noqa: E402
from src.infrastructure.reporting.templates import HTMLTemplates # noqa: E402
class MockConfigManager: class MockConfigManager:
@@ -94,6 +95,12 @@ class MockConfigManager:
def get_browser_path(self) -> str: def get_browser_path(self) -> str:
return "" return ""
def get_t2i_max_concurrent(self) -> int:
return 4
def get_llm_max_concurrent(self) -> int:
return 2
async def mock_get_user_avatar(user_id: str) -> str: async def mock_get_user_avatar(user_id: str) -> str:
# Return a known avatar URL for testing # Return a known avatar URL for testing
@@ -106,12 +113,13 @@ async def debug_render(
# 1. Setup Mock Data # 1. Setup Mock Data
config_manager = MockConfigManager(template_name) config_manager = MockConfigManager(template_name)
# 2. Mock Analysis Result using Entities # 2. Mock Analysis Result using Data Models
stats = GroupStatistics( stats = GroupStatistics(
message_count=1250, message_count=1250,
total_characters=45000, total_characters=45000,
participant_count=42, participant_count=42,
most_active_period="20:00 - 22:00", most_active_period="20:00 - 22:00",
golden_quotes=[], # Will be filled later
emoji_count=156, emoji_count=156,
emoji_statistics=EmojiStatistics(face_count=100, mface_count=56), emoji_statistics=EmojiStatistics(face_count=100, mface_count=56),
activity_visualization=ActivityVisualization( activity_visualization=ActivityVisualization(
@@ -119,6 +127,40 @@ async def debug_render(
i: (10 + i * 5 if i < 12 else 100 - i * 2) for i in range(24) i: (10 + i * 5 if i < 12 else 100 - i * 2) for i in range(24)
} }
), ),
token_usage=TokenUsage(
prompt_tokens=1500, completion_tokens=800, total_tokens=2300
),
chat_quality_review=QualityReview(
title="互联网难民收容所",
subtitle="只要不工作,我们就是最好的朋友",
dimensions=[
QualityDimension(
"水群闲聊",
44.0,
"这里的群友不生产代码,只生产各种表情包和废话,建议送去加个班。",
"#607d8b",
),
QualityDimension(
"技术探讨",
25.5,
"偶尔冒出的技术术语像是在荒漠里发现绿洲,虽然很快就被废话淹没了。",
"#2196f3",
),
QualityDimension(
"深夜发情",
15.0,
"凌晨三点的群聊内容需要打上 R18 标签,建议各位群友早点休息。",
"#f44336",
),
QualityDimension(
"就业焦虑",
10.5,
"谈到工作时群里笼罩着一股淡淡的忧伤,大家都在比谁的工位更像牢房。",
"#ff9800",
),
],
summary="今天也是充满活力(或者说充满废话)的一天,继续保持这份不求上进的快乐吧。",
),
) )
topics = [ topics = [
@@ -257,9 +299,7 @@ async def debug_render(
] ]
stats.golden_quotes = golden_quotes stats.golden_quotes = golden_quotes
stats.token_usage = TokenUsage( # token_usage already set in constructor
prompt_tokens=1500, completion_tokens=800, total_tokens=2300
)
analysis_result = { analysis_result = {
"statistics": stats, "statistics": stats,
@@ -270,6 +310,7 @@ async def debug_render(
"987654321": {"nickname": "李四"}, "987654321": {"nickname": "李四"},
"112233445": {"nickname": "潜水员"}, "112233445": {"nickname": "潜水员"},
}, },
"chat_quality_review": stats.chat_quality_review,
"analysis_date": "2026年02月11日", "analysis_date": "2026年02月11日",
"group_id": "123456", "group_id": "123456",
"group_name": "测试群组", "group_name": "测试群组",
@@ -285,22 +326,18 @@ async def debug_render(
# Note: _prepare_render_data handles converting Entities to template-friendly dicts # Note: _prepare_render_data handles converting Entities to template-friendly dicts
render_payload = await generator._prepare_render_data(analysis_result) render_payload = await generator._prepare_render_data(analysis_result)
# 5. Render Main Template # Use Jinja2 renderer
html_templates = HTMLTemplates(config_manager) final_html = generator.html_templates.render_template(
# Get image template string "image_template.html", **render_payload
raw_template = html_templates.get_image_template() )
if not raw_template:
print(f"[ERROR] Failed to load template for '{template_name}'")
return
# Use generator's internal renderer
final_html = generator._render_html_template(raw_template, render_payload)
# 6. Save to file # 6. Save to file
output_path = Path(output_file) output_path = Path(output_file)
output_path.write_text(final_html, encoding="utf-8") output_path.write_text(final_html, encoding="utf-8")
# 7. Close generator
await generator.close()
print( print(
f"Successfully rendered template '{template_name}' to {output_path.absolute()}" f"Successfully rendered template '{template_name}' to {output_path.absolute()}"
) )
@@ -182,10 +182,14 @@ class AnalysisApplicationService:
golden_quote_enabled = ( golden_quote_enabled = (
self.config_manager.get_golden_quote_analysis_enabled() self.config_manager.get_golden_quote_analysis_enabled()
) )
chat_quality_enabled = (
self.config_manager.get_chat_quality_analysis_enabled()
)
topics = [] topics = []
user_titles = [] user_titles = []
golden_quotes = [] golden_quotes = []
chat_quality_review = None
total_token_usage = TokenUsage() total_token_usage = TokenUsage()
# Note: LLMAnalyzer 目前可能只接收 legacy 格式或特定的 UnifiedMessage 适配 # Note: LLMAnalyzer 目前可能只接收 legacy 格式或特定的 UnifiedMessage 适配
@@ -198,7 +202,12 @@ class AnalysisApplicationService:
f"{platform_id}:GroupMessage:{group_id}" if platform_id else group_id f"{platform_id}:GroupMessage:{group_id}" if platform_id else group_id
) )
if topic_enabled or user_title_enabled or golden_quote_enabled: if (
topic_enabled
or user_title_enabled
or golden_quote_enabled
or chat_quality_enabled
):
async with self.llm_semaphore: async with self.llm_semaphore:
logger.debug(f"[LLM] 已进入分析队列 (群: {group_id})") logger.debug(f"[LLM] 已进入分析队列 (群: {group_id})")
( (
@@ -206,6 +215,7 @@ class AnalysisApplicationService:
user_titles, user_titles,
golden_quotes, golden_quotes,
total_token_usage, total_token_usage,
chat_quality_review,
) = await self.llm_analyzer.analyze_all_concurrent( ) = await self.llm_analyzer.analyze_all_concurrent(
legacy_messages, legacy_messages,
user_activity, user_activity,
@@ -214,6 +224,7 @@ class AnalysisApplicationService:
topic_enabled=topic_enabled, topic_enabled=topic_enabled,
user_title_enabled=user_title_enabled, user_title_enabled=user_title_enabled,
golden_quote_enabled=golden_quote_enabled, golden_quote_enabled=golden_quote_enabled,
chat_quality_enabled=chat_quality_enabled,
) )
# 回填结果 # 回填结果
@@ -225,6 +236,7 @@ class AnalysisApplicationService:
"topics": topics, "topics": topics,
"user_titles": user_titles, "user_titles": user_titles,
"user_analysis": user_activity, "user_analysis": user_activity,
"chat_quality_review": chat_quality_review,
} }
# 6. 持久化摘要 (Persistence) # 6. 持久化摘要 (Persistence)
@@ -355,6 +367,9 @@ class AnalysisApplicationService:
golden_quote_enabled = ( golden_quote_enabled = (
self.config_manager.get_golden_quote_analysis_enabled() self.config_manager.get_golden_quote_analysis_enabled()
) )
chat_quality_enabled = (
self.config_manager.get_chat_quality_analysis_enabled()
)
# 需要将 UnifiedMessage 转换为 legacy 格式供 LLM 分析器使用 # 需要将 UnifiedMessage 转换为 legacy 格式供 LLM 分析器使用
legacy_messages = self.statistics_service._convert_to_legacy_dict( legacy_messages = self.statistics_service._convert_to_legacy_dict(
@@ -364,20 +379,28 @@ class AnalysisApplicationService:
f"{platform_id}:GroupMessage:{group_id}" if platform_id else group_id f"{platform_id}:GroupMessage:{group_id}" if platform_id else group_id
) )
async with self.llm_semaphore: topics = []
logger.debug(f"[LLM] 已进入增量分析队列 (群: {group_id})") golden_quotes = []
( token_usage = TokenUsage()
topics, chat_quality_review = None
golden_quotes,
token_usage, if topic_enabled or golden_quote_enabled or chat_quality_enabled:
) = await self.llm_analyzer.analyze_incremental_concurrent( async with self.llm_semaphore:
legacy_messages, logger.debug(f"[LLM] 已进入增量分析队列 (群: {group_id})")
umo=unified_msg_origin, (
topics_per_batch=topics_per_batch, topics,
quotes_per_batch=quotes_per_batch, golden_quotes,
topic_enabled=topic_enabled, token_usage,
golden_quote_enabled=golden_quote_enabled, chat_quality_review,
) ) = await self.llm_analyzer.analyze_incremental_concurrent(
legacy_messages,
umo=unified_msg_origin,
topics_per_batch=topics_per_batch,
quotes_per_batch=quotes_per_batch,
topic_enabled=topic_enabled,
golden_quote_enabled=golden_quote_enabled,
chat_quality_enabled=chat_quality_enabled,
)
# 8. 构建 IncrementalBatch # 8. 构建 IncrementalBatch
# 8a. 转换话题: SummaryTopic -> dict # 8a. 转换话题: SummaryTopic -> dict
@@ -424,7 +447,25 @@ class AnalysisApplicationService:
"face_details": statistics.emoji_statistics.face_details, "face_details": statistics.emoji_statistics.face_details,
} }
# 8f. 获取参与者 ID 和最后消息时间戳 # 8f. 转换聊天质量锐评: QualityReview -> dict
chat_quality_dict = None
if chat_quality_review:
chat_quality_dict = {
"title": chat_quality_review.title,
"subtitle": chat_quality_review.subtitle,
"dimensions": [
{
"name": d.name,
"percentage": d.percentage,
"comment": d.comment,
"color": d.color,
}
for d in chat_quality_review.dimensions
],
"summary": chat_quality_review.summary,
}
# 8g. 获取参与者 ID 和最后消息时间戳
participant_ids = list({msg.sender_id for msg in unified_messages}) participant_ids = list({msg.sender_id for msg in unified_messages})
last_message_timestamp = max( last_message_timestamp = max(
(msg.timestamp for msg in unified_messages), default=0 (msg.timestamp for msg in unified_messages), default=0
@@ -446,6 +487,7 @@ class AnalysisApplicationService:
topics=new_topics, topics=new_topics,
golden_quotes=new_quotes, golden_quotes=new_quotes,
token_usage=token_usage_dict, token_usage=token_usage_dict,
chat_quality_review=chat_quality_dict,
last_message_timestamp=last_message_timestamp, last_message_timestamp=last_message_timestamp,
participant_ids=participant_ids, participant_ids=participant_ids,
) )
+5
View File
@@ -39,6 +39,7 @@ class IncrementalBatch:
topics: 本批次提取的话题列表 topics: 本批次提取的话题列表
golden_quotes: 本批次提取的金句列表 golden_quotes: 本批次提取的金句列表
token_usage: 本批次 token 消耗 {prompt_tokens, completion_tokens, total_tokens} token_usage: 本批次 token 消耗 {prompt_tokens, completion_tokens, total_tokens}
chat_quality_review: 本批次提取的聊天质量锐评
last_message_timestamp: 本批次最后一条消息的时间戳 last_message_timestamp: 本批次最后一条消息的时间戳
participant_ids: 本批次参与者 ID 列表 participant_ids: 本批次参与者 ID 列表
""" """
@@ -73,6 +74,7 @@ class IncrementalBatch:
) )
# 增量追踪 # 增量追踪
chat_quality_review: dict[str, Any] | None = None
last_message_timestamp: int = 0 last_message_timestamp: int = 0
participant_ids: list[str] = field(default_factory=list) participant_ids: list[str] = field(default_factory=list)
@@ -91,6 +93,7 @@ class IncrementalBatch:
"topics": self.topics, "topics": self.topics,
"golden_quotes": self.golden_quotes, "golden_quotes": self.golden_quotes,
"token_usage": self.token_usage, "token_usage": self.token_usage,
"chat_quality_review": self.chat_quality_review,
"last_message_timestamp": self.last_message_timestamp, "last_message_timestamp": self.last_message_timestamp,
"participant_ids": self.participant_ids, "participant_ids": self.participant_ids,
} }
@@ -118,6 +121,7 @@ class IncrementalBatch:
"total_tokens": 0, "total_tokens": 0,
}, },
), ),
chat_quality_review=data.get("chat_quality_review"),
last_message_timestamp=data.get("last_message_timestamp", 0), last_message_timestamp=data.get("last_message_timestamp", 0),
participant_ids=data.get("participant_ids", []), participant_ids=data.get("participant_ids", []),
) )
@@ -170,6 +174,7 @@ class IncrementalState:
# 合并后的 LLM 分析结果 # 合并后的 LLM 分析结果
topics: list[dict] = field(default_factory=list) topics: list[dict] = field(default_factory=list)
golden_quotes: list[dict] = field(default_factory=list) golden_quotes: list[dict] = field(default_factory=list)
chat_quality_review: dict[str, Any] | None = None
# 合并后的统计数据(按小时) # 合并后的统计数据(按小时)
hourly_message_counts: dict[str, int] = field(default_factory=dict) hourly_message_counts: dict[str, int] = field(default_factory=dict)
+22
View File
@@ -4,6 +4,7 @@
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional
@dataclass @dataclass
@@ -39,6 +40,26 @@ class GoldenQuote:
user_id: str = "" # 原 qq 字段 user_id: str = "" # 原 qq 字段
@dataclass
class QualityDimension:
"""聊天质量维度数据结构"""
name: str # 维度名称
percentage: float # 占比
comment: str # 犀利点评
color: str = "#607d8b" # 颜色
@dataclass
class QualityReview:
"""聊天质量锐评数据结构"""
title: str
subtitle: str
dimensions: list[QualityDimension]
summary: str
@dataclass @dataclass
class TokenUsage: class TokenUsage:
"""Token使用统计""" """Token使用统计"""
@@ -97,3 +118,4 @@ class GroupStatistics:
default_factory=ActivityVisualization default_factory=ActivityVisualization
) )
token_usage: TokenUsage = field(default_factory=TokenUsage) token_usage: TokenUsage = field(default_factory=TokenUsage)
chat_quality_review: Optional["QualityReview"] = None
+17 -3
View File
@@ -5,7 +5,13 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from ..models.data_models import GoldenQuote, SummaryTopic, TokenUsage, UserTitle from ..models.data_models import (
GoldenQuote,
QualityReview,
SummaryTopic,
TokenUsage,
UserTitle,
)
class IAnalysisProvider(ABC): class IAnalysisProvider(ABC):
@@ -55,7 +61,14 @@ class IAnalysisProvider(ABC):
topic_enabled: bool = True, topic_enabled: bool = True,
user_title_enabled: bool = True, user_title_enabled: bool = True,
golden_quote_enabled: bool = True, golden_quote_enabled: bool = True,
) -> tuple[list[SummaryTopic], list[UserTitle], list[GoldenQuote], TokenUsage]: chat_quality_enabled: bool = False,
) -> tuple[
list[SummaryTopic],
list[UserTitle],
list[GoldenQuote],
TokenUsage,
QualityReview | None,
]:
"""并发分析所有内容""" """并发分析所有内容"""
pass pass
@@ -68,6 +81,7 @@ class IAnalysisProvider(ABC):
quotes_per_batch: int = 3, quotes_per_batch: int = 3,
topic_enabled: bool = True, topic_enabled: bool = True,
golden_quote_enabled: bool = True, golden_quote_enabled: bool = True,
) -> tuple[list[SummaryTopic], list[GoldenQuote], TokenUsage]: chat_quality_enabled: bool = False,
) -> tuple[list[SummaryTopic], list[GoldenQuote], TokenUsage, QualityReview | None]:
"""增量模式并发分析""" """增量模式并发分析"""
pass pass
@@ -20,6 +20,8 @@ from ...domain.models.data_models import (
EmojiStatistics, EmojiStatistics,
GoldenQuote, GoldenQuote,
GroupStatistics, GroupStatistics,
QualityDimension,
QualityReview,
SummaryTopic, SummaryTopic,
TokenUsage, TokenUsage,
) )
@@ -175,6 +177,9 @@ class IncrementalMergeService:
# 记录最后分析消息时间戳(取最大值) # 记录最后分析消息时间戳(取最大值)
if batch.last_message_timestamp > state.last_analyzed_message_timestamp: if batch.last_message_timestamp > state.last_analyzed_message_timestamp:
state.last_analyzed_message_timestamp = batch.last_message_timestamp state.last_analyzed_message_timestamp = batch.last_message_timestamp
# 更新锐评为最新批次的 (如果有)
if batch.chat_quality_review:
state.chat_quality_review = batch.chat_quality_review
logger.info( logger.info(
f"合并批次完成: 群={state.group_id}, " f"合并批次完成: 群={state.group_id}, "
@@ -233,6 +238,27 @@ class IncrementalMergeService:
# 获取最活跃时段描述 # 获取最活跃时段描述
most_active_period = state.get_most_active_period() most_active_period = state.get_most_active_period()
# 转换聊天质量锐评 (如果有)
chat_quality_review = None
if state.chat_quality_review:
review_dict = state.chat_quality_review
dimensions_dict = review_dict.get("dimensions", [])
dimensions = [
QualityDimension(
name=d.get("name", "未知"),
percentage=float(d.get("percentage", 0)),
comment=d.get("comment", ""),
color=d.get("color", "#607d8b"),
)
for d in dimensions_dict
]
chat_quality_review = QualityReview(
title=review_dict.get("title", "聊天质量锐评"),
subtitle=review_dict.get("subtitle", "今天的群里发生了什么?"),
dimensions=dimensions,
summary=review_dict.get("summary", "今天也是充满活力的一天。"),
)
statistics = GroupStatistics( statistics = GroupStatistics(
message_count=state.total_message_count, message_count=state.total_message_count,
total_characters=state.total_character_count, total_characters=state.total_character_count,
@@ -243,6 +269,7 @@ class IncrementalMergeService:
emoji_statistics=emoji_statistics, emoji_statistics=emoji_statistics,
activity_visualization=activity_visualization, activity_visualization=activity_visualization,
token_usage=token_usage, token_usage=token_usage,
chat_quality_review=chat_quality_review,
) )
logger.debug( logger.debug(
@@ -335,6 +362,7 @@ class IncrementalMergeService:
"topics": topics, "topics": topics,
"user_titles": user_titles or [], "user_titles": user_titles or [],
"user_analysis": state.user_activities, "user_analysis": state.user_activities,
"chat_quality_review": statistics.chat_quality_review,
} }
logger.info( logger.info(
@@ -0,0 +1,277 @@
"""
聊天质量分析模块
专门处理群聊质量锐评分析
"""
from datetime import datetime
from ....domain.models.data_models import QualityDimension, QualityReview, TokenUsage
from ....utils.logger import logger
from ..utils import InfoUtils
from ..utils.json_utils import extract_quality_with_regex, parse_json_object_response
from ..utils.llm_utils import (
call_provider_with_retry,
extract_response_text,
extract_token_usage,
)
from .base_analyzer import BaseAnalyzer
class ChatQualityAnalyzer(BaseAnalyzer):
"""
聊天质量分析器
专门处理群聊质量的锐评和多维度分析
注意:由于聊天质量分析返回的是 JSON 对象而非数组,
此分析器重写了 analyze() 方法,使用 parse_json_object_response 解析,
并以 extract_quality_with_regex 作为正则降级方案。
"""
def get_provider_id_key(self) -> str:
"""获取 Provider ID 配置键名"""
return "quality_provider_id"
def get_data_type(self) -> str:
"""获取数据类型标识"""
return "聊天质量"
def get_max_count(self) -> int:
"""获取最大维度数量"""
return 8
def get_max_tokens(self) -> int:
"""获取最大token数"""
return self.config_manager.get_quality_max_tokens()
def get_temperature(self) -> float:
"""获取温度参数"""
return 0.8
def build_prompt(self, data: list[dict]) -> str:
"""
构建聊天质量分析提示词
"""
if not data:
return ""
# 提取文本消息
text_messages = []
for msg in data:
if not isinstance(msg, dict):
continue
sender = msg.get("sender", {})
user_id = str(sender.get("user_id", ""))
bot_self_ids = self.config_manager.get_bot_self_ids()
if bot_self_ids and user_id in [str(uid) for uid in bot_self_ids]:
continue
nickname = InfoUtils.get_user_nickname(self.config_manager, sender)
msg_time = datetime.fromtimestamp(msg.get("time", 0)).strftime("%H:%M")
message_list = msg.get("message", [])
text_parts = []
for content in message_list:
if content.get("type") == "text":
text = content.get("data", {}).get("text", "").strip()
if text:
text_parts.append(text)
combined_text = "".join(text_parts).strip()
if combined_text and not combined_text.startswith("/"):
text_messages.append(f"[{msg_time}] [{nickname}]: {combined_text}")
messages_text = "\n".join(text_messages[:1000])
prompt_template = self.config_manager.get_quality_analysis_prompt()
if not prompt_template:
prompt_template = """你是一个毒舌且幽默的群聊质量分析师。
请分析以下群聊记录,输出一份"聊天质量锐评"
## 任务目标:
1. 将聊天内容划分为 3-6 个不同的维度/类别(如:技术探讨、水群闲聊、就业焦虑、深夜发情等)。
2. 为每个维度计算一个大致的百分比占位(总和为 100%)。
3. 为每个维度写一句犀利、幽默、毒舌或温情的点评。
4. 给出一句总结性的全群表现评价。
5. 设定一个本次报告的主题标题和副标题。
## 点评风格指南:
- 语言要接地气,多用互联网黑话。
- 吐槽要精准,避重就轻。
- 如果群友在认真讨论技术,可以夸两句但也要带点调侃。
- 如果群友在无意义水群,请狠狠吐槽。
## 返回格式要求:
必须以纯 JSON 格式返回,不得包含任何 Markdown 格式。
```json
{{
"title": "主题标题 (如: 互联网难民收容所)",
"subtitle": "副标题 (如: 只要不工作,我们就是最好的朋友)",
"dimensions": [
{{
"name": "维度名称",
"percentage": 25.5,
"comment": "维度的毒舌点评"
}}
],
"summary": "一句总结性的金句"
}}
```
群聊记录:
{messages_text}
"""
return prompt_template.format(messages_text=messages_text)
def extract_with_regex(self, result_text: str, max_count: int) -> list[dict]:
"""
使用正则表达式提取质量分析数据(BaseAnalyzer 要求的接口)
注意: 此方法供 BaseAnalyzer.analyze() 的降级流程使用,
但由于聊天质量分析重写了 analyze(),实际由 analyze_quality() 中调用
extract_quality_with_regex 实现。
"""
return []
def create_data_objects(self, data_list: list[dict]) -> list[QualityReview]:
"""
满足 BaseAnalyzer 抽象要求。
聊天质量分析的数据对象创建在 analyze_quality 中完成。
"""
return []
def _build_review_from_dict(self, data: dict) -> QualityReview:
"""
从解析后的字典构建 QualityReview 对象
Args:
data: 解析后的 JSON 对象字典
Returns:
QualityReview 数据对象
"""
dimensions = []
for d in data.get("dimensions", []):
dimensions.append(
QualityDimension(
name=d.get("name", "未知"),
percentage=float(d.get("percentage", 0)),
comment=d.get("comment", ""),
)
)
# 自动分配颜色
colors = [
"#607d8b",
"#2196f3",
"#f44336",
"#e91e63",
"#ff9800",
"#4caf50",
"#009688",
"#9c27b0",
]
for i, d in enumerate(dimensions):
d.color = colors[i % len(colors)]
return QualityReview(
title=data.get("title", "聊天质量锐评"),
subtitle=data.get("subtitle", "今天的群里发生了什么?"),
dimensions=dimensions,
summary=data.get("summary", "今天也是充满活力的一天。"),
)
async def analyze_quality(
self,
messages: list[dict],
umo: str | None = None,
session_id: str | None = None,
) -> tuple[QualityReview | None, TokenUsage]:
"""
分析聊天质量
流程遵循 BaseAnalyzer 的设计模式:
1. 构建 prompt
2. 调用 LLM
3. 提取 token 使用统计
4. JSON 解析(使用 parse_json_object_response
5. 正则降级(使用 extract_quality_with_regex
"""
try:
# 1. 获取人格设定
system_prompt = await self._build_system_prompt(umo)
# 2. 构建 prompt
prompt = self.build_prompt(messages)
if not prompt:
return None, TokenUsage()
# 3. 调用 LLM
response = await call_provider_with_retry(
self.context,
self.config_manager,
prompt=prompt,
max_tokens=self.get_max_tokens(),
temperature=self.get_temperature(),
umo=umo,
provider_id_key=self.get_provider_id_key(),
system_prompt=system_prompt,
)
if response is None:
return None, TokenUsage()
# 4. 提取 token 使用统计
token_usage_dict = extract_token_usage(response)
usage = TokenUsage(
prompt_tokens=token_usage_dict["prompt_tokens"],
completion_tokens=token_usage_dict["completion_tokens"],
total_tokens=token_usage_dict["total_tokens"],
)
# 5. 提取响应文本
result_text = extract_response_text(response)
if not result_text:
return None, usage
# 6. JSON 解析(使用 parse_json_object_response
success, parsed_data, error_msg = parse_json_object_response(
result_text, self.get_data_type()
)
if success and parsed_data:
review = self._build_review_from_dict(parsed_data)
logger.info(f"聊天质量分析成功,解析到 {len(review.dimensions)} 个维度")
return review, usage
# 7. 正则降级(使用 extract_quality_with_regex
logger.warning(f"聊天质量JSON解析失败,尝试正则表达式提取: {error_msg}")
regex_data = extract_quality_with_regex(result_text)
if regex_data:
review = self._build_review_from_dict(regex_data)
logger.info(
f"聊天质量正则提取成功,获得 {len(review.dimensions)} 个维度"
)
return review, usage
# 8. 全部失败
logger.error("聊天质量分析失败: JSON解析和正则表达式提取均未成功")
return None, usage
except Exception as e:
logger.error(f"聊天质量分析解析失败: {e}", exc_info=True)
return None, TokenUsage()
# Override analyze to bridge the base class interface
async def analyze(
self,
data: list[dict],
umo: str | None = None,
session_id: str | None = None,
) -> tuple[list[QualityReview], TokenUsage]:
review, usage = await self.analyze_quality(data, umo, session_id)
return [review] if review else [], usage
+72 -19
View File
@@ -7,12 +7,14 @@ import asyncio
from ...domain.models.data_models import ( from ...domain.models.data_models import (
GoldenQuote, GoldenQuote,
QualityReview,
SummaryTopic, SummaryTopic,
TokenUsage, TokenUsage,
UserTitle, UserTitle,
) )
from ...domain.repositories.analysis_repository import IAnalysisProvider from ...domain.repositories.analysis_repository import IAnalysisProvider
from ...utils.logger import logger from ...utils.logger import logger
from .analyzers.chat_quality_analyzer import ChatQualityAnalyzer
from .analyzers.golden_quote_analyzer import GoldenQuoteAnalyzer from .analyzers.golden_quote_analyzer import GoldenQuoteAnalyzer
from .analyzers.topic_analyzer import TopicAnalyzer from .analyzers.topic_analyzer import TopicAnalyzer
from .analyzers.user_title_analyzer import UserTitleAnalyzer from .analyzers.user_title_analyzer import UserTitleAnalyzer
@@ -46,6 +48,7 @@ class LLMAnalyzer(IAnalysisProvider):
self.topic_analyzer = TopicAnalyzer(context, config_manager) self.topic_analyzer = TopicAnalyzer(context, config_manager)
self.user_title_analyzer = UserTitleAnalyzer(context, config_manager) self.user_title_analyzer = UserTitleAnalyzer(context, config_manager)
self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager) self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager)
self.chat_quality_analyzer = ChatQualityAnalyzer(context, config_manager)
async def analyze_topics( async def analyze_topics(
self, self,
@@ -170,7 +173,14 @@ class LLMAnalyzer(IAnalysisProvider):
topic_enabled: bool = True, topic_enabled: bool = True,
user_title_enabled: bool = True, user_title_enabled: bool = True,
golden_quote_enabled: bool = True, golden_quote_enabled: bool = True,
) -> tuple[list[SummaryTopic], list[UserTitle], list[GoldenQuote], TokenUsage]: chat_quality_enabled: bool = False,
) -> tuple[
list[SummaryTopic],
list[UserTitle],
list[GoldenQuote],
TokenUsage,
QualityReview | None,
]:
""" """
并发执行所有分析任务(话题、用户称号、金句),支持按需启用。 并发执行所有分析任务(话题、用户称号、金句),支持按需启用。
@@ -230,8 +240,16 @@ class LLMAnalyzer(IAnalysisProvider):
) )
task_names.append("golden_quote") task_names.append("golden_quote")
if chat_quality_enabled:
tasks.append(
self.chat_quality_analyzer.analyze_quality(
messages, umo, session_id
)
)
task_names.append("chat_quality")
if not tasks: if not tasks:
return [], [], [], TokenUsage() return [], [], [], TokenUsage(), None
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -239,6 +257,8 @@ class LLMAnalyzer(IAnalysisProvider):
topics, topic_usage = [], TokenUsage() topics, topic_usage = [], TokenUsage()
user_titles, title_usage = [], TokenUsage() user_titles, title_usage = [], TokenUsage()
golden_quotes, quote_usage = [], TokenUsage() golden_quotes, quote_usage = [], TokenUsage()
chat_quality_review = None
quality_usage = TokenUsage() # Initialize here
for i, result in enumerate(results): for i, result in enumerate(results):
name = task_names[i] name = task_names[i]
@@ -252,38 +272,52 @@ class LLMAnalyzer(IAnalysisProvider):
user_titles, title_usage = result user_titles, title_usage = result
elif name == "golden_quote" and isinstance(result, tuple): elif name == "golden_quote" and isinstance(result, tuple):
golden_quotes, quote_usage = result golden_quotes, quote_usage = result
elif name == "chat_quality" and isinstance(result, tuple):
chat_quality_review, quality_usage = result
if not isinstance(quality_usage, TokenUsage):
quality_usage = TokenUsage()
# 合并Token使用统计 # 合并Token使用统计
total_usage = TokenUsage( total_usage = TokenUsage(
prompt_tokens=topic_usage.prompt_tokens prompt_tokens=topic_usage.prompt_tokens
+ title_usage.prompt_tokens + title_usage.prompt_tokens
+ quote_usage.prompt_tokens, + quote_usage.prompt_tokens
+ quality_usage.prompt_tokens,
completion_tokens=topic_usage.completion_tokens completion_tokens=topic_usage.completion_tokens
+ title_usage.completion_tokens + title_usage.completion_tokens
+ quote_usage.completion_tokens, + quote_usage.completion_tokens
+ quality_usage.completion_tokens,
total_tokens=topic_usage.total_tokens total_tokens=topic_usage.total_tokens
+ title_usage.total_tokens + title_usage.total_tokens
+ quote_usage.total_tokens, + quote_usage.total_tokens
+ quality_usage.total_tokens,
) )
logger.info( logger.info(
f"并发分析完成 - 话题: {len(topics)}, 称号: {len(user_titles)}, 金句: {len(golden_quotes)}" f"并发分析完成 - 话题: {len(topics)}, 称号: {len(user_titles)}, 金句: {len(golden_quotes)}, 质量锐评: {1 if chat_quality_review else 0}"
)
return (
topics,
user_titles,
golden_quotes,
total_usage,
chat_quality_review,
) )
return topics, user_titles, golden_quotes, total_usage
except Exception as e: except Exception as e:
logger.error(f"并发分析失败: {e}") logger.error(f"并发分析失败: {e}")
return [], [], [], TokenUsage() return [], [], [], TokenUsage(), None
async def analyze_incremental_concurrent( async def analyze_incremental_concurrent(
self, self,
messages: list[dict], messages: list[dict],
umo: str | None = None, umo: str | None = None,
topics_per_batch: int = 3, topics_per_batch: int = 2,
quotes_per_batch: int = 3, quotes_per_batch: int = 1,
topic_enabled: bool = True, topic_enabled: bool = True,
golden_quote_enabled: bool = True, golden_quote_enabled: bool = True,
) -> tuple[list[SummaryTopic], list[GoldenQuote], TokenUsage]: chat_quality_enabled: bool = False,
) -> tuple[list[SummaryTopic], list[GoldenQuote], TokenUsage, QualityReview | None]:
""" """
增量分析模式的并发执行方法。 增量分析模式的并发执行方法。
仅执行话题分析和金句分析(用户称号分析在最终报告时执行), 仅执行话题分析和金句分析(用户称号分析在最终报告时执行),
@@ -311,7 +345,7 @@ class LLMAnalyzer(IAnalysisProvider):
session_id = f"incr_{timestamp}" session_id = f"incr_{timestamp}"
logger.info( logger.info(
f"开始增量并发分析 (话题:{topic_enabled}/{topics_per_batch}, 金句:{golden_quote_enabled}/{quotes_per_batch})" f"开始增量并发分析 (话题:{topic_enabled}/{topics_per_batch}, 金句:{golden_quote_enabled}/{quotes_per_batch}, 质量锐评:{chat_quality_enabled})"
f"消息数量: {len(messages)},会话ID: {session_id}" f"消息数量: {len(messages)},会话ID: {session_id}"
) )
@@ -342,14 +376,24 @@ class LLMAnalyzer(IAnalysisProvider):
) )
task_names.append("golden_quote") task_names.append("golden_quote")
if chat_quality_enabled:
tasks.append(
self.chat_quality_analyzer.analyze_quality(
messages, umo, session_id
)
)
task_names.append("chat_quality")
if not tasks: if not tasks:
return [], [], TokenUsage() return [], [], TokenUsage(), None
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
# 处理结果 # 处理结果
topics, topic_usage = [], TokenUsage() topics, topic_usage = [], TokenUsage()
golden_quotes, quote_usage = [], TokenUsage() golden_quotes, quote_usage = [], TokenUsage()
chat_quality_review = None
quality_usage = TokenUsage()
for i, result in enumerate(results): for i, result in enumerate(results):
name = task_names[i] name = task_names[i]
@@ -361,20 +405,29 @@ class LLMAnalyzer(IAnalysisProvider):
topics, topic_usage = result topics, topic_usage = result
elif name == "golden_quote" and isinstance(result, tuple): elif name == "golden_quote" and isinstance(result, tuple):
golden_quotes, quote_usage = result golden_quotes, quote_usage = result
elif name == "chat_quality" and isinstance(result, tuple):
chat_quality_review, quality_usage = result
if not isinstance(quality_usage, TokenUsage):
quality_usage = TokenUsage()
# 合并Token使用统计 # 合并Token使用统计
total_usage = TokenUsage( total_usage = TokenUsage(
prompt_tokens=topic_usage.prompt_tokens + quote_usage.prompt_tokens, prompt_tokens=topic_usage.prompt_tokens
+ quote_usage.prompt_tokens
+ quality_usage.prompt_tokens,
completion_tokens=topic_usage.completion_tokens completion_tokens=topic_usage.completion_tokens
+ quote_usage.completion_tokens, + quote_usage.completion_tokens
total_tokens=topic_usage.total_tokens + quote_usage.total_tokens, + quality_usage.completion_tokens,
total_tokens=topic_usage.total_tokens
+ quote_usage.total_tokens
+ quality_usage.total_tokens,
) )
logger.info( logger.info(
f"增量并发分析完成 - 话题: {len(topics)}, 金句: {len(golden_quotes)}, " f"增量并发分析完成 - 话题: {len(topics)}, 金句: {len(golden_quotes)}, 质量锐评: {1 if chat_quality_review else 0}, "
f"Token消耗: {total_usage.total_tokens}" f"Token消耗: {total_usage.total_tokens}"
) )
return topics, golden_quotes, total_usage return topics, golden_quotes, total_usage, chat_quality_review
finally: finally:
# 无论成功或失败,都要恢复原始的最大数量设置 # 无论成功或失败,都要恢复原始的最大数量设置
@@ -383,7 +436,7 @@ class LLMAnalyzer(IAnalysisProvider):
except Exception as e: except Exception as e:
logger.error(f"增量并发分析失败: {e}", exc_info=True) logger.error(f"增量并发分析失败: {e}", exc_info=True)
return [], [], TokenUsage() return [], [], TokenUsage(), None
def _save_debug_messages(self, messages: list[dict], session_id: str): def _save_debug_messages(self, messages: list[dict], session_id: str):
""" """
@@ -6,9 +6,11 @@
from .info_utils import InfoUtils from .info_utils import InfoUtils
from .json_utils import ( from .json_utils import (
extract_golden_quotes_with_regex, extract_golden_quotes_with_regex,
extract_quality_with_regex,
extract_topics_with_regex, extract_topics_with_regex,
extract_user_titles_with_regex, extract_user_titles_with_regex,
fix_json, fix_json,
parse_json_object_response,
parse_json_response, parse_json_response,
) )
from .llm_utils import ( from .llm_utils import (
@@ -18,16 +20,18 @@ from .llm_utils import (
) )
__all__ = [ __all__ = [
# JSON处理工具 # JSON processing utilities
"fix_json", "fix_json",
"parse_json_response", "parse_json_response",
"parse_json_object_response",
"extract_topics_with_regex", "extract_topics_with_regex",
"extract_user_titles_with_regex", "extract_user_titles_with_regex",
"extract_golden_quotes_with_regex", "extract_golden_quotes_with_regex",
# LLM工具 "extract_quality_with_regex",
# LLM utilities
"call_provider_with_retry", "call_provider_with_retry",
"extract_token_usage", "extract_token_usage",
"extract_response_text", "extract_response_text",
# 信息工具 # Info utilities
"InfoUtils", "InfoUtils",
] ]
+128 -1
View File
@@ -85,7 +85,7 @@ def parse_json_response(
result_text: str, data_type: str result_text: str, data_type: str
) -> tuple[bool, list[dict] | None, str | None]: ) -> tuple[bool, list[dict] | None, str | None]:
""" """
统一的JSON解析方法 统一的JSON解析方法(用于JSON数组响应)
Args: Args:
result_text: LLM返回的原始文本 result_text: LLM返回的原始文本
@@ -134,6 +134,78 @@ def parse_json_response(
return False, None, error_msg return False, None, error_msg
def parse_json_object_response(
result_text: str, data_type: str
) -> tuple[bool, dict | None, str | None]:
"""
统一的JSON解析方法(用于JSON对象响应,如聊天质量分析)
与 parse_json_response 不同,此函数用于解析返回单个 JSON 对象 {...}
而非数组 [{...}, {...}] 的场景。
解析策略:
1. 先去除 markdown 代码块标记
2. 直接解析原始 JSON(避免 fix_json 破坏中文引号等合法内容)
3. 若直接解析失败,再使用 fix_json 修复后重试
Args:
result_text: LLM返回的原始文本
data_type: 数据类型标识(用于日志)
Returns:
(成功标志, 解析后的字典, 错误消息)
"""
try:
# 1. 去除 markdown 代码块标记
raw_text = result_text.strip()
raw_text = re.sub(r"```(?:json)?\s*", "", raw_text)
raw_text = re.sub(r"```\s*$", "", raw_text)
raw_text = raw_text.strip()
# 2. 提取 JSON 对象
json_match = re.search(r"\{.*\}", raw_text, re.DOTALL)
if not json_match:
error_msg = f"{data_type}响应中未找到JSON对象"
logger.warning(error_msg)
return False, None, error_msg
json_text = json_match.group()
logger.debug(f"{data_type}分析JSON原文: {json_text[:500]}...")
# 3. 尝试直接解析(保留原始文本,避免中文引号被破坏)
try:
data = json.loads(json_text)
logger.info(f"{data_type}直接解析成功")
return True, data, None
except json.JSONDecodeError:
logger.debug(f"{data_type}直接解析失败,尝试修复JSON...")
# 4. 使用 fix_json 修复后重试
fixed_json = fix_json(json_text)
fixed_match = re.search(r"\{.*\}", fixed_json, re.DOTALL)
if fixed_match:
try:
data = json.loads(fixed_match.group())
logger.info(f"{data_type}修复后解析成功")
return True, data, None
except json.JSONDecodeError as e:
error_msg = f"{data_type}JSON修复后解析仍失败: {e}"
logger.warning(error_msg)
return False, None, error_msg
error_msg = f"{data_type}修复后未找到JSON对象"
return False, None, error_msg
except json.JSONDecodeError as e:
error_msg = f"{data_type}JSON解析失败: {e}"
logger.warning(error_msg)
return False, None, error_msg
except Exception as e:
error_msg = f"{data_type}解析异常: {e}"
logger.error(error_msg)
return False, None, error_msg
def extract_topics_with_regex(result_text: str, max_topics: int) -> list[dict]: def extract_topics_with_regex(result_text: str, max_topics: int) -> list[dict]:
""" """
使用正则表达式提取话题信息 使用正则表达式提取话题信息
@@ -280,3 +352,58 @@ def extract_golden_quotes_with_regex(result_text: str, max_count: int) -> list[d
except Exception as e: except Exception as e:
logger.error(f"金句正则表达式提取失败: {e}") logger.error(f"金句正则表达式提取失败: {e}")
return [] return []
def extract_quality_with_regex(result_text: str) -> dict | None:
"""
使用正则表达式提取聊天质量分析数据
当 JSON 解析失败时作为降级方案使用。
Args:
result_text: LLM 返回的原始文本
Returns:
解析后的质量分析字典,失败返回 None
"""
try:
title_m = re.search(r'"title"\s*:\s*"([^"]*(?:\\.[^"]*)*)"', result_text)
subtitle_m = re.search(r'"subtitle"\s*:\s*"([^"]*(?:\\.[^"]*)*)"', result_text)
summary_m = re.search(r'"summary"\s*:\s*"([^"]*(?:\\.[^"]*)*)"', result_text)
# Extract dimensions array
dims_match = re.search(r'"dimensions"\s*:\s*\[(.*?)\]', result_text, re.DOTALL)
dims = []
if dims_match:
dim_objects = re.findall(
r'\{[^}]*"name"\s*:\s*"([^"]*)"[^}]*'
r'"percentage"\s*:\s*([\d.]+)[^}]*'
r'"comment"\s*:\s*"([^"]*(?:\\.[^"]*)*)"[^}]*\}',
dims_match.group(1),
)
for dm in dim_objects:
dims.append(
{
"name": dm[0],
"percentage": float(dm[1]),
"comment": dm[2],
}
)
if not dims:
logger.warning("聊天质量正则提取未找到有效维度数据")
return None
data = {
"title": title_m.group(1) if title_m else "聊天质量锐评",
"subtitle": subtitle_m.group(1) if subtitle_m else "今天的群里发生了什么?",
"dimensions": dims,
"summary": summary_m.group(1) if summary_m else "今天也是充满活力的一天。",
}
logger.info(f"聊天质量正则表达式提取成功,提取到 {len(dims)} 个维度")
return data
except Exception as e:
logger.error(f"聊天质量正则表达式提取失败: {e}")
return None
@@ -164,6 +164,12 @@ class ConfigManager:
"golden_quote_analysis_enabled", True "golden_quote_analysis_enabled", True
) )
def get_chat_quality_analysis_enabled(self) -> bool:
"""获取是否启用聊天质量分析"""
return self._get_group("analysis_features").get(
"chat_quality_analysis_enabled", False
)
def get_max_topics(self) -> int: def get_max_topics(self) -> int:
"""获取最大话题数量""" """获取最大话题数量"""
return self._get_group("analysis_features").get("max_topics", 5) return self._get_group("analysis_features").get("max_topics", 5)
@@ -196,6 +202,10 @@ class ConfigManager:
"""获取用户称号分析最大token数""" """获取用户称号分析最大token数"""
return self._get_group("llm").get("user_title_max_tokens", 4096) return self._get_group("llm").get("user_title_max_tokens", 4096)
def get_quality_max_tokens(self) -> int:
"""获取聊天质量分析最大token数"""
return self._get_group("llm").get("quality_max_tokens", 4096)
def get_debug_mode(self) -> bool: def get_debug_mode(self) -> bool:
"""获取是否启用调试模式""" """获取是否启用调试模式"""
return self._get_group("basic").get("debug_mode", False) return self._get_group("basic").get("debug_mode", False)
@@ -281,6 +291,14 @@ class ConfigManager:
return prompt return prompt
return "" return ""
def get_quality_analysis_prompt(self, style: str = "quality_prompt") -> str:
"""获取聊天质量分析提示词模板"""
prompts_config = self._get_group("prompts").get("quality_analysis_prompts", {})
prompt = prompts_config.get(style, "")
if prompt:
return prompt
return ""
def set_topic_analysis_prompt(self, prompt: str): def set_topic_analysis_prompt(self, prompt: str):
"""设置话题分析提示词模板""" """设置话题分析提示词模板"""
prompts = self._ensure_group("prompts") prompts = self._ensure_group("prompts")
@@ -379,6 +397,13 @@ class ConfigManager:
) )
self.config.save_config() self.config.save_config()
def set_chat_quality_analysis_enabled(self, enabled: bool):
"""设置是否启用聊天质量分析"""
self._ensure_group("analysis_features")["chat_quality_analysis_enabled"] = (
enabled
)
self.config.save_config()
def set_max_topics(self, count: int): def set_max_topics(self, count: int):
"""设置最大话题数量""" """设置最大话题数量"""
self._ensure_group("analysis_features")["max_topics"] = count self._ensure_group("analysis_features")["max_topics"] = count
+46 -30
View File
@@ -42,6 +42,12 @@ class ReportGenerator(IReportGenerator):
) )
self._avatar_session = None self._avatar_session = None
async def close(self):
"""关闭资源"""
if self._avatar_session:
await self._avatar_session.close()
self._avatar_session = None
async def generate_image_report( async def generate_image_report(
self, self,
analysis_result: dict, analysis_result: dict,
@@ -73,9 +79,10 @@ class ReportGenerator(IReportGenerator):
nickname_getter=nickname_getter, nickname_getter=nickname_getter,
) )
# 先渲染HTML模板(使用异步方法 # 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签
image_template = await self.html_templates.get_image_template_async() html_content = self.html_templates.render_template(
html_content = self._render_html_template(image_template, render_payload) "image_template.html", **render_payload
)
# 检查HTML内容是否有效 # 检查HTML内容是否有效
if not html_content: if not html_content:
@@ -229,9 +236,10 @@ class ReportGenerator(IReportGenerator):
) )
logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段") logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段")
# 生成 HTML 内容(使用异步方法 # 生成 HTML 内容(使用 Jinja2 渲染器以支持逻辑标签
pdf_template = await self.html_templates.get_pdf_template_async() html_content = self.html_templates.render_template(
html_content = self._render_html_template(pdf_template, render_data) "pdf_template.html", **render_data
)
# 检查HTML内容是否有效 # 检查HTML内容是否有效
if not html_content: if not html_content:
@@ -388,6 +396,37 @@ class ReportGenerator(IReportGenerator):
) )
logger.info(f"活跃度图表HTML生成完成,长度: {len(hourly_chart_html)}") logger.info(f"活跃度图表HTML生成完成,长度: {len(hourly_chart_html)}")
# 生成聊天质量锐评HTML
chat_quality_html = ""
chat_quality_review = analysis_result.get("chat_quality_review")
if not chat_quality_review and hasattr(stats, "chat_quality_review"):
chat_quality_review = stats.chat_quality_review
if chat_quality_review:
# 如果是对象,转为字典(为了统一渲染)
if hasattr(chat_quality_review, "dimensions"):
review_data = {
"title": chat_quality_review.title,
"subtitle": chat_quality_review.subtitle,
"dimensions": [
{
"name": d.name,
"percentage": d.percentage,
"comment": d.comment,
"color": d.color,
}
for d in chat_quality_review.dimensions
],
"summary": chat_quality_review.summary,
}
else:
review_data = chat_quality_review
chat_quality_html = self.html_templates.render_template(
"chat_quality_item.html", **review_data
)
logger.info(f"聊天质量锐评HTML生成完成,长度: {len(chat_quality_html)}")
# 准备最终渲染数据 # 准备最终渲染数据
render_data = { render_data = {
"current_date": datetime.now().strftime("%Y年%m月%d"), "current_date": datetime.now().strftime("%Y年%m月%d"),
@@ -401,6 +440,7 @@ class ReportGenerator(IReportGenerator):
"titles_html": titles_html, "titles_html": titles_html,
"quotes_html": quotes_html, "quotes_html": quotes_html,
"hourly_chart_html": hourly_chart_html, "hourly_chart_html": hourly_chart_html,
"chat_quality_html": chat_quality_html,
"total_tokens": stats.token_usage.total_tokens "total_tokens": stats.token_usage.total_tokens
if stats.token_usage.total_tokens if stats.token_usage.total_tokens
else 0, else 0,
@@ -506,30 +546,6 @@ class ReportGenerator(IReportGenerator):
return True return True
return normalized == str(user_id).strip() return normalized == str(user_id).strip()
def _render_html_template(self, template: str, data: dict) -> str:
"""HTML模板渲染,使用 {{key}} 占位符格式
Args:
template: HTML模板字符串
data: 渲染数据字典
"""
result = template
for key, value in data.items():
# 统一使用双大括号格式 {{key}}
placeholder = "{{" + key + "}}"
result = result.replace(placeholder, str(value))
# 检查是否还有未替换的占位符
import re
if remaining_placeholders := re.findall(r"\{\{[^}]+\}\}", result):
logger.warning(
f"未替换的占位符 ({len(remaining_placeholders)}个): {remaining_placeholders[:10]}"
)
return result
@staticmethod @staticmethod
def _safe_url_for_log(url: str | None) -> str: def _safe_url_for_log(url: str | None) -> str:
"""对日志中的 URL 进行脱敏,避免泄露 token。""" """对日志中的 URL 进行脱敏,避免泄露 token。"""
@@ -0,0 +1,25 @@
<div style="background: white; border: 1px solid #ddd; padding: 20px; margin-bottom: 24px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); border-left: 5px solid #007bff;">
<div style="margin-bottom: 15px;">
<h3 style="margin: 0; font-size: 1.5em; color: #222;">{{ title }}</h3>
{% if subtitle %}<span style="color: #666; font-size: 0.9em;">{{ subtitle }}</span>{% endif %}
</div>
<div style="margin-bottom: 15px;">
{% for dimension in dimensions %}
<div style="margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
<span style="font-weight: 600;">{{ dimension.name }}</span>
<span style="color: #007bff; font-weight: 700;">{{ dimension.percentage }}%</span>
</div>
<div style="height: 12px; background: #f0f0f0; border-radius: 6px; overflow: hidden;">
<div style="height: 100%; width: {{ dimension.percentage }}%; background-color: {{ dimension.color }}; border-radius: 6px;"></div>
</div>
<p style="margin: 8px 0 0; color: #555; font-size: 0.9em; line-height: 1.5;">{{ dimension.comment }}</p>
</div>
{% endfor %}
</div>
<div style="background: #f8f9fa; border-top: 1px solid #eee; padding: 12px; font-size: 0.95em; color: #333; font-style: italic;">
{{ summary }}
</div>
</div>
@@ -435,8 +435,9 @@
color: var(--text-muted); color: var(--text-muted);
text-align: center; text-align: center;
padding: 30px; padding: 30px;
font-size: 0.8em; font-size: 0.85em;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
line-height: 1.8;
} }
/* Responsive */ /* Responsive */
@@ -518,14 +519,21 @@
{{hourly_chart_html}} {{hourly_chart_html}}
</div> </div>
{% if chat_quality_html %}
<div class="section full-width-section">
<h2 class="section-title">群聊质量分析</h2>
{{ chat_quality_html }}
</div>
{% endif %}
{{topics_html}} {{topics_html}}
{{titles_html}} {{titles_html}}
{{quotes_html}} {{quotes_html}}
</div> </div>
<div class="footer"> <div class="footer">
由 AstrBot QQ群日常分析插件 生成 | {{current_datetime}} | SXP-Simon/astrbot_plugin_qq_group_daily_analysis<br> 由 AstrBot QQ群日常分析插件 生成 | {{current_datetime}}<br>
<small style="opacity: 0.8; font-size: 0.9em; margin-top: 8px; display: block;">AI分析消耗:{{total_tokens}} SXP-Simon/astrbot_plugin_qq_group_daily_analysis<br>
tokens (输入: {{prompt_tokens}}, 输出: {{completion_tokens}})</small> <span style="opacity: 0.7;">AI分析消耗:{{total_tokens}} tokens (输入: {{prompt_tokens}}, 输出: {{completion_tokens}})</span>
</div> </div>
</div> </div>
</body> </body>
@@ -0,0 +1,25 @@
<div style="background: #000; border: 1px solid #0f0; padding: 15px; margin-bottom: 20px; box-shadow: 0 0 10px #0f0; color: #0f0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace;">
<div style="text-align: center; margin-bottom: 15px;">
<h3 style="margin: 0; color: #0f0;">[ STATUS: {{ title }} ]</h3>
{% if subtitle %}<div style="font-size: 0.8em; color: #0c0;"># {{ subtitle }}</div>{% endif %}
</div>
<div>
{% for dimension in dimensions %}
<div style="margin-bottom: 12px;">
<div style="display: flex; justify-content: space-between; font-size: 0.9em; margin-bottom: 4px;">
<span>> {{ dimension.name }}</span>
<span>{{ dimension.percentage }}%</span>
</div>
<div style="height: 10px; background: #030; border: 1px solid #0f0; overflow: hidden;">
<div style="height: 100%; width: {{ dimension.percentage }}%; background-color: #0f0;"></div>
</div>
<p style="margin: 4px 0 0 10px; font-size: 0.8em; color: #0a0;">// {{ dimension.comment }}</p>
</div>
{% endfor %}
</div>
<div style="border-top: 1px solid #0f0; margin-top: 10px; padding-top: 10px; line-height: 1.4; color: #0f0;">
<span style="font-weight: bold;">[LOG]:</span> {{ summary }}
</div>
</div>
@@ -499,7 +499,7 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.8rem; font-size: 0.9rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -543,7 +543,17 @@
</div> </div>
</div> </div>
{% if chat_quality_html %}
<div>
<div class="section-header">
<span class="prompt">$</span> analyze --quality
</div>
{{ chat_quality_html }}
</div>
{% endif %}
<!-- Topics moved into left column --> <!-- Topics moved into left column -->
{% if topics_html %}
<div> <div>
<div class="section-header"> <div class="section-header">
<span class="prompt">$</span> ls ./topics <span class="prompt">$</span> ls ./topics
@@ -552,6 +562,7 @@
{{topics_html}} {{topics_html}}
</div> </div>
</div> </div>
{% endif %}
</div> </div>
<!-- Right Side: Visuals & Members --> <!-- Right Side: Visuals & Members -->
@@ -571,6 +582,7 @@
</div> </div>
<!-- User Titles grouped here --> <!-- User Titles grouped here -->
{% if titles_html %}
<div style="margin-top: 20px;"> <div style="margin-top: 20px;">
<div class="section-header"> <div class="section-header">
<span class="prompt">$</span> cat ./user_titles <span class="prompt">$</span> cat ./user_titles
@@ -579,8 +591,10 @@
{{titles_html}} {{titles_html}}
</div> </div>
</div> </div>
{% endif %}
<!-- Golden Quotes grouped here --> <!-- Golden Quotes grouped here -->
{% if quotes_html %}
<div style="margin-top: 20px;"> <div style="margin-top: 20px;">
<div class="section-header"> <div class="section-header">
<span class="prompt">$</span> grep -r "golden_quotes" <span class="prompt">$</span> grep -r "golden_quotes"
@@ -589,6 +603,7 @@
{{quotes_html}} {{quotes_html}}
</div> </div>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
@@ -0,0 +1,187 @@
<style>
.retro-quality-item {
background: #FFFFFF;
border: 3px solid var(--c-text);
padding: 24px;
margin-bottom: 25px;
font-family: var(--font-mono);
color: var(--c-text);
box-shadow: 8px 8px 0 rgba(0, 0, 0, 0.1);
position: relative;
}
.retro-quality-item::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; height: 6px;
background: repeating-linear-gradient(90deg, var(--c-accent), var(--c-accent) 15px, var(--c-dec-3) 15px, var(--c-dec-3) 30px);
}
.retro-quality-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 20px;
border-bottom: 2px solid var(--c-text);
padding-bottom: 10px;
margin-top: 15px;
}
.retro-theme-title {
font-family: var(--font-display);
font-size: 2em;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--c-dec-1);
}
.retro-time-range {
font-size: 1.1em;
font-weight: bold;
background: var(--c-dec-3);
color: #fff;
padding: 4px 10px;
border: 2px solid var(--c-text);
box-shadow: 2px 2px 0 var(--c-text);
}
.retro-dimension-bar-container {
margin: 25px 0;
background: #EEEEEE;
border: 2px solid var(--c-text);
height: 40px;
display: flex;
width: 100%;
position: relative;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.1);
}
.retro-dimension-segment {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
border-right: 2px solid var(--c-text);
white-space: nowrap;
font-size: 0.9em;
font-weight: bold;
transition: filter 0.2s;
}
.retro-dimension-segment:hover {
filter: brightness(1.1);
}
.retro-dimension-segment:last-child {
border-right: none;
}
.retro-comments-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-bottom: 25px;
}
.retro-comment-box {
background: #FAFAFA;
border: 2px solid var(--c-text);
border-top: 4px solid var(--c-dec-2);
padding: 15px;
font-size: 0.95em;
line-height: 1.5;
box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.05);
}
.retro-comment-title {
display: inline-block;
font-weight: bold;
margin-bottom: 10px;
color: var(--c-dec-1);
text-transform: uppercase;
border-bottom: 2px solid var(--c-grid);
padding-bottom: 4px;
width: 100%;
}
.retro-summary-section {
margin-top: 25px;
padding: 18px;
background: var(--c-dec-1);
color: #fff;
border: 3px solid var(--c-text);
font-size: 1.1em;
line-height: 1.6;
position: relative;
box-shadow: 4px 4px 0 var(--c-text);
}
.retro-summary-label {
background: var(--c-accent);
color: #fff;
padding: 4px 12px;
font-weight: bold;
margin-right: 15px;
display: inline-block;
font-family: var(--font-display);
letter-spacing: 1px;
border: 2px solid #fff;
}
</style>
<div class="retro-quality-item">
<div class="retro-quality-header">
<div class="retro-theme-title">{{ title }}</div>
{% if subtitle %}<div class="retro-time-range">{{ subtitle }}</div>{% endif %}
</div>
<!-- Stacked dimension bar chart -->
<div class="retro-dimension-bar-container">
{% set total = 0 %}
{% for dim in dimensions %}
{% set total = total + dim.percentage %}
{% endfor %}
{% set scale = 1 %}
{% if total > 100 %}
{% set scale = 100 / total %}
{% elif total > 0 %}
{% set scale = 100 / total %}
{% endif %}
{% set bg_colors = ['var(--c-accent)', 'var(--c-dec-1)', 'var(--c-dec-3)', 'var(--c-dec-2)', '#FFBA08', '#273C75', '#E07A5F', '#8D99AE'] %}
{% set txt_colors = ['#ffffff', '#ffffff', '#000000', '#ffffff', '#000000', '#ffffff', '#000000', '#ffffff'] %}
{% for dim in dimensions %}
{% if dim.percentage > 0 %}
<div class="retro-dimension-segment"
style="width: {{ dim.percentage * scale }}%; background-color: {{ bg_colors[loop.index0 % bg_colors|length] }}; color: {{ txt_colors[loop.index0 % txt_colors|length] }};"
title="{{ dim.comment }}">
<span style="display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; padding: 0 5px;">
{{ dim.name }} {{ dim.percentage }}%
</span>
</div>
{% endif %}
{% endfor %}
</div>
<!-- Dimension comments adapted to a retro dual-column layout -->
<div class="retro-comments-grid">
{% for dim in dimensions %}
{% if dim.percentage > 0 %}
<div class="retro-comment-box">
<div class="retro-comment-title">{{ dim.name }}</div>
<div style="color: var(--c-text);">{{ dim.comment }}</div>
</div>
{% endif %}
{% endfor %}
</div>
<!-- System Summary Section -->
<div class="retro-summary-section">
<span class="retro-summary-label">OVERVIEW</span>
<span>{{ summary }}</span>
</div>
</div>
@@ -640,7 +640,7 @@
padding: 60px 0; padding: 60px 0;
border-top: 8px solid var(--c-text); border-top: 8px solid var(--c-text);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.8rem; font-size: 0.9rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
@@ -772,48 +772,70 @@
<div class="grid-container"> <div class="grid-container">
<div class="content-card"> <div class="content-card">
<div class="activity-wrapper"> <div class="activity-wrapper">
{{hourly_chart_html}} {{hourly_chart_html|safe}}
</div> </div>
</div> </div>
</div> </div>
<!-- Chat Quality Review -->
{% if chat_quality_html %}
<div class="section-label">
Quality Analysis <span>STABILITY_METRICS</span>
</div>
<div class="grid-container">
<div style="grid-column: span 12;">
{{ chat_quality_html|safe }}
</div>
</div>
{% endif %}
<!-- Topics --> <!-- Topics -->
{% if topics_html %}
<div class="section-label"> <div class="section-label">
Thread Matrix <span>TOPICS_MODULE</span> Thread Matrix <span>TOPICS_MODULE</span>
</div> </div>
<div class="topic-list"> <div class="topic-list">
{{topics_html}} {{topics_html|safe}}
</div> </div>
{% endif %}
<!-- Titles --> <!-- Titles -->
{% if titles_html %}
<div class="section-label"> <div class="section-label">
Operator Registry <span>TITLES_MODULE</span> Operator Registry <span>TITLES_MODULE</span>
</div> </div>
<div class="grid-container"> <div class="grid-container">
<div class="content-card"> <div class="content-card">
<div class="title-grid"> <div class="title-grid">
{{titles_html}} {{titles_html|safe}}
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<!-- Quotes --> <!-- Quotes -->
{% if quotes_html %}
<div class="section-label"> <div class="section-label">
Golden Lines <span>QUOTES_MODULE</span> Golden Lines <span>QUOTES_MODULE</span>
</div> </div>
<div class="quote-list"> <div class="quote-list">
{{quotes_html}} {{quotes_html|safe}}
</div> </div>
{% endif %}
<footer> <footer>
<div>CRT_EXPORT_CHANNEL // QQ群日常分析插件</div> <div class="footer-row">
<div class="footer-tokens"> <div>CRT_EXPORT_CHANNEL // QQ群日常分析插件</div>
<span>Total Tokens // {{total_tokens}}</span> <div class="footer-author">SXP-Simon / astrbot_plugin_qq_group_daily_analysis</div>
<span>Prompt // {{prompt_tokens}}</span>
<span>Completion // {{completion_tokens}}</span>
</div> </div>
<div class="footer-author">SXP-Simon / astrbot_plugin_qq_group_daily_analysis</div> <div class="footer-row">
<div>END_OF_LINE_</div> <div class="footer-tokens">
<span>Total Tokens // {{total_tokens}}</span>
<span>Prompt // {{prompt_tokens}}</span>
<span>Completion // {{completion_tokens}}</span>
</div>
</div>
<div>{{current_datetime}} // END_OF_LINE_</div>
</footer> </footer>
</div> </div>
</body> </body>
@@ -835,7 +835,7 @@
<div class="grid-container"> <div class="grid-container">
<div class="content-box"> <div class="content-box">
<div class="activity-wrapper"> <div class="activity-wrapper">
{{hourly_chart_html}} {{hourly_chart_html|safe}}
</div> </div>
</div> </div>
</div> </div>
@@ -845,7 +845,7 @@
Thread Matrix <span>TOPICS_MODULE</span> Thread Matrix <span>TOPICS_MODULE</span>
</div> </div>
<div class="topic-list"> <div class="topic-list">
{{topics_html}} {{topics_html|safe}}
</div> </div>
<!-- Titles --> <!-- Titles -->
@@ -855,7 +855,7 @@
<div class="grid-container"> <div class="grid-container">
<div class="content-box"> <div class="content-box">
<div class="title-grid"> <div class="title-grid">
{{titles_html}} {{titles_html|safe}}
</div> </div>
</div> </div>
</div> </div>
@@ -865,7 +865,7 @@
Golden Lines <span>QUOTES_MODULE</span> Golden Lines <span>QUOTES_MODULE</span>
</div> </div>
<div class="quote-list"> <div class="quote-list">
{{quotes_html}} {{quotes_html|safe}}
</div> </div>
<footer> <footer>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
<div style="background: #f9f9f9; border-radius: 8px; padding: 12px; margin-bottom: 20px; border: 1px solid #eee;">
<h3 style="margin: 0 0 10px; font-size: 1.2em; color: #333; text-align: center;">{{ title }}</h3>
{% if subtitle %}<p style="margin: -5px 0 10px; font-size: 0.85em; color: #666; text-align: center;">{{ subtitle }}</p>{% endif %}
{% for dimension in dimensions %}
<div style="margin-bottom: 10px;">
<div style="display: flex; justify-content: space-between; font-size: 0.9em; margin-bottom: 3px;">
<span>{{ dimension.name }}</span>
<span>{{ dimension.percentage }}%</span>
</div>
<div style="height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden;">
<div style="height: 100%; width: {{ dimension.percentage }}%; background-color: {{ dimension.color }};"></div>
</div>
<p style="margin: 3px 0 0; font-size: 0.8em; color: #555;">{{ dimension.comment }}</p>
</div>
{% endfor %}
<div style="border-top: 1px solid #eee; margin-top: 10px; padding-top: 8px; font-size: 0.9em; line-height: 1.4; color: #444;">
{{ summary }}
</div>
</div>
@@ -40,16 +40,23 @@
{{hourly_chart_html}} {{hourly_chart_html}}
</div> </div>
{% if chat_quality_html %}
<div class="section">
<h2>群聊质量分析</h2>
{{ chat_quality_html }}
</div>
{% endif %}
{{topics_html}} {{topics_html}}
{{titles_html}} {{titles_html}}
{{quotes_html}} {{quotes_html}}
<div class="footer"> <div class="footer" style="margin-top: 30px; padding: 15px; border-top: 1px solid #ccc; color: #666; font-size: 0.85em; text-align: center; line-height: 1.8;">
Generated by AstrBot | {{current_datetime}} Generated by AstrBot | {{current_datetime}}<br>
<br> SXP-Simon/astrbot_plugin_qq_group_daily_analysis<br>
<small>Token Usage: {{total_tokens}} (Prompt: {{prompt_tokens}}, Completion: {{completion_tokens}})</small> <span style="opacity: 0.7;">Token Usage: {{total_tokens}} (Prompt: {{prompt_tokens}}, Completion: {{completion_tokens}})</span>
</div> </div>
</body> </body>
@@ -0,0 +1,27 @@
<div style="background: #fff5f5; border: 2px solid #ff4d4f; border-radius: 12px; padding: 20px; margin-bottom: 25px; box-shadow: 0 4px 12px rgba(255, 77, 79, 0.1); position: relative; overflow: hidden;">
<div style="position: absolute; top: -10px; right: -10px; opacity: 0.1; font-size: 80px; transform: rotate(15deg);"></div>
<div style="text-align: center; margin-bottom: 20px;">
<h3 style="margin: 0; color: #cf1322; font-size: 1.6em; border-bottom: 2px solid #ff4d4f; display: inline-block; padding-bottom: 5px;">{{ title }}</h3>
{% if subtitle %}<p style="margin: 8px 0 0; color: #fa541c; font-weight: bold;">{{ subtitle }}</p>{% endif %}
</div>
<div>
{% for dimension in dimensions %}
<div style="margin-bottom: 15px;">
<div style="display: flex; justify-content: space-between; font-size: 1em; margin-bottom: 6px; color: #333; font-weight: bold;">
<span>{{ dimension.name }}</span>
<span style="color: #cf1322;">{{ dimension.percentage }}%</span>
</div>
<div style="height: 12px; background: #ffe8e6; border-radius: 6px; overflow: hidden; border: 1px solid #ffccc7;">
<div style="height: 100%; width: {{ dimension.percentage }}%; background: linear-gradient(90deg, #ff4d4f, #cf1322);"></div>
</div>
<p style="margin: 6px 0 0; font-size: 0.9em; color: #8c8c8c; font-style: italic;">{{ dimension.comment }}</p>
</div>
{% endfor %}
</div>
<div style="margin-top: 15px; padding: 12px; background: #fff1f0; border-radius: 8px; border-left: 4px solid #f5222d; font-size: 0.95em; color: #434343;">
{{ summary }}
</div>
</div>
@@ -633,13 +633,23 @@
</div> </div>
<div class="sf-main-flow"> <div class="sf-main-flow">
<!-- 1. Topics: Full Width --> <!-- 0. Chat Quality Analysis -->
{% if chat_quality_html %}
<div class="sf-section-box"> <div class="sf-section-box">
<div class="sf-section-title"><span>🏮</span> 核心话题回顾</div> <div class="sf-section-title"><span>🏺</span> 聊天质量锐评</div>
{{ chat_quality_html }}
</div>
{% endif %}
<!-- 1. Topics: Full Width -->
{% if topics_html %}
<div class="sf-section-box">
<div class="sf-section-title"><span>&#127982;</span> 核心话题回顾</div>
<div class="sf-topic-list"> <div class="sf-topic-list">
{{topics_html}} {{topics_html}}
</div> </div>
</div> </div>
{% endif %}
<!-- 2. Chart: Full Width with SF window --> <!-- 2. Chart: Full Width with SF window -->
<div class="sf-section-box"> <div class="sf-section-box">
@@ -651,22 +661,27 @@
</div> </div>
<!-- 3. Characters: Masonry 2-Column Grid --> <!-- 3. Characters: Masonry 2-Column Grid -->
{% if titles_html %}
<div class="sf-section-box"> <div class="sf-section-box">
<div class="sf-section-title"><span>🎖️</span> 群友风云榜</div> <div class="sf-section-title"><span>🎖️</span> 群友风云榜</div>
{{titles_html}} {{titles_html}}
</div> </div>
{% endif %}
<!-- 4. Quotes: Staggered Timeline --> <!-- 4. Quotes: Staggered Timeline -->
{% if quotes_html %}
<div class="sf-section-box"> <div class="sf-section-box">
<div class="sf-section-title"><span></span> 每日金句回响</div> <div class="sf-section-title"><span></span> 每日金句回响</div>
<div class="sf-quote-timeline"> <div class="sf-quote-timeline">
{{quotes_html}} {{quotes_html}}
</div> </div>
</div> </div>
{% endif %}
</div> </div>
<footer> <footer>
<div>宜 · 总结分析 // {{current_datetime}}</div> <div>宜 · 总结分析 // {{current_datetime}}</div>
<div style="font-size: 0.85rem; margin-top: 8px; opacity: 0.7;">Token 消耗:{{total_tokens}} (Prompt: {{prompt_tokens}}, Completion: {{completion_tokens}})</div>
<div class="sf-footer-tag">SXP-Simon / astrbot_plugin_qq_group_daily_analysis</div> <div class="sf-footer-tag">SXP-Simon / astrbot_plugin_qq_group_daily_analysis</div>
</footer> </footer>
</div> </div>