mirror of
https://github.com/Nezumi-2711/astrbot_plugin_qq_group_daily_analysis.git
synced 2026-09-22 05:31:52 +00:00
fix: prevent Chinese output in group analysis command
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user