diff --git a/_conf_schema.json b/_conf_schema.json index 188981b..b89785b 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -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```" } } } diff --git a/scripts/debug_render.py b/scripts/debug_render.py index 9e60859..8e682a6 100644 --- a/scripts/debug_render.py +++ b/scripts/debug_render.py @@ -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()}" ) diff --git a/src/application/services/analysis_application_service.py b/src/application/services/analysis_application_service.py index e7dbfbc..e26df81 100644 --- a/src/application/services/analysis_application_service.py +++ b/src/application/services/analysis_application_service.py @@ -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, ) diff --git a/src/domain/entities/incremental_state.py b/src/domain/entities/incremental_state.py index a90c67c..ab9833b 100644 --- a/src/domain/entities/incremental_state.py +++ b/src/domain/entities/incremental_state.py @@ -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) diff --git a/src/domain/models/data_models.py b/src/domain/models/data_models.py index 761bcfe..3a82e8e 100644 --- a/src/domain/models/data_models.py +++ b/src/domain/models/data_models.py @@ -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 diff --git a/src/domain/repositories/analysis_repository.py b/src/domain/repositories/analysis_repository.py index a733539..f3ac3d5 100644 --- a/src/domain/repositories/analysis_repository.py +++ b/src/domain/repositories/analysis_repository.py @@ -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 diff --git a/src/domain/services/incremental_merge_service.py b/src/domain/services/incremental_merge_service.py index 58f762a..57851d3 100644 --- a/src/domain/services/incremental_merge_service.py +++ b/src/domain/services/incremental_merge_service.py @@ -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( diff --git a/src/infrastructure/analysis/analyzers/chat_quality_analyzer.py b/src/infrastructure/analysis/analyzers/chat_quality_analyzer.py new file mode 100644 index 0000000..7915c10 --- /dev/null +++ b/src/infrastructure/analysis/analyzers/chat_quality_analyzer.py @@ -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 diff --git a/src/infrastructure/analysis/llm_analyzer.py b/src/infrastructure/analysis/llm_analyzer.py index 1f39f7b..7eb9f56 100644 --- a/src/infrastructure/analysis/llm_analyzer.py +++ b/src/infrastructure/analysis/llm_analyzer.py @@ -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): """ diff --git a/src/infrastructure/analysis/utils/__init__.py b/src/infrastructure/analysis/utils/__init__.py index 651c60e..1fb65f6 100644 --- a/src/infrastructure/analysis/utils/__init__.py +++ b/src/infrastructure/analysis/utils/__init__.py @@ -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", ] diff --git a/src/infrastructure/analysis/utils/json_utils.py b/src/infrastructure/analysis/utils/json_utils.py index ecf8975..c27b563 100644 --- a/src/infrastructure/analysis/utils/json_utils.py +++ b/src/infrastructure/analysis/utils/json_utils.py @@ -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 diff --git a/src/infrastructure/config/config_manager.py b/src/infrastructure/config/config_manager.py index 2cb55ca..537bcb2 100644 --- a/src/infrastructure/config/config_manager.py +++ b/src/infrastructure/config/config_manager.py @@ -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 diff --git a/src/infrastructure/reporting/generators.py b/src/infrastructure/reporting/generators.py index c28fe8b..0358339 100644 --- a/src/infrastructure/reporting/generators.py +++ b/src/infrastructure/reporting/generators.py @@ -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。""" diff --git a/src/infrastructure/reporting/templates/format/chat_quality_item.html b/src/infrastructure/reporting/templates/format/chat_quality_item.html new file mode 100644 index 0000000..f7768b6 --- /dev/null +++ b/src/infrastructure/reporting/templates/format/chat_quality_item.html @@ -0,0 +1,25 @@ +
+
+

{{ title }}

+ {% if subtitle %}{{ subtitle }}{% endif %} +
+ +
+ {% for dimension in dimensions %} +
+
+ {{ dimension.name }} + {{ dimension.percentage }}% +
+
+
+
+

{{ dimension.comment }}

+
+ {% endfor %} +
+ +
+ {{ summary }} +
+
diff --git a/src/infrastructure/reporting/templates/format/image_template.html b/src/infrastructure/reporting/templates/format/image_template.html index f68f407..a5c3603 100644 --- a/src/infrastructure/reporting/templates/format/image_template.html +++ b/src/infrastructure/reporting/templates/format/image_template.html @@ -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}} + {% if chat_quality_html %} +
+

群聊质量分析

+ {{ chat_quality_html }} +
+ {% endif %} + {{topics_html}} {{titles_html}} {{quotes_html}} diff --git a/src/infrastructure/reporting/templates/hack/chat_quality_item.html b/src/infrastructure/reporting/templates/hack/chat_quality_item.html new file mode 100644 index 0000000..a4058d0 --- /dev/null +++ b/src/infrastructure/reporting/templates/hack/chat_quality_item.html @@ -0,0 +1,25 @@ +
+
+

[ STATUS: {{ title }} ]

+ {% if subtitle %}
# {{ subtitle }}
{% endif %} +
+ +
+ {% for dimension in dimensions %} +
+
+ > {{ dimension.name }} + {{ dimension.percentage }}% +
+
+
+
+

// {{ dimension.comment }}

+
+ {% endfor %} +
+ +
+ [LOG]: {{ summary }} +
+
diff --git a/src/infrastructure/reporting/templates/hack/image_template.html b/src/infrastructure/reporting/templates/hack/image_template.html index 7ebbc79..e189e37 100644 --- a/src/infrastructure/reporting/templates/hack/image_template.html +++ b/src/infrastructure/reporting/templates/hack/image_template.html @@ -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 @@ + {% if chat_quality_html %} +
+
+ $ analyze --quality +
+ {{ chat_quality_html }} +
+ {% endif %} + + {% if topics_html %}
$ ls ./topics @@ -552,6 +562,7 @@ {{topics_html}}
+ {% endif %} @@ -571,6 +582,7 @@ + {% if titles_html %}
$ cat ./user_titles @@ -579,8 +591,10 @@ {{titles_html}}
+ {% endif %} + {% if quotes_html %}
$ grep -r "golden_quotes" @@ -589,6 +603,7 @@ {{quotes_html}}
+ {% endif %} diff --git a/src/infrastructure/reporting/templates/retro_futurism/chat_quality_item.html b/src/infrastructure/reporting/templates/retro_futurism/chat_quality_item.html new file mode 100644 index 0000000..c005524 --- /dev/null +++ b/src/infrastructure/reporting/templates/retro_futurism/chat_quality_item.html @@ -0,0 +1,187 @@ + + +
+
+
{{ title }}
+ {% if subtitle %}
{{ subtitle }}
{% endif %} +
+ + +
+ {% 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 %} +
+ + {{ dim.name }} {{ dim.percentage }}% + +
+ {% endif %} + {% endfor %} +
+ + +
+ {% for dim in dimensions %} + {% if dim.percentage > 0 %} +
+
{{ dim.name }}
+
{{ dim.comment }}
+
+ {% endif %} + {% endfor %} +
+ + +
+ OVERVIEW + {{ summary }} +
+
diff --git a/src/infrastructure/reporting/templates/retro_futurism/image_template.html b/src/infrastructure/reporting/templates/retro_futurism/image_template.html index 1d56251..7d8176a 100644 --- a/src/infrastructure/reporting/templates/retro_futurism/image_template.html +++ b/src/infrastructure/reporting/templates/retro_futurism/image_template.html @@ -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 @@
- {{hourly_chart_html}} + {{hourly_chart_html|safe}}
+ + {% if chat_quality_html %} +
+ Quality Analysis STABILITY_METRICS +
+
+
+ {{ chat_quality_html|safe }} +
+
+ {% endif %} + + {% if topics_html %}
Thread Matrix TOPICS_MODULE
- {{topics_html}} + {{topics_html|safe}}
+ {% endif %} + {% if titles_html %}
Operator Registry TITLES_MODULE
- {{titles_html}} + {{titles_html|safe}}
+ {% endif %} + {% if quotes_html %}
Golden Lines QUOTES_MODULE
- {{quotes_html}} + {{quotes_html|safe}}
+ {% endif %} diff --git a/src/infrastructure/reporting/templates/retro_futurism/pdf_template.html b/src/infrastructure/reporting/templates/retro_futurism/pdf_template.html index 34abf03..2bb24c8 100644 --- a/src/infrastructure/reporting/templates/retro_futurism/pdf_template.html +++ b/src/infrastructure/reporting/templates/retro_futurism/pdf_template.html @@ -835,7 +835,7 @@
- {{hourly_chart_html}} + {{hourly_chart_html|safe}}
@@ -845,7 +845,7 @@ Thread Matrix TOPICS_MODULE
- {{topics_html}} + {{topics_html|safe}}
@@ -855,7 +855,7 @@
- {{titles_html}} + {{titles_html|safe}}
@@ -865,7 +865,7 @@ Golden Lines QUOTES_MODULE
- {{quotes_html}} + {{quotes_html|safe}}