fix: prevent Chinese output in group analysis command

This commit is contained in:
2026-07-24 15:28:24 +07:00
parent 5f74728b69
commit a9d0715f60
7 changed files with 271 additions and 38 deletions
+1
View File
@@ -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 ### 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 src/infrastructure/reporting/templates/ATRI/*.html whitespace=cr-at-eol
*.py whitespace=cr-at-eol
+3 -1
View File
@@ -506,7 +506,9 @@ class GroupDailyAnalysis(Star):
self._background_tasks.add(current_task) self._background_tasks.add(current_task)
try: 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) group_id = self._get_group_id_from_event(event)
platform_id = self._get_platform_id_from_event(event) platform_id = self._get_platform_id_from_event(event)
+1 -1
View File
@@ -1,7 +1,7 @@
name: astrbot_plugin_qq_group_daily_analysis # Mã định danh duy nhất của plugin. 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. 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. 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ả. author: SXP-Simon # Tác giả.
astrbot_version: ">=4.16.0" astrbot_version: ">=4.16.0"
support_platforms: support_platforms:
@@ -22,6 +22,7 @@ from markupsafe import Markup
from ...domain.repositories.report_repository import IReportGenerator from ...domain.repositories.report_repository import IReportGenerator
from ...utils.logger import logger from ...utils.logger import logger
from ...shared.vietnamese_language import sanitize_analysis_result_language
from ..utils.template_utils import render_template from ..utils.template_utils import render_template
from ..visualization.activity_charts import ActivityVisualizer from ..visualization.activity_charts import ActivityVisualizer
from .qq_official_markdown import QQOfficialMarkdownReportGenerator from .qq_official_markdown import QQOfficialMarkdownReportGenerator
@@ -171,6 +172,17 @@ class ReportGenerator(IReportGenerator):
self._avatar_session = None self._avatar_session = None
self._profile_asset_manifest = self._load_profile_asset_manifest() 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]: def _load_profile_asset_manifest(self) -> dict[str, dict]:
"""Tải manifest tài nguyên hồ sơ.""" """Tải manifest tài nguyên hồ sơ."""
manifest_path = ( manifest_path = (
@@ -426,6 +438,7 @@ class ReportGenerator(IReportGenerator):
""" """
html_content = None html_content = None
try: try:
self._enforce_vietnamese_report_content(analysis_result)
# Chuẩn bị dữ liệu render. # Chuẩn bị dữ liệu render.
render_payload = await self._prepare_render_data( render_payload = await self._prepare_render_data(
analysis_result, analysis_result,
@@ -596,6 +609,7 @@ class ReportGenerator(IReportGenerator):
Tuple path tệp HTML và JSON. Tuple path tệp HTML và JSON.
""" """
try: try:
self._enforce_vietnamese_report_content(analysis_result)
import json import json
# Đảm bảo thư mục output tồn tại mà không block event loop. # Đả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: 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.""" """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"] stats = analysis_result["statistics"]
topics = analysis_result["topics"] topics = analysis_result["topics"]
user_titles = analysis_result["user_titles"] user_titles = analysis_result["user_titles"]
@@ -785,6 +800,7 @@ class ReportGenerator(IReportGenerator):
self, analysis_result: dict, html_render_func=None self, analysis_result: dict, html_render_func=None
) -> tuple[str, str]: ) -> tuple[str, str]:
"""Delegate QQ-only text generation to the platform-specific module.""" """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) generator = getattr(self, "_qq_official_markdown_generator", None)
if generator is None: if generator is None:
generator = QQOfficialMarkdownReportGenerator( generator = QQOfficialMarkdownReportGenerator(
+130
View File
@@ -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
+39 -36
View File
@@ -56,9 +56,9 @@ def test_standard_text_report_keeps_existing_identity_format():
most_active_period="12:00-13:00", most_active_period="12:00-13:00",
golden_quotes=[ golden_quotes=[
SimpleNamespace( SimpleNamespace(
content="测试内容", content="Nội dung kiểm thử",
sender=openid, 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, "statistics": statistics,
"topics": [ "topics": [
SimpleNamespace( SimpleNamespace(
topic="测试话题", topic="Chủ đề kiểm thử",
contributors=[openid], contributors=[openid],
detail=f"{openid} 参与讨论", detail=f"{openid} tham gia thảo luận",
) )
], ],
"user_titles": [ "user_titles": [
SimpleNamespace( SimpleNamespace(
name=openid, name=openid,
title="龙王", title="Vua trò chuyện",
mbti="ENTP", mbti="ENTP",
reason=f"{openid} 发言最多", reason=f"{openid} phát biểu nhiều nhất",
) )
], ],
"user_analysis": {openid: {"nickname": openid}}, "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) report = generator.generate_text_report(analysis_result)
assert openid in report assert openid in report
assert "测试内容" in report assert "Nội dung kiểm thử" in report
assert "龙王" in report assert "Vua trò chuyện" in report
assert f"Người tham gia: {openid}" in report assert f"Người tham gia: {openid}" in report
assert f"{openid} - 龙王 (ENTP)" in report assert f"{openid} - Vua trò chuyện (ENTP)" in report
assert f'1. "测试内容" —— {openid}' 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(): 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", most_active_period="12:00-13:00",
golden_quotes=[ golden_quotes=[
SimpleNamespace( SimpleNamespace(
content=f"[{openid}] 说了一句话", content=f"[{openid}] đã nói một câu",
sender=nickname, sender=nickname,
reason=f"{nickname} 的发言很精彩", reason=f"Phát biểu của {nickname} rất ấn tượng",
user_id=openid, user_id=openid,
) )
], ],
@@ -147,19 +147,19 @@ def test_qq_official_markdown_uses_mentions_for_all_identity_sections():
"statistics": statistics, "statistics": statistics,
"topics": [ "topics": [
SimpleNamespace( SimpleNamespace(
topic="测试话题", topic="Chủ đề kiểm thử",
contributors=[nickname], contributors=[nickname],
contributor_ids=[openid], contributor_ids=[openid],
detail=f"{nickname} {openid} 参与讨论", detail=f"{nickname} {openid} tham gia thảo luận",
) )
], ],
"user_titles": [ "user_titles": [
SimpleNamespace( SimpleNamespace(
name=nickname, name=nickname,
user_id=openid, user_id=openid,
title="龙王", title="Vua trò chuyện",
mbti="ENTP", mbti="ENTP",
reason=f"[{openid}] 发言最多", reason=f"[{openid}] phát biểu nhiều nhất",
) )
], ],
"user_analysis": {openid: {"nickname": nickname}}, "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 nickname not in without_mentions
assert "## 💬 Chủ đề nổi bật" in report assert "## 💬 Chủ đề nổi bật" in report
assert "**Người tham gia**" in report assert "**Người tham gia**" in report
assert "**龙王**" in report assert "**Vua trò chuyện**" in report
assert f"- **1. <@{openid}> 说了一句话** — <@{openid}>" in report assert f"- **1. <@{openid}> đã nói một câu** — <@{openid}>" in report
assert f" > <@{openid}> 的发言很精彩" in report assert f" > Phát biểu của <@{openid}> rất ấn tượng" in report
assert f"> 1. <@{openid}> 说了一句话" not 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(): 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", most_active_period="12:00-13:00",
golden_quotes=[ golden_quotes=[
SimpleNamespace( SimpleNamespace(
content="测试内容", content="Nội dung kiểm thử",
sender="无法映射的用户", sender="无法映射的用户",
reason="理由保留", reason="Lý do được giữ lại",
user_id="", user_id="",
) )
], ],
@@ -203,9 +203,9 @@ def test_qq_official_markdown_keeps_content_when_identity_id_is_missing():
SimpleNamespace( SimpleNamespace(
name="无法映射的用户", name="无法映射的用户",
user_id="", user_id="",
title="龙王", title="Vua trò chuyện",
mbti="", mbti="",
reason="称号理由", reason="Lý do trao danh hiệu",
) )
], ],
"user_analysis": {}, "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) report = generate_qq_markdown(generator, analysis_result)
assert "<@" not in report assert "<@" not in report
assert "龙王" in report assert "Vua trò chuyện" in report
assert "称号理由" in report assert "Lý do trao danh hiệu" in report
assert "测试内容" in report assert "Nội dung kiểm thử" in report
assert "理由保留" in report assert "Lý do được giữ lại" in report
assert "无法映射的用户" not in report assert "无法映射的用户" not in report
assert "- **1. 测试内容**" in report assert "- **1. Nội dung kiểm thử**" in report
assert " > 理由保留" in report assert " > Lý do được giữ lại" in report
assert "> 1. 测试内容" not in report assert "> 1. Nội dung kiểm thử" not in report
def test_qq_official_scripture_spacing_and_optional_reason(): 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", most_active_period="12:00-13:00",
golden_quotes=[ golden_quotes=[
SimpleNamespace( SimpleNamespace(
content="第一条", content="Câu nói thứ nhất",
sender="", sender="",
reason="第一条理由", reason="Lý do thứ nhất",
user_id="A_OPENID", user_id="A_OPENID",
), ),
SimpleNamespace( SimpleNamespace(
content="第二条", content="Câu nói thứ hai",
sender="", sender="",
reason="", reason="",
user_id="", user_id="",
@@ -258,8 +258,11 @@ def test_qq_official_scripture_spacing_and_optional_reason():
report = generate_qq_markdown(generator, analysis_result) report = generate_qq_markdown(generator, analysis_result)
assert "- **1. 第一条** — <@A_OPENID>\n > 第一条理由\n\n- **2. 第二条**" in report assert (
assert "- **2. 第二条** —" not in report "- **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(): def test_qq_official_markdown_renders_simple_hourly_bar_chart():
+81
View File
@@ -1,4 +1,5 @@
import json import json
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from src.infrastructure.analysis.analyzers.base_analyzer import ( from src.infrastructure.analysis.analyzers.base_analyzer import (
@@ -11,6 +12,7 @@ from src.infrastructure.analysis.utils.response_validation import (
validate_topic_items, validate_topic_items,
validate_user_title_items, validate_user_title_items,
) )
from src.shared.vietnamese_language import sanitize_analysis_result_language
class DummyAnalyzer(BaseAnalyzer[dict, list[dict]]): 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 "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 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"
)