From 81b83c88dc553b22964fff68c05c245619ca6a19 Mon Sep 17 00:00:00 2001 From: minecraft1024a Date: Thu, 13 Nov 2025 11:35:41 +0800 Subject: [PATCH] =?UTF-8?q?Revert=20"refactor(report):=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4HTML=E6=8A=A5=E5=91=8A=E7=94=9F=E6=88=90=E5=8A=9F?= =?UTF-8?q?=E8=83=BD"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bc533880dded08f4e7e093f636fa1dbe396a3d6f. --- src/chat/utils/report_generator.py | 446 +++++++++++++++++++++++++++++ src/chat/utils/statistic.py | 23 ++ 2 files changed, 469 insertions(+) create mode 100644 src/chat/utils/report_generator.py diff --git a/src/chat/utils/report_generator.py b/src/chat/utils/report_generator.py new file mode 100644 index 000000000..5526781fa --- /dev/null +++ b/src/chat/utils/report_generator.py @@ -0,0 +1,446 @@ +""" +该模块用于生成HTML格式的统计报告。 +""" +from datetime import datetime, timedelta +from typing import Any + +import aiofiles + +from .statistic_keys import * # noqa: F403 + + +def format_online_time(online_seconds: int) -> str: + """ + 格式化在线时间。 + + :param online_seconds: 在线时间(秒)。 + :return: 格式化后的在线时间字符串。 + """ + total_online_time = timedelta(seconds=online_seconds) + days = total_online_time.days + hours = total_online_time.seconds // 3600 + minutes = (total_online_time.seconds // 60) % 60 + seconds = total_online_time.seconds % 60 + if days > 0: + return f"{days}天{hours}小时{minutes}分钟{seconds}秒" + elif hours > 0: + return f"{hours}小时{minutes}分钟{seconds}秒" + else: + return f"{minutes}分钟{seconds}秒" + + +class HTMLReportGenerator: + """生成HTML统计报告""" + + def __init__( + self, + name_mapping: dict, + stat_period: list, + deploy_time: datetime, + ): + """ + 初始化报告生成器。 + + :param name_mapping: 聊天ID到名称的映射。 + :param stat_period: 统计时间段配置。 + :param deploy_time: 系统部署时间。 + """ + self.name_mapping = name_mapping + self.stat_period = stat_period + self.deploy_time = deploy_time + + def _format_stat_data_div(self, stat_data: dict[str, Any], div_id: str, start_time: datetime, now: datetime) -> str: + """ + 将单个时间段的统计数据格式化为HTML div块。 + + :param stat_data: 统计数据。 + :param div_id: div的ID。 + :param start_time: 统计时间段的开始时间。 + :param now: 当前时间。 + :return: HTML字符串。 + """ + # 按模型分类统计 + model_rows = "\n".join( + [ + f"" + f"{model_name}" + f"{count}" + f"{stat_data[AVG_TOK_BY_MODEL].get(model_name, 0)}" + f"{stat_data[TOTAL_TOK_BY_MODEL].get(model_name, 0)}" + f"{stat_data[TPS_BY_MODEL].get(model_name, 0):.2f}" + f"{stat_data[COST_PER_KTOK_BY_MODEL].get(model_name, 0):.4f} ¥" + f"{stat_data[COST_BY_MODEL].get(model_name, 0):.4f} ¥" + f"{stat_data[AVG_TIME_COST_BY_MODEL].get(model_name, 0):.3f} 秒" + f"" + for model_name, count in sorted(stat_data[REQ_CNT_BY_MODEL].items()) + ] + ) + # 按供应商分类统计 + provider_rows = "\n".join( + [ + f"" + f"{provider_name}" + f"{count}" + f"{stat_data[TOTAL_TOK_BY_PROVIDER].get(provider_name, 0)}" + f"{stat_data[TPS_BY_PROVIDER].get(provider_name, 0):.2f}" + f"{stat_data[COST_PER_KTOK_BY_PROVIDER].get(provider_name, 0):.4f} ¥" + f"{stat_data[COST_BY_PROVIDER].get(provider_name, 0):.4f} ¥" + f"" + for provider_name, count in sorted(stat_data[REQ_CNT_BY_PROVIDER].items()) + ] + ) + # 按请求类型分类统计 + type_rows = "\n".join( + [ + f"" + f"{req_type}" + f"{count}" + f"{stat_data[IN_TOK_BY_TYPE].get(req_type, 0)}" + f"{stat_data[OUT_TOK_BY_TYPE].get(req_type, 0)}" + f"{stat_data[TOTAL_TOK_BY_TYPE].get(req_type, 0)}" + f"{stat_data[COST_BY_TYPE].get(req_type, 0):.4f} ¥" + f"{stat_data[AVG_TIME_COST_BY_TYPE].get(req_type, 0):.3f} 秒" + f"{stat_data[STD_TIME_COST_BY_TYPE].get(req_type, 0):.3f} 秒" + f"" + for req_type, count in sorted(stat_data[REQ_CNT_BY_TYPE].items()) + ] + ) + # 按模块分类统计 + module_rows = "\n".join( + [ + f"" + f"{module_name}" + f"{count}" + f"{stat_data[IN_TOK_BY_MODULE].get(module_name, 0)}" + f"{stat_data[OUT_TOK_BY_MODULE].get(module_name, 0)}" + f"{stat_data[TOTAL_TOK_BY_MODULE].get(module_name, 0)}" + f"{stat_data[COST_BY_MODULE].get(module_name, 0):.4f} ¥" + f"{stat_data[AVG_TIME_COST_BY_MODULE].get(module_name, 0):.3f} 秒" + f"{stat_data[STD_TIME_COST_BY_MODULE].get(module_name, 0):.3f} 秒" + f"" + for module_name, count in sorted(stat_data[REQ_CNT_BY_MODULE].items()) + ] + ) + # 聊天消息统计 + chat_rows = "\n".join( + [ + f"{self.name_mapping.get(chat_id, ('未知', 0))[0]}{count}" + for chat_id, count in sorted(stat_data[MSG_CNT_BY_CHAT].items()) + ] + ) + summary_cards = f""" +
+
+

总花费

+

{stat_data.get(TOTAL_COST, 0):.4f} ¥

+
+
+

总请求数

+

{stat_data.get(TOTAL_REQ_CNT, 0)}

+
+
+

总Token数

+

{sum(stat_data.get(TOTAL_TOK_BY_MODEL, {}).values())}

+
+
+

总消息数

+

{stat_data.get(TOTAL_MSG_CNT, 0)}

+
+
+

总在线时间

+

{format_online_time(int(stat_data.get(ONLINE_TIME, 0)))}

+
+
+ """ + + # 增加饼图和条形图 + static_charts = self._generate_static_charts_div(stat_data, div_id) # 该功能尚未实现 + return f""" +
+

+ 统计时段: + {start_time.strftime("%Y-%m-%d %H:%M:%S")} ~ {now.strftime("%Y-%m-%d %H:%M:%S")} +

+ {summary_cards} + {static_charts} + +

按模型分类统计

+ + + {model_rows} +
模型名称调用次数平均Token数Token总量TPS每K Token成本累计花费平均耗时(秒)
+ +

按供应商分类统计

+ + + {provider_rows} +
供应商名称调用次数Token总量TPS每K Token成本累计花费
+ +

按模块分类统计

+ + + + + {module_rows} +
模块名称调用次数输入Token输出TokenToken总量累计花费平均耗时(秒)标准差(秒)
+ +

按请求类型分类统计

+ + + + + {type_rows} +
请求类型调用次数输入Token输出TokenToken总量累计花费平均耗时(秒)标准差(秒)
+ +

聊天消息统计

+ + + + + {chat_rows} +
联系人/群组名称消息数量
+
+ """ + def _generate_chart_tab(self, chart_data: dict) -> str: + """生成图表选项卡的HTML内容。""" + return f""" +
+

数据图表

+
+ + + + + +
+
+
+
+
+
+
+ + +
+ """ + + async def generate_report(self, stat: dict[str, Any], chart_data: dict, now: datetime, output_path: str): + """ + 生成并写入完整的HTML报告文件。 + + :param stat: 所有时间段的统计数据。 + :param chart_data: 用于图表的数据。 + :param now: 当前时间。 + :param output_path: 输出文件路径。 + """ + tab_list = [ + f'' + for period in self.stat_period + ] + tab_list.append('') + + tab_content_list = [ + self._format_stat_data_div(stat[period[0]], period[0], now - period[1], now) + for period in self.stat_period + if period[0] != "all_time" + ] + tab_content_list.append( + self._format_stat_data_div(stat["all_time"], "all_time", self.deploy_time, now) + ) + tab_content_list.append(self._generate_chart_tab(chart_data)) + + joined_tab_list = "\n".join(tab_list) + joined_tab_content = "\n".join(tab_content_list) + + html_template = f""" + + + + + + MoFox-Bot运行统计报告 + + + + +
+

MoFox-Bot运行统计报告

+

统计截止时间: {now.strftime("%Y-%m-%d %H:%M:%S")}

+
{joined_tab_list}
+ {joined_tab_content} +
+ + + + """ + async with aiofiles.open(output_path, "w", encoding="utf-8") as f: + await f.write(html_template) + def _generate_static_charts_div(self, stat_data: dict[str, Any], period_id: str) -> str: + """生成静态图表(饼图、条形图)的HTML和JS。""" + provider_cost_data = stat_data.get(PIE_CHART_COST_BY_PROVIDER, {}) + model_cost_data = stat_data.get(BAR_CHART_COST_BY_MODEL, {}) + + if not provider_cost_data and not model_cost_data: + return "

数据总览

当前时段暂无足够数据生成图表。

" + + provider_labels = provider_cost_data.get('labels', []) + provider_data = provider_cost_data.get('data', []) + model_labels = model_cost_data.get('labels', []) + model_data = model_cost_data.get('data', []) + + return f""" +

数据总览

+
+
+ +
+
+ +
+
+ + """ diff --git a/src/chat/utils/statistic.py b/src/chat/utils/statistic.py index 8007f8f97..08d2ed8be 100644 --- a/src/chat/utils/statistic.py +++ b/src/chat/utils/statistic.py @@ -16,6 +16,7 @@ logger = get_logger("maibot_statistic") # 彻底异步化:删除原同步包装器 _sync_db_get,所有数据库访问统一使用 await db_get。 +from .report_generator import HTMLReportGenerator, format_online_time from .statistic_keys import * @@ -180,6 +181,16 @@ class StatisticOutputTask(AsyncTask): logger.info("统计数据收集完成") self._statistic_console_output(stats, now) + # 使用新的 HTMLReportGenerator 生成报告 + chart_data = await self._collect_chart_data(stats) + deploy_time = datetime.fromtimestamp(local_storage.get("deploy_time", now.timestamp())) + report_generator = HTMLReportGenerator( + name_mapping=self.name_mapping, + stat_period=self.stat_period, + deploy_time=deploy_time, + ) + await report_generator.generate_report(stats, chart_data, now, self.record_file_path) + logger.info("统计数据HTML报告输出完成") except Exception as e: logger.exception(f"输出统计数据过程中发生异常,错误信息:{e}") @@ -196,6 +207,18 @@ class StatisticOutputTask(AsyncTask): logger.info("(后台) 正在收集统计数据(异步)...") stats = await self._collect_all_statistics(now) self._statistic_console_output(stats, now) + + # 使用新的 HTMLReportGenerator 生成报告 + chart_data = await self._collect_chart_data(stats) + deploy_time = datetime.fromtimestamp(local_storage.get("deploy_time", now.timestamp())) + report_generator = HTMLReportGenerator( + name_mapping=self.name_mapping, + stat_period=self.stat_period, + deploy_time=deploy_time, + ) + await report_generator.generate_report(stats, chart_data, now, self.record_file_path) + + logger.info("统计数据后台输出完成") except Exception as e: logger.exception(f"后台统计数据输出过程中发生异常:{e}")