diff --git a/.gitattributes b/.gitattributes index fa31884..21e4483 100644 --- a/.gitattributes +++ b/.gitattributes @@ -16,3 +16,4 @@ tests/ export-ignore ### Các template ATRI hiện dùng CRLF; không coi CR cuối dòng là khoảng trắng thừa src/infrastructure/reporting/templates/ATRI/*.html whitespace=cr-at-eol +*.py whitespace=cr-at-eol diff --git a/main.py b/main.py index 0bccabd..5e603d2 100644 --- a/main.py +++ b/main.py @@ -506,7 +506,9 @@ class GroupDailyAnalysis(Star): self._background_tasks.add(current_task) try: - event.should_call_llm(True) # Ngăn LLM mặc định phân tích. + # Chặn cả LLM mặc định lẫn các handler tiếp theo xử lý lại slash command. + event.should_call_llm(True) + event.stop_event() group_id = self._get_group_id_from_event(event) platform_id = self._get_platform_id_from_event(event) diff --git a/metadata.yaml b/metadata.yaml index 914b598..36f8a79 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,7 +1,7 @@ name: astrbot_plugin_qq_group_daily_analysis # Mã định danh duy nhất của plugin. display_name: Plugin tổng hợp và phân tích nhóm # Tên hiển thị của plugin. desc: "Plugin tổng hợp và phân tích hoạt động nhóm hằng ngày - hỗ trợ OneBot (NapCat, LLOneBot, Snowluma), bot QQ chính thức, Telegram và Discord; tạo báo cáo phân tích trò chuyện nhóm trực quan, hỗ trợ phân tích chủ đề, chân dung thành viên và trích dẫn nổi bật." # Mô tả ngắn của plugin. -version: v4.11.2 # Phiên bản plugin, theo định dạng v1.1.1 hoặc v1.1. +version: v4.11.3 # Phiên bản plugin, theo định dạng v1.1.1 hoặc v1.1. author: SXP-Simon # Tác giả. astrbot_version: ">=4.16.0" support_platforms: diff --git a/src/infrastructure/reporting/generators.py b/src/infrastructure/reporting/generators.py index b898c51..2b6311d 100644 --- a/src/infrastructure/reporting/generators.py +++ b/src/infrastructure/reporting/generators.py @@ -22,6 +22,7 @@ from markupsafe import Markup from ...domain.repositories.report_repository import IReportGenerator from ...utils.logger import logger +from ...shared.vietnamese_language import sanitize_analysis_result_language from ..utils.template_utils import render_template from ..visualization.activity_charts import ActivityVisualizer from .qq_official_markdown import QQOfficialMarkdownReportGenerator @@ -171,6 +172,17 @@ class ReportGenerator(IReportGenerator): self._avatar_session = None self._profile_asset_manifest = self._load_profile_asset_manifest() + @staticmethod + def _enforce_vietnamese_report_content(analysis_result: dict) -> None: + """Loại nội dung sinh còn chữ Hán trước mọi đường kết xuất báo cáo.""" + removed_fields = sanitize_analysis_result_language(analysis_result) + if removed_fields: + logger.warning( + "Đã loại %s trường không phải tiếng Việt trước khi tạo báo cáo: %s", + len(removed_fields), + ", ".join(removed_fields), + ) + def _load_profile_asset_manifest(self) -> dict[str, dict]: """Tải manifest tài nguyên hồ sơ.""" manifest_path = ( @@ -426,6 +438,7 @@ class ReportGenerator(IReportGenerator): """ html_content = None try: + self._enforce_vietnamese_report_content(analysis_result) # Chuẩn bị dữ liệu render. render_payload = await self._prepare_render_data( analysis_result, @@ -596,6 +609,7 @@ class ReportGenerator(IReportGenerator): Tuple path tệp HTML và JSON. """ try: + self._enforce_vietnamese_report_content(analysis_result) import json # Đảm bảo thư mục output tồn tại mà không block event loop. @@ -742,6 +756,7 @@ class ReportGenerator(IReportGenerator): def generate_text_report(self, analysis_result: dict) -> str: """Tạo báo cáo phân tích dạng văn bản.""" + self._enforce_vietnamese_report_content(analysis_result) stats = analysis_result["statistics"] topics = analysis_result["topics"] user_titles = analysis_result["user_titles"] @@ -785,6 +800,7 @@ class ReportGenerator(IReportGenerator): self, analysis_result: dict, html_render_func=None ) -> tuple[str, str]: """Delegate QQ-only text generation to the platform-specific module.""" + self._enforce_vietnamese_report_content(analysis_result) generator = getattr(self, "_qq_official_markdown_generator", None) if generator is None: generator = QQOfficialMarkdownReportGenerator( diff --git a/src/shared/vietnamese_language.py b/src/shared/vietnamese_language.py new file mode 100644 index 0000000..5dd390f --- /dev/null +++ b/src/shared/vietnamese_language.py @@ -0,0 +1,130 @@ +"""Tiện ích thuần để kiểm tra ngôn ngữ đầu ra báo cáo.""" + +from __future__ import annotations + +import re + +HAN_CHARACTER_PATTERN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]") + + +def contains_han_characters(value: object) -> bool: + """Kiểm tra chuỗi có chứa chữ Hán hay không.""" + return bool(HAN_CHARACTER_PATTERN.search(str(value or ""))) + + +def sanitize_analysis_result_language(analysis_result: dict) -> list[str]: + """Loại nội dung sinh còn chữ Hán trước khi tạo báo cáo. + + Đây là cổng phòng vệ cuối cho dữ liệu cũ hoặc đường gọi không đi qua + analyzer. Các trường danh tính như tên người dùng, người gửi, ID và người + tham gia không bị kiểm tra. + """ + + def get_value(container: object, field: str, default=None): + if isinstance(container, dict): + return container.get(field, default) + return getattr(container, field, default) + + def set_value(container: object, field: str, value: object) -> None: + if isinstance(container, dict): + container[field] = value + else: + setattr(container, field, value) + + known_identities: set[str] = set() + user_analysis = analysis_result.get("user_analysis", {}) + if isinstance(user_analysis, dict): + for user_id, user_data in user_analysis.items(): + known_identities.add(str(user_id).strip()) + if isinstance(user_data, dict): + for field in ("nickname", "name", "card"): + known_identities.add(str(user_data.get(field, "")).strip()) + + for topic in analysis_result.get("topics", []): + contributors = get_value(topic, "contributors", []) + if isinstance(contributors, list): + known_identities.update(str(item).strip() for item in contributors) + for title in analysis_result.get("user_titles", []): + known_identities.add(str(get_value(title, "name", "")).strip()) + + statistics = analysis_result.get("statistics") + if statistics is not None: + quotes = get_value(statistics, "golden_quotes", []) + if isinstance(quotes, list): + for quote in quotes: + known_identities.add(str(get_value(quote, "sender", "")).strip()) + + known_identities.discard("") + + def contains_untranslated_han(value: object) -> bool: + text = str(value or "") + for identity in sorted(known_identities, key=len, reverse=True): + text = text.replace(identity, "") + return contains_han_characters(text) + + removed: list[str] = [] + for collection_name, fields in ( + ("topics", ("topic", "detail")), + ("user_titles", ("title", "reason")), + ): + collection = analysis_result.get(collection_name, []) + if not isinstance(collection, list): + continue + kept = [] + for index, item in enumerate(collection): + invalid_fields = [ + field + for field in fields + if contains_untranslated_han(get_value(item, field, "")) + ] + if invalid_fields: + removed.extend( + f"{collection_name}[{index}].{field}" for field in invalid_fields + ) + continue + kept.append(item) + analysis_result[collection_name] = kept + + if statistics is not None: + quotes = get_value(statistics, "golden_quotes", []) + if isinstance(quotes, list): + kept_quotes = [] + for index, quote in enumerate(quotes): + invalid_fields = [ + field + for field in ("content", "reason") + if contains_untranslated_han(get_value(quote, field, "")) + ] + if invalid_fields: + removed.extend( + f"statistics.golden_quotes[{index}].{field}" + for field in invalid_fields + ) + continue + kept_quotes.append(quote) + set_value(statistics, "golden_quotes", kept_quotes) + + quality_review = analysis_result.get("chat_quality_review") + if quality_review is None and statistics is not None: + quality_review = get_value(statistics, "chat_quality_review") + if quality_review is not None: + invalid_fields = [ + field + for field in ("title", "subtitle", "summary") + if contains_untranslated_han(get_value(quality_review, field, "")) + ] + dimensions = get_value(quality_review, "dimensions", []) + if isinstance(dimensions, list): + for index, dimension in enumerate(dimensions): + invalid_fields.extend( + f"dimensions[{index}].{field}" + for field in ("name", "comment") + if contains_untranslated_han(get_value(dimension, field, "")) + ) + if invalid_fields: + removed.extend(f"chat_quality_review.{field}" for field in invalid_fields) + analysis_result["chat_quality_review"] = None + if statistics is not None: + set_value(statistics, "chat_quality_review", None) + + return removed diff --git a/tests/test_avatar_only_reporting.py b/tests/test_avatar_only_reporting.py index 2e1c215..0a24c79 100644 --- a/tests/test_avatar_only_reporting.py +++ b/tests/test_avatar_only_reporting.py @@ -56,9 +56,9 @@ def test_standard_text_report_keeps_existing_identity_format(): most_active_period="12:00-13:00", golden_quotes=[ SimpleNamespace( - content="测试内容", + content="Nội dung kiểm thử", sender=openid, - reason=f"由 {openid} 发出", + reason=f"Được phát biểu bởi {openid}", ) ], ) @@ -66,17 +66,17 @@ def test_standard_text_report_keeps_existing_identity_format(): "statistics": statistics, "topics": [ SimpleNamespace( - topic="测试话题", + topic="Chủ đề kiểm thử", contributors=[openid], - detail=f"{openid} 参与讨论", + detail=f"{openid} tham gia thảo luận", ) ], "user_titles": [ SimpleNamespace( name=openid, - title="龙王", + title="Vua trò chuyện", mbti="ENTP", - reason=f"{openid} 发言最多", + reason=f"{openid} phát biểu nhiều nhất", ) ], "user_analysis": {openid: {"nickname": openid}}, @@ -85,11 +85,11 @@ def test_standard_text_report_keeps_existing_identity_format(): report = generator.generate_text_report(analysis_result) assert openid in report - assert "测试内容" in report - assert "龙王" in report + assert "Nội dung kiểm thử" in report + assert "Vua trò chuyện" in report assert f"Người tham gia: {openid}" in report - assert f"• {openid} - 龙王 (ENTP)" in report - assert f'1. "测试内容" —— {openid}' in report + assert f"• {openid} - Vua trò chuyện (ENTP)" in report + assert f'1. "Nội dung kiểm thử" —— {openid}' in report def test_standard_text_report_api_has_no_qq_platform_switches(): @@ -136,9 +136,9 @@ def test_qq_official_markdown_uses_mentions_for_all_identity_sections(): most_active_period="12:00-13:00", golden_quotes=[ SimpleNamespace( - content=f"[{openid}] 说了一句话", + content=f"[{openid}] đã nói một câu", sender=nickname, - reason=f"{nickname} 的发言很精彩", + reason=f"Phát biểu của {nickname} rất ấn tượng", user_id=openid, ) ], @@ -147,19 +147,19 @@ def test_qq_official_markdown_uses_mentions_for_all_identity_sections(): "statistics": statistics, "topics": [ SimpleNamespace( - topic="测试话题", + topic="Chủ đề kiểm thử", contributors=[nickname], contributor_ids=[openid], - detail=f"{nickname} 和 {openid} 参与讨论", + detail=f"{nickname} và {openid} tham gia thảo luận", ) ], "user_titles": [ SimpleNamespace( name=nickname, user_id=openid, - title="龙王", + title="Vua trò chuyện", mbti="ENTP", - reason=f"[{openid}] 发言最多", + reason=f"[{openid}] phát biểu nhiều nhất", ) ], "user_analysis": {openid: {"nickname": nickname}}, @@ -173,10 +173,10 @@ def test_qq_official_markdown_uses_mentions_for_all_identity_sections(): assert nickname not in without_mentions assert "## 💬 Chủ đề nổi bật" in report assert "**Người tham gia**" in report - assert "**龙王**" in report - assert f"- **1. <@{openid}> 说了一句话** — <@{openid}>" in report - assert f" > <@{openid}> 的发言很精彩" in report - assert f"> 1. <@{openid}> 说了一句话" not in report + assert "**Vua trò chuyện**" in report + assert f"- **1. <@{openid}> đã nói một câu** — <@{openid}>" in report + assert f" > Phát biểu của <@{openid}> rất ấn tượng" in report + assert f"> 1. <@{openid}> đã nói một câu" not in report def test_qq_official_markdown_keeps_content_when_identity_id_is_missing(): @@ -189,9 +189,9 @@ def test_qq_official_markdown_keeps_content_when_identity_id_is_missing(): most_active_period="12:00-13:00", golden_quotes=[ SimpleNamespace( - content="测试内容", + content="Nội dung kiểm thử", sender="无法映射的用户", - reason="理由保留", + reason="Lý do được giữ lại", user_id="", ) ], @@ -203,9 +203,9 @@ def test_qq_official_markdown_keeps_content_when_identity_id_is_missing(): SimpleNamespace( name="无法映射的用户", user_id="", - title="龙王", + title="Vua trò chuyện", mbti="", - reason="称号理由", + reason="Lý do trao danh hiệu", ) ], "user_analysis": {}, @@ -214,14 +214,14 @@ def test_qq_official_markdown_keeps_content_when_identity_id_is_missing(): report = generate_qq_markdown(generator, analysis_result) assert "<@" not in report - assert "龙王" in report - assert "称号理由" in report - assert "测试内容" in report - assert "理由保留" in report + assert "Vua trò chuyện" in report + assert "Lý do trao danh hiệu" in report + assert "Nội dung kiểm thử" in report + assert "Lý do được giữ lại" in report assert "无法映射的用户" not in report - assert "- **1. 测试内容**" in report - assert " > 理由保留" in report - assert "> 1. 测试内容" not in report + assert "- **1. Nội dung kiểm thử**" in report + assert " > Lý do được giữ lại" in report + assert "> 1. Nội dung kiểm thử" not in report def test_qq_official_scripture_spacing_and_optional_reason(): @@ -234,13 +234,13 @@ def test_qq_official_scripture_spacing_and_optional_reason(): most_active_period="12:00-13:00", golden_quotes=[ SimpleNamespace( - content="第一条", + content="Câu nói thứ nhất", sender="甲", - reason="第一条理由", + reason="Lý do thứ nhất", user_id="A_OPENID", ), SimpleNamespace( - content="第二条", + content="Câu nói thứ hai", sender="乙", reason="", user_id="", @@ -258,8 +258,11 @@ def test_qq_official_scripture_spacing_and_optional_reason(): report = generate_qq_markdown(generator, analysis_result) - assert "- **1. 第一条** — <@A_OPENID>\n > 第一条理由\n\n- **2. 第二条**" in report - assert "- **2. 第二条** —" not in report + assert ( + "- **1. Câu nói thứ nhất** — <@A_OPENID>\n > Lý do thứ nhất\n\n- **2. Câu nói thứ hai**" + in report + ) + assert "- **2. Câu nói thứ hai** —" not in report def test_qq_official_markdown_renders_simple_hourly_bar_chart(): diff --git a/tests/test_vietnamese_analysis_output.py b/tests/test_vietnamese_analysis_output.py index 8708f8b..02ed84c 100644 --- a/tests/test_vietnamese_analysis_output.py +++ b/tests/test_vietnamese_analysis_output.py @@ -1,4 +1,5 @@ import json +from dataclasses import dataclass from pathlib import Path from src.infrastructure.analysis.analyzers.base_analyzer import ( @@ -11,6 +12,7 @@ from src.infrastructure.analysis.utils.response_validation import ( validate_topic_items, validate_user_title_items, ) +from src.shared.vietnamese_language import sanitize_analysis_result_language class DummyAnalyzer(BaseAnalyzer[dict, list[dict]]): @@ -152,3 +154,82 @@ def test_default_golden_quote_prompt_requires_translation(): assert "dịch phát biểu sang tiếng Việt" in prompt assert "content` phải giữ nguyên lời nói gốc" not in prompt + + +@dataclass +class ReportItem: + topic: str = "" + detail: str = "" + name: str = "" + title: str = "" + reason: str = "" + content: str = "" + sender: str = "" + + +@dataclass +class ReportStatistics: + golden_quotes: list[ReportItem] + chat_quality_review: dict | None = None + + +def test_report_boundary_removes_chinese_semantics_but_keeps_identity(): + chinese_identity = "测试用户" + vietnamese_identity = "Nguyễn Văn A" + analysis_result = { + "topics": [ + ReportItem(topic="技术讨论", detail="讨论 rất sâu"), + ReportItem(topic="Kế hoạch cuối tuần", detail="Cả nhóm sẽ đi chơi."), + ], + "user_titles": [ + ReportItem(name=chinese_identity, title="技术专家", reason="Rất giỏi"), + ReportItem( + name=chinese_identity, + title="Chuyên gia kỹ thuật", + reason="Thường xuyên hỗ trợ mọi người.", + ), + ], + "statistics": ReportStatistics( + golden_quotes=[ + ReportItem(content="代码能跑就不要动", sender=chinese_identity), + ReportItem( + content="Chạy được thì đừng sửa.", sender=vietnamese_identity + ), + ] + ), + "chat_quality_review": { + "title": "今日群聊", + "subtitle": "Một ngày vui vẻ", + "dimensions": [], + "summary": "Mọi người trò chuyện tích cực.", + }, + } + + removed = sanitize_analysis_result_language(analysis_result) + + assert len(analysis_result["topics"]) == 1 + assert analysis_result["topics"][0].topic == "Kế hoạch cuối tuần" + assert len(analysis_result["user_titles"]) == 1 + assert analysis_result["user_titles"][0].name == chinese_identity + assert len(analysis_result["statistics"].golden_quotes) == 1 + assert analysis_result["statistics"].golden_quotes[0].sender == vietnamese_identity + assert analysis_result["chat_quality_review"] is None + assert analysis_result["statistics"].chat_quality_review is None + assert "topics[0].topic" in removed + assert "user_titles[0].title" in removed + assert "statistics.golden_quotes[0].content" in removed + assert "chat_quality_review.title" in removed + + +def test_group_analysis_command_stops_event_propagation(): + main_path = Path(__file__).resolve().parents[1] / "main.py" + source = main_path.read_text(encoding="utf-8") + command_start = source.index("async def analyze_group_daily(") + command_end = source.index("async def _send_analysis_report(", command_start) + command_source = source[command_start:command_end] + + assert "event.should_call_llm(True)" in command_source + assert "event.stop_event()" in command_source + assert command_source.index("event.stop_event()") < command_source.index( + "execute_daily_analysis" + )