mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 13:38:43 +00:00
feat(chat_quality_analysis): chat_quality_analysis 模块
This commit is contained in:
+34
-1
@@ -148,6 +148,13 @@
|
||||
"default": "",
|
||||
"hint": "专门用于金句分析的 Provider。留空则使用主 LLM Provider"
|
||||
},
|
||||
"quality_provider_id": {
|
||||
"type": "string",
|
||||
"description": "聊天质量分析专用 Provider ID",
|
||||
"_special": "select_provider",
|
||||
"default": "",
|
||||
"hint": "专门用于聊天质量锐评的 Provider。留空则使用主 LLM Provider"
|
||||
},
|
||||
"llm_retries": {
|
||||
"type": "int",
|
||||
"description": "LLM 请求重试次数",
|
||||
@@ -177,6 +184,12 @@
|
||||
"description": "(兼容部分提供商)用户称号分析最大 Token 数",
|
||||
"default": 4096,
|
||||
"hint": "(兼容部分提供商,实测大部分模型调整后没有明显效果)用户称号分析时 LLM 能生成的最大 token 数量。当分析内容较多或者分析提示词复杂时,建议适当调大此值以保证输出质量。"
|
||||
},
|
||||
"quality_max_tokens": {
|
||||
"type": "int",
|
||||
"description": "(兼容部分提供商)聊天质量分析最大 Token 数",
|
||||
"default": 4096,
|
||||
"hint": "聊天质量分析时 LLM 能生成的最大 token 数量。"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -203,6 +216,12 @@
|
||||
"default": true,
|
||||
"hint": "是否使用LLM进行金句分析"
|
||||
},
|
||||
"chat_quality_analysis_enabled": {
|
||||
"type": "bool",
|
||||
"description": "启用聊天质量锐评",
|
||||
"default": true,
|
||||
"hint": "是否使用LLM进行聊天质量锐评(维度化分析)"
|
||||
},
|
||||
"max_topics": {
|
||||
"type": "int",
|
||||
"description": "最大话题数量",
|
||||
@@ -407,7 +426,21 @@
|
||||
"type": "text",
|
||||
"editor_mode": true,
|
||||
"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
@@ -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_path"] = astrbot_path
|
||||
|
||||
from src.domain.entities.analysis_result import ( # noqa: E402
|
||||
from src.domain.models.data_models import ( # noqa: E402
|
||||
ActivityVisualization,
|
||||
EmojiStatistics,
|
||||
GoldenQuote,
|
||||
GroupStatistics,
|
||||
QualityDimension,
|
||||
QualityReview,
|
||||
SummaryTopic,
|
||||
TokenUsage,
|
||||
UserTitle,
|
||||
)
|
||||
from src.infrastructure.reporting.generators import ReportGenerator # noqa: E402
|
||||
from src.infrastructure.reporting.templates import HTMLTemplates # noqa: E402
|
||||
|
||||
|
||||
class MockConfigManager:
|
||||
@@ -94,6 +95,12 @@ class MockConfigManager:
|
||||
def get_browser_path(self) -> str:
|
||||
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:
|
||||
# Return a known avatar URL for testing
|
||||
@@ -106,12 +113,13 @@ async def debug_render(
|
||||
# 1. Setup Mock Data
|
||||
config_manager = MockConfigManager(template_name)
|
||||
|
||||
# 2. Mock Analysis Result using Entities
|
||||
# 2. Mock Analysis Result using Data Models
|
||||
stats = GroupStatistics(
|
||||
message_count=1250,
|
||||
total_characters=45000,
|
||||
participant_count=42,
|
||||
most_active_period="20:00 - 22:00",
|
||||
golden_quotes=[], # Will be filled later
|
||||
emoji_count=156,
|
||||
emoji_statistics=EmojiStatistics(face_count=100, mface_count=56),
|
||||
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)
|
||||
}
|
||||
),
|
||||
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 = [
|
||||
@@ -257,9 +299,7 @@ async def debug_render(
|
||||
]
|
||||
|
||||
stats.golden_quotes = golden_quotes
|
||||
stats.token_usage = TokenUsage(
|
||||
prompt_tokens=1500, completion_tokens=800, total_tokens=2300
|
||||
)
|
||||
# token_usage already set in constructor
|
||||
|
||||
analysis_result = {
|
||||
"statistics": stats,
|
||||
@@ -270,6 +310,7 @@ async def debug_render(
|
||||
"987654321": {"nickname": "李四"},
|
||||
"112233445": {"nickname": "潜水员"},
|
||||
},
|
||||
"chat_quality_review": stats.chat_quality_review,
|
||||
"analysis_date": "2026年02月11日",
|
||||
"group_id": "123456",
|
||||
"group_name": "测试群组",
|
||||
@@ -285,22 +326,18 @@ async def debug_render(
|
||||
# Note: _prepare_render_data handles converting Entities to template-friendly dicts
|
||||
render_payload = await generator._prepare_render_data(analysis_result)
|
||||
|
||||
# 5. Render Main Template
|
||||
html_templates = HTMLTemplates(config_manager)
|
||||
# Get image template string
|
||||
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)
|
||||
# Use Jinja2 renderer
|
||||
final_html = generator.html_templates.render_template(
|
||||
"image_template.html", **render_payload
|
||||
)
|
||||
|
||||
# 6. Save to file
|
||||
output_path = Path(output_file)
|
||||
output_path.write_text(final_html, encoding="utf-8")
|
||||
|
||||
# 7. Close generator
|
||||
await generator.close()
|
||||
|
||||
print(
|
||||
f"Successfully rendered template '{template_name}' to {output_path.absolute()}"
|
||||
)
|
||||
|
||||
@@ -182,10 +182,14 @@ class AnalysisApplicationService:
|
||||
golden_quote_enabled = (
|
||||
self.config_manager.get_golden_quote_analysis_enabled()
|
||||
)
|
||||
chat_quality_enabled = (
|
||||
self.config_manager.get_chat_quality_analysis_enabled()
|
||||
)
|
||||
|
||||
topics = []
|
||||
user_titles = []
|
||||
golden_quotes = []
|
||||
chat_quality_review = None
|
||||
total_token_usage = TokenUsage()
|
||||
|
||||
# Note: LLMAnalyzer 目前可能只接收 legacy 格式或特定的 UnifiedMessage 适配
|
||||
@@ -198,7 +202,12 @@ class AnalysisApplicationService:
|
||||
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:
|
||||
logger.debug(f"[LLM] 已进入分析队列 (群: {group_id})")
|
||||
(
|
||||
@@ -206,6 +215,7 @@ class AnalysisApplicationService:
|
||||
user_titles,
|
||||
golden_quotes,
|
||||
total_token_usage,
|
||||
chat_quality_review,
|
||||
) = await self.llm_analyzer.analyze_all_concurrent(
|
||||
legacy_messages,
|
||||
user_activity,
|
||||
@@ -214,6 +224,7 @@ class AnalysisApplicationService:
|
||||
topic_enabled=topic_enabled,
|
||||
user_title_enabled=user_title_enabled,
|
||||
golden_quote_enabled=golden_quote_enabled,
|
||||
chat_quality_enabled=chat_quality_enabled,
|
||||
)
|
||||
|
||||
# 回填结果
|
||||
@@ -225,6 +236,7 @@ class AnalysisApplicationService:
|
||||
"topics": topics,
|
||||
"user_titles": user_titles,
|
||||
"user_analysis": user_activity,
|
||||
"chat_quality_review": chat_quality_review,
|
||||
}
|
||||
|
||||
# 6. 持久化摘要 (Persistence)
|
||||
@@ -355,6 +367,9 @@ class AnalysisApplicationService:
|
||||
golden_quote_enabled = (
|
||||
self.config_manager.get_golden_quote_analysis_enabled()
|
||||
)
|
||||
chat_quality_enabled = (
|
||||
self.config_manager.get_chat_quality_analysis_enabled()
|
||||
)
|
||||
|
||||
# 需要将 UnifiedMessage 转换为 legacy 格式供 LLM 分析器使用
|
||||
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
|
||||
)
|
||||
|
||||
async with self.llm_semaphore:
|
||||
logger.debug(f"[LLM] 已进入增量分析队列 (群: {group_id})")
|
||||
(
|
||||
topics,
|
||||
golden_quotes,
|
||||
token_usage,
|
||||
) = 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,
|
||||
)
|
||||
topics = []
|
||||
golden_quotes = []
|
||||
token_usage = TokenUsage()
|
||||
chat_quality_review = None
|
||||
|
||||
if topic_enabled or golden_quote_enabled or chat_quality_enabled:
|
||||
async with self.llm_semaphore:
|
||||
logger.debug(f"[LLM] 已进入增量分析队列 (群: {group_id})")
|
||||
(
|
||||
topics,
|
||||
golden_quotes,
|
||||
token_usage,
|
||||
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
|
||||
# 8a. 转换话题: SummaryTopic -> dict
|
||||
@@ -424,7 +447,25 @@ class AnalysisApplicationService:
|
||||
"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})
|
||||
last_message_timestamp = max(
|
||||
(msg.timestamp for msg in unified_messages), default=0
|
||||
@@ -446,6 +487,7 @@ class AnalysisApplicationService:
|
||||
topics=new_topics,
|
||||
golden_quotes=new_quotes,
|
||||
token_usage=token_usage_dict,
|
||||
chat_quality_review=chat_quality_dict,
|
||||
last_message_timestamp=last_message_timestamp,
|
||||
participant_ids=participant_ids,
|
||||
)
|
||||
|
||||
@@ -39,6 +39,7 @@ class IncrementalBatch:
|
||||
topics: 本批次提取的话题列表
|
||||
golden_quotes: 本批次提取的金句列表
|
||||
token_usage: 本批次 token 消耗 {prompt_tokens, completion_tokens, total_tokens}
|
||||
chat_quality_review: 本批次提取的聊天质量锐评
|
||||
last_message_timestamp: 本批次最后一条消息的时间戳
|
||||
participant_ids: 本批次参与者 ID 列表
|
||||
"""
|
||||
@@ -73,6 +74,7 @@ class IncrementalBatch:
|
||||
)
|
||||
|
||||
# 增量追踪
|
||||
chat_quality_review: dict[str, Any] | None = None
|
||||
last_message_timestamp: int = 0
|
||||
participant_ids: list[str] = field(default_factory=list)
|
||||
|
||||
@@ -91,6 +93,7 @@ class IncrementalBatch:
|
||||
"topics": self.topics,
|
||||
"golden_quotes": self.golden_quotes,
|
||||
"token_usage": self.token_usage,
|
||||
"chat_quality_review": self.chat_quality_review,
|
||||
"last_message_timestamp": self.last_message_timestamp,
|
||||
"participant_ids": self.participant_ids,
|
||||
}
|
||||
@@ -118,6 +121,7 @@ class IncrementalBatch:
|
||||
"total_tokens": 0,
|
||||
},
|
||||
),
|
||||
chat_quality_review=data.get("chat_quality_review"),
|
||||
last_message_timestamp=data.get("last_message_timestamp", 0),
|
||||
participant_ids=data.get("participant_ids", []),
|
||||
)
|
||||
@@ -170,6 +174,7 @@ class IncrementalState:
|
||||
# 合并后的 LLM 分析结果
|
||||
topics: 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)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -39,6 +40,26 @@ class GoldenQuote:
|
||||
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
|
||||
class TokenUsage:
|
||||
"""Token使用统计"""
|
||||
@@ -97,3 +118,4 @@ class GroupStatistics:
|
||||
default_factory=ActivityVisualization
|
||||
)
|
||||
token_usage: TokenUsage = field(default_factory=TokenUsage)
|
||||
chat_quality_review: Optional["QualityReview"] = None
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
|
||||
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):
|
||||
@@ -55,7 +61,14 @@ class IAnalysisProvider(ABC):
|
||||
topic_enabled: bool = True,
|
||||
user_title_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
|
||||
|
||||
@@ -68,6 +81,7 @@ class IAnalysisProvider(ABC):
|
||||
quotes_per_batch: int = 3,
|
||||
topic_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
|
||||
|
||||
@@ -20,6 +20,8 @@ from ...domain.models.data_models import (
|
||||
EmojiStatistics,
|
||||
GoldenQuote,
|
||||
GroupStatistics,
|
||||
QualityDimension,
|
||||
QualityReview,
|
||||
SummaryTopic,
|
||||
TokenUsage,
|
||||
)
|
||||
@@ -175,6 +177,9 @@ class IncrementalMergeService:
|
||||
# 记录最后分析消息时间戳(取最大值)
|
||||
if batch.last_message_timestamp > state.last_analyzed_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(
|
||||
f"合并批次完成: 群={state.group_id}, "
|
||||
@@ -233,6 +238,27 @@ class IncrementalMergeService:
|
||||
# 获取最活跃时段描述
|
||||
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(
|
||||
message_count=state.total_message_count,
|
||||
total_characters=state.total_character_count,
|
||||
@@ -243,6 +269,7 @@ class IncrementalMergeService:
|
||||
emoji_statistics=emoji_statistics,
|
||||
activity_visualization=activity_visualization,
|
||||
token_usage=token_usage,
|
||||
chat_quality_review=chat_quality_review,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
@@ -335,6 +362,7 @@ class IncrementalMergeService:
|
||||
"topics": topics,
|
||||
"user_titles": user_titles or [],
|
||||
"user_analysis": state.user_activities,
|
||||
"chat_quality_review": statistics.chat_quality_review,
|
||||
}
|
||||
|
||||
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
|
||||
@@ -7,12 +7,14 @@ import asyncio
|
||||
|
||||
from ...domain.models.data_models import (
|
||||
GoldenQuote,
|
||||
QualityReview,
|
||||
SummaryTopic,
|
||||
TokenUsage,
|
||||
UserTitle,
|
||||
)
|
||||
from ...domain.repositories.analysis_repository import IAnalysisProvider
|
||||
from ...utils.logger import logger
|
||||
from .analyzers.chat_quality_analyzer import ChatQualityAnalyzer
|
||||
from .analyzers.golden_quote_analyzer import GoldenQuoteAnalyzer
|
||||
from .analyzers.topic_analyzer import TopicAnalyzer
|
||||
from .analyzers.user_title_analyzer import UserTitleAnalyzer
|
||||
@@ -46,6 +48,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
self.topic_analyzer = TopicAnalyzer(context, config_manager)
|
||||
self.user_title_analyzer = UserTitleAnalyzer(context, config_manager)
|
||||
self.golden_quote_analyzer = GoldenQuoteAnalyzer(context, config_manager)
|
||||
self.chat_quality_analyzer = ChatQualityAnalyzer(context, config_manager)
|
||||
|
||||
async def analyze_topics(
|
||||
self,
|
||||
@@ -170,7 +173,14 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
topic_enabled: bool = True,
|
||||
user_title_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")
|
||||
|
||||
if chat_quality_enabled:
|
||||
tasks.append(
|
||||
self.chat_quality_analyzer.analyze_quality(
|
||||
messages, umo, session_id
|
||||
)
|
||||
)
|
||||
task_names.append("chat_quality")
|
||||
|
||||
if not tasks:
|
||||
return [], [], [], TokenUsage()
|
||||
return [], [], [], TokenUsage(), None
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
@@ -239,6 +257,8 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
topics, topic_usage = [], TokenUsage()
|
||||
user_titles, title_usage = [], TokenUsage()
|
||||
golden_quotes, quote_usage = [], TokenUsage()
|
||||
chat_quality_review = None
|
||||
quality_usage = TokenUsage() # Initialize here
|
||||
|
||||
for i, result in enumerate(results):
|
||||
name = task_names[i]
|
||||
@@ -252,38 +272,52 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
user_titles, title_usage = result
|
||||
elif name == "golden_quote" and isinstance(result, tuple):
|
||||
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使用统计
|
||||
total_usage = TokenUsage(
|
||||
prompt_tokens=topic_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
|
||||
+ title_usage.completion_tokens
|
||||
+ quote_usage.completion_tokens,
|
||||
+ quote_usage.completion_tokens
|
||||
+ quality_usage.completion_tokens,
|
||||
total_tokens=topic_usage.total_tokens
|
||||
+ title_usage.total_tokens
|
||||
+ quote_usage.total_tokens,
|
||||
+ quote_usage.total_tokens
|
||||
+ quality_usage.total_tokens,
|
||||
)
|
||||
|
||||
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:
|
||||
logger.error(f"并发分析失败: {e}")
|
||||
return [], [], [], TokenUsage()
|
||||
return [], [], [], TokenUsage(), None
|
||||
|
||||
async def analyze_incremental_concurrent(
|
||||
self,
|
||||
messages: list[dict],
|
||||
umo: str | None = None,
|
||||
topics_per_batch: int = 3,
|
||||
quotes_per_batch: int = 3,
|
||||
topics_per_batch: int = 2,
|
||||
quotes_per_batch: int = 1,
|
||||
topic_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}"
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
@@ -342,14 +376,24 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
)
|
||||
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:
|
||||
return [], [], TokenUsage()
|
||||
return [], [], TokenUsage(), None
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# 处理结果
|
||||
topics, topic_usage = [], TokenUsage()
|
||||
golden_quotes, quote_usage = [], TokenUsage()
|
||||
chat_quality_review = None
|
||||
quality_usage = TokenUsage()
|
||||
|
||||
for i, result in enumerate(results):
|
||||
name = task_names[i]
|
||||
@@ -361,20 +405,29 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
topics, topic_usage = result
|
||||
elif name == "golden_quote" and isinstance(result, tuple):
|
||||
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使用统计
|
||||
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
|
||||
+ quote_usage.completion_tokens,
|
||||
total_tokens=topic_usage.total_tokens + quote_usage.total_tokens,
|
||||
+ quote_usage.completion_tokens
|
||||
+ quality_usage.completion_tokens,
|
||||
total_tokens=topic_usage.total_tokens
|
||||
+ quote_usage.total_tokens
|
||||
+ quality_usage.total_tokens,
|
||||
)
|
||||
|
||||
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}"
|
||||
)
|
||||
return topics, golden_quotes, total_usage
|
||||
return topics, golden_quotes, total_usage, chat_quality_review
|
||||
|
||||
finally:
|
||||
# 无论成功或失败,都要恢复原始的最大数量设置
|
||||
@@ -383,7 +436,7 @@ class LLMAnalyzer(IAnalysisProvider):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"增量并发分析失败: {e}", exc_info=True)
|
||||
return [], [], TokenUsage()
|
||||
return [], [], TokenUsage(), None
|
||||
|
||||
def _save_debug_messages(self, messages: list[dict], session_id: str):
|
||||
"""
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
from .info_utils import InfoUtils
|
||||
from .json_utils import (
|
||||
extract_golden_quotes_with_regex,
|
||||
extract_quality_with_regex,
|
||||
extract_topics_with_regex,
|
||||
extract_user_titles_with_regex,
|
||||
fix_json,
|
||||
parse_json_object_response,
|
||||
parse_json_response,
|
||||
)
|
||||
from .llm_utils import (
|
||||
@@ -18,16 +20,18 @@ from .llm_utils import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# JSON处理工具
|
||||
# JSON processing utilities
|
||||
"fix_json",
|
||||
"parse_json_response",
|
||||
"parse_json_object_response",
|
||||
"extract_topics_with_regex",
|
||||
"extract_user_titles_with_regex",
|
||||
"extract_golden_quotes_with_regex",
|
||||
# LLM工具
|
||||
"extract_quality_with_regex",
|
||||
# LLM utilities
|
||||
"call_provider_with_retry",
|
||||
"extract_token_usage",
|
||||
"extract_response_text",
|
||||
# 信息工具
|
||||
# Info utilities
|
||||
"InfoUtils",
|
||||
]
|
||||
|
||||
@@ -85,7 +85,7 @@ def parse_json_response(
|
||||
result_text: str, data_type: str
|
||||
) -> tuple[bool, list[dict] | None, str | None]:
|
||||
"""
|
||||
统一的JSON解析方法
|
||||
统一的JSON解析方法(用于JSON数组响应)
|
||||
|
||||
Args:
|
||||
result_text: LLM返回的原始文本
|
||||
@@ -134,6 +134,78 @@ def parse_json_response(
|
||||
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]:
|
||||
"""
|
||||
使用正则表达式提取话题信息
|
||||
@@ -280,3 +352,58 @@ def extract_golden_quotes_with_regex(result_text: str, max_count: int) -> list[d
|
||||
except Exception as e:
|
||||
logger.error(f"金句正则表达式提取失败: {e}")
|
||||
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
|
||||
)
|
||||
|
||||
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:
|
||||
"""获取最大话题数量"""
|
||||
return self._get_group("analysis_features").get("max_topics", 5)
|
||||
@@ -196,6 +202,10 @@ class ConfigManager:
|
||||
"""获取用户称号分析最大token数"""
|
||||
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:
|
||||
"""获取是否启用调试模式"""
|
||||
return self._get_group("basic").get("debug_mode", False)
|
||||
@@ -281,6 +291,14 @@ class ConfigManager:
|
||||
return prompt
|
||||
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):
|
||||
"""设置话题分析提示词模板"""
|
||||
prompts = self._ensure_group("prompts")
|
||||
@@ -379,6 +397,13 @@ class ConfigManager:
|
||||
)
|
||||
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):
|
||||
"""设置最大话题数量"""
|
||||
self._ensure_group("analysis_features")["max_topics"] = count
|
||||
|
||||
@@ -42,6 +42,12 @@ class ReportGenerator(IReportGenerator):
|
||||
)
|
||||
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(
|
||||
self,
|
||||
analysis_result: dict,
|
||||
@@ -73,9 +79,10 @@ class ReportGenerator(IReportGenerator):
|
||||
nickname_getter=nickname_getter,
|
||||
)
|
||||
|
||||
# 先渲染HTML模板(使用异步方法)
|
||||
image_template = await self.html_templates.get_image_template_async()
|
||||
html_content = self._render_html_template(image_template, render_payload)
|
||||
# 先渲染HTML模板(使用 Jinja2 渲染器以支持逻辑标签)
|
||||
html_content = self.html_templates.render_template(
|
||||
"image_template.html", **render_payload
|
||||
)
|
||||
|
||||
# 检查HTML内容是否有效
|
||||
if not html_content:
|
||||
@@ -229,9 +236,10 @@ class ReportGenerator(IReportGenerator):
|
||||
)
|
||||
logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段")
|
||||
|
||||
# 生成 HTML 内容(使用异步方法)
|
||||
pdf_template = await self.html_templates.get_pdf_template_async()
|
||||
html_content = self._render_html_template(pdf_template, render_data)
|
||||
# 生成 HTML 内容(使用 Jinja2 渲染器以支持逻辑标签)
|
||||
html_content = self.html_templates.render_template(
|
||||
"pdf_template.html", **render_data
|
||||
)
|
||||
|
||||
# 检查HTML内容是否有效
|
||||
if not html_content:
|
||||
@@ -388,6 +396,37 @@ class ReportGenerator(IReportGenerator):
|
||||
)
|
||||
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 = {
|
||||
"current_date": datetime.now().strftime("%Y年%m月%d日"),
|
||||
@@ -401,6 +440,7 @@ class ReportGenerator(IReportGenerator):
|
||||
"titles_html": titles_html,
|
||||
"quotes_html": quotes_html,
|
||||
"hourly_chart_html": hourly_chart_html,
|
||||
"chat_quality_html": chat_quality_html,
|
||||
"total_tokens": stats.token_usage.total_tokens
|
||||
if stats.token_usage.total_tokens
|
||||
else 0,
|
||||
@@ -506,30 +546,6 @@ class ReportGenerator(IReportGenerator):
|
||||
return True
|
||||
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
|
||||
def _safe_url_for_log(url: str | None) -> str:
|
||||
"""对日志中的 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);
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
font-size: 0.8em;
|
||||
font-size: 0.85em;
|
||||
border-top: 1px solid var(--border);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@@ -518,14 +519,21 @@
|
||||
{{hourly_chart_html}}
|
||||
</div>
|
||||
|
||||
{% if chat_quality_html %}
|
||||
<div class="section full-width-section">
|
||||
<h2 class="section-title">群聊质量分析</h2>
|
||||
{{ chat_quality_html }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{topics_html}}
|
||||
{{titles_html}}
|
||||
{{quotes_html}}
|
||||
</div>
|
||||
<div class="footer">
|
||||
由 AstrBot QQ群日常分析插件 生成 | {{current_datetime}} | SXP-Simon/astrbot_plugin_qq_group_daily_analysis<br>
|
||||
<small style="opacity: 0.8; font-size: 0.9em; margin-top: 8px; display: block;">AI分析消耗:{{total_tokens}}
|
||||
tokens (输入: {{prompt_tokens}}, 输出: {{completion_tokens}})</small>
|
||||
由 AstrBot QQ群日常分析插件 生成 | {{current_datetime}}<br>
|
||||
SXP-Simon/astrbot_plugin_qq_group_daily_analysis<br>
|
||||
<span style="opacity: 0.7;">AI分析消耗:{{total_tokens}} tokens (输入: {{prompt_tokens}}, 输出: {{completion_tokens}})</span>
|
||||
</div>
|
||||
</div>
|
||||
</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;
|
||||
justify-content: space-between;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -543,7 +543,17 @@
|
||||
</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 -->
|
||||
{% if topics_html %}
|
||||
<div>
|
||||
<div class="section-header">
|
||||
<span class="prompt">$</span> ls ./topics
|
||||
@@ -552,6 +562,7 @@
|
||||
{{topics_html}}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Visuals & Members -->
|
||||
@@ -571,6 +582,7 @@
|
||||
</div>
|
||||
|
||||
<!-- User Titles grouped here -->
|
||||
{% if titles_html %}
|
||||
<div style="margin-top: 20px;">
|
||||
<div class="section-header">
|
||||
<span class="prompt">$</span> cat ./user_titles
|
||||
@@ -579,8 +591,10 @@
|
||||
{{titles_html}}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Golden Quotes grouped here -->
|
||||
{% if quotes_html %}
|
||||
<div style="margin-top: 20px;">
|
||||
<div class="section-header">
|
||||
<span class="prompt">$</span> grep -r "golden_quotes"
|
||||
@@ -589,6 +603,7 @@
|
||||
{{quotes_html}}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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;
|
||||
border-top: 8px solid var(--c-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
@@ -772,48 +772,70 @@
|
||||
<div class="grid-container">
|
||||
<div class="content-card">
|
||||
<div class="activity-wrapper">
|
||||
{{hourly_chart_html}}
|
||||
{{hourly_chart_html|safe}}
|
||||
</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 -->
|
||||
{% if topics_html %}
|
||||
<div class="section-label">
|
||||
Thread Matrix <span>TOPICS_MODULE</span>
|
||||
</div>
|
||||
<div class="topic-list">
|
||||
{{topics_html}}
|
||||
{{topics_html|safe}}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Titles -->
|
||||
{% if titles_html %}
|
||||
<div class="section-label">
|
||||
Operator Registry <span>TITLES_MODULE</span>
|
||||
</div>
|
||||
<div class="grid-container">
|
||||
<div class="content-card">
|
||||
<div class="title-grid">
|
||||
{{titles_html}}
|
||||
{{titles_html|safe}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quotes -->
|
||||
{% if quotes_html %}
|
||||
<div class="section-label">
|
||||
Golden Lines <span>QUOTES_MODULE</span>
|
||||
</div>
|
||||
<div class="quote-list">
|
||||
{{quotes_html}}
|
||||
{{quotes_html|safe}}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<footer>
|
||||
<div>CRT_EXPORT_CHANNEL // QQ群日常分析插件</div>
|
||||
<div class="footer-tokens">
|
||||
<span>Total Tokens // {{total_tokens}}</span>
|
||||
<span>Prompt // {{prompt_tokens}}</span>
|
||||
<span>Completion // {{completion_tokens}}</span>
|
||||
<div class="footer-row">
|
||||
<div>CRT_EXPORT_CHANNEL // QQ群日常分析插件</div>
|
||||
<div class="footer-author">SXP-Simon / astrbot_plugin_qq_group_daily_analysis</div>
|
||||
</div>
|
||||
<div class="footer-author">SXP-Simon / astrbot_plugin_qq_group_daily_analysis</div>
|
||||
<div>END_OF_LINE_</div>
|
||||
<div class="footer-row">
|
||||
<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>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -835,7 +835,7 @@
|
||||
<div class="grid-container">
|
||||
<div class="content-box">
|
||||
<div class="activity-wrapper">
|
||||
{{hourly_chart_html}}
|
||||
{{hourly_chart_html|safe}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -845,7 +845,7 @@
|
||||
Thread Matrix <span>TOPICS_MODULE</span>
|
||||
</div>
|
||||
<div class="topic-list">
|
||||
{{topics_html}}
|
||||
{{topics_html|safe}}
|
||||
</div>
|
||||
|
||||
<!-- Titles -->
|
||||
@@ -855,7 +855,7 @@
|
||||
<div class="grid-container">
|
||||
<div class="content-box">
|
||||
<div class="title-grid">
|
||||
{{titles_html}}
|
||||
{{titles_html|safe}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -865,7 +865,7 @@
|
||||
Golden Lines <span>QUOTES_MODULE</span>
|
||||
</div>
|
||||
<div class="quote-list">
|
||||
{{quotes_html}}
|
||||
{{quotes_html|safe}}
|
||||
</div>
|
||||
|
||||
<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}}
|
||||
</div>
|
||||
|
||||
{% if chat_quality_html %}
|
||||
<div class="section">
|
||||
<h2>群聊质量分析</h2>
|
||||
{{ chat_quality_html }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{topics_html}}
|
||||
|
||||
{{titles_html}}
|
||||
|
||||
{{quotes_html}}
|
||||
|
||||
<div class="footer">
|
||||
Generated by AstrBot | {{current_datetime}}
|
||||
<br>
|
||||
<small>Token Usage: {{total_tokens}} (Prompt: {{prompt_tokens}}, Completion: {{completion_tokens}})</small>
|
||||
<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}}<br>
|
||||
SXP-Simon/astrbot_plugin_qq_group_daily_analysis<br>
|
||||
<span style="opacity: 0.7;">Token Usage: {{total_tokens}} (Prompt: {{prompt_tokens}}, Completion: {{completion_tokens}})</span>
|
||||
</div>
|
||||
</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 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-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>🏮</span> 核心话题回顾</div>
|
||||
<div class="sf-topic-list">
|
||||
{{topics_html}}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 2. Chart: Full Width with SF window -->
|
||||
<div class="sf-section-box">
|
||||
@@ -651,22 +661,27 @@
|
||||
</div>
|
||||
|
||||
<!-- 3. Characters: Masonry 2-Column Grid -->
|
||||
{% if titles_html %}
|
||||
<div class="sf-section-box">
|
||||
<div class="sf-section-title"><span>🎖️</span> 群友风云榜</div>
|
||||
{{titles_html}}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 4. Quotes: Staggered Timeline -->
|
||||
{% if quotes_html %}
|
||||
<div class="sf-section-box">
|
||||
<div class="sf-section-title"><span>✨</span> 每日金句回响</div>
|
||||
<div class="sf-quote-timeline">
|
||||
{{quotes_html}}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<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>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user