From 90d012db9d93c7f0812a625b4bf9436f9c4c1ef0 Mon Sep 17 00:00:00 2001 From: EterUltimate <1831303476@qq.com> Date: Sat, 19 Sep 2026 12:34:22 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(analysis):=20=E8=A1=A8=E8=BE=BE?= =?UTF-8?q?=E5=AD=A6=E4=B9=A0=E6=89=B9=E9=87=8F=E9=93=BE=E8=B7=AF=E5=90=88?= =?UTF-8?q?=E5=B9=B6=20Bot=20=E5=9B=9E=E5=A4=8D=E5=B9=B6=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E4=BA=BA=E6=A0=BC=E5=85=BC=E5=AE=B9=E6=80=A7=E5=8F=82=E6=95=B0?= =?UTF-8?q?=20(#257)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 批量/审批链路的 filtered_messages 仅含用户消息,_extract_few_shot_pairs 需要用户→bot 相邻对,导致恒学习不到模式(issue #257 未获得有效结果)。将合并 BotMessage 的逻辑下沉到 ExpressionPatternLearner.trigger_learning_for_group,仅在未含 bot 时按时间线合并,实时链路行为不变。同时修正 analyze_persona_compatibility 未向 get_current_persona 传必填 group_id 的潜在 TypeError。新增真实 sqlite 回归测试。 --- CHANGELOG.md | 8 ++ .../analysis/expression_pattern_learner.py | 78 +++++++++++++++++- services/persona/persona_updater.py | 11 ++- tests/unit/test_learning_chain_regressions.py | 81 +++++++++++++++++++ 4 files changed, 174 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99004036..5d565f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ 所有重要更改都将记录在此文件中。 +## [Unreleased] + +### 修复(表达模式学习链路隐患,延续 issue #257) + +- 批量/审批链路的表达模式学习以前只拿到用户原始消息(`filtered_messages` 来自 `get_unprocessed_messages`,不含 bot 回复),而 `_extract_few_shot_pairs` 需要「用户→bot」相邻对话对,导致恒学习不到任何模式(issue #257 日志中的“未获得有效结果”)。现将合并数据库中 `BotMessage` 回复的逻辑下沉到 `ExpressionPatternLearner.trigger_learning_for_group`:仅在传入消息不含 bot 时按时间线自动合并,实时链路(已预合并)行为不变。 +- 修正 `PersonaUpdater.analyze_persona_compatibility` 调用 `get_current_persona()` 未传必填 `group_id` 导致的潜在 `TypeError`(改为接受并透传 `group_id`)。 +- 新增回归测试:以真实 sqlite 验证批量链路合并 bot 回复后能学到模式,并验证已含 bot / 无 db 时不重复查询。 + ## [4.2.4] - 2026-09-19 ### 修复:表达模式学习按群组/人格隔离降级为 default(issue #257) diff --git a/services/analysis/expression_pattern_learner.py b/services/analysis/expression_pattern_learner.py index 095bf534..2487f098 100644 --- a/services/analysis/expression_pattern_learner.py +++ b/services/analysis/expression_pattern_learner.py @@ -112,7 +112,77 @@ def _expression_scope_key( user_id: Optional[str] = None, ) -> str: return f"{group_id}:{persona_id}:{user_id or 'group-level'}" - + + @staticmethod + def _msg_sender_id(msg: Any) -> str: + if hasattr(msg, "sender_id"): + return str(getattr(msg, "sender_id", "") or "") + if isinstance(msg, dict): + return str(msg.get("sender_id", "") or "") + return "" + + @staticmethod + def _msg_timestamp(msg: Any) -> float: + value = ( + getattr(msg, "timestamp", 0) + if hasattr(msg, "timestamp") + else msg.get("timestamp", 0) if isinstance(msg, dict) else 0 + ) + try: + return float(value or 0) + except (TypeError, ValueError): + return 0.0 + + async def _merge_bot_messages_for_group( + self, group_id: str, messages: List[Any] + ) -> List[Any]: + """将数据库中的 Bot 回复按时间线合并进用户消息,供 few-shot 对话对提取。 + + 实时学习链路已在调用前合并过 bot 消息(消息中已含 sender_id=='bot'), + 此时直接返回避免重复查询;批量/审批链路传入的原始消息只有用户发言, + 必须合并 bot 回复才能提取到 用户→bot 对话对(否则学习结果恒为空, + 即 issue #257 日志中的“未获得有效结果”)。 + """ + if not messages or not self.db_manager: + return messages + if any(self._msg_sender_id(m) == "bot" for m in messages): + return messages + try: + from sqlalchemy import desc, select + + from ...models.orm.message import BotMessage + from ..learning.sample_filter import should_ignore_learning_sample + + async with self.db_manager.get_session() as session: + stmt = ( + select(BotMessage) + .where(BotMessage.group_id == group_id) + .order_by(desc(BotMessage.timestamp)) + .limit(max(len(messages), 25)) + ) + result = await session.execute(stmt) + bot_msgs: List[Dict[str, Any]] = [] + for row in result.scalars().all(): + if should_ignore_learning_sample( + row.message, sender_id="bot", is_bot=True + ): + continue + bot_msgs.append( + { + "sender_id": "bot", + "message": row.message, + "timestamp": float(row.timestamp), + } + ) + if not bot_msgs: + return messages + merged = list(messages) + bot_msgs + merged.sort(key=self._msg_timestamp) + return merged + except Exception as exc: + logger.debug(f"合并 Bot 回复失败,使用原始消息继续: {exc}") + return messages + @classmethod def get_instance(cls, config: PluginConfig = None, db_manager: DatabaseManager = None, context=None, llm_adapter=None) -> 'ExpressionPatternLearner': """获取单例实例,支持延迟初始化""" @@ -184,6 +254,12 @@ async def trigger_learning_for_group( return False try: + # 先合并数据库中已存储的 Bot 回复,才能提取到 用户→bot 对话对(修复批量链路恒为空) + recent_messages = await self._merge_bot_messages_for_group( + group_id, recent_messages + ) + if len(recent_messages) < 3: + return False persona_id = normalize_persona_scope(persona_id) user_scope = self._normalize_user_scope(user_id) learning_scope = self._expression_scope_key(group_id, persona_id, user_scope) diff --git a/services/persona/persona_updater.py b/services/persona/persona_updater.py index e1144ad9..7ed4ff78 100644 --- a/services/persona/persona_updater.py +++ b/services/persona/persona_updater.py @@ -710,10 +710,15 @@ def _extract_style_adjustments(self, style_analysis: Dict[str, Any]) -> str: except Exception as e: return f"提取失败: {str(e)}" - async def analyze_persona_compatibility(self, target_style: Dict[str, Any]) -> AnalysisResult: - """分析目标风格与当前人格的兼容性""" + async def analyze_persona_compatibility(self, target_style: Dict[str, Any], group_id: str = "default") -> AnalysisResult: + """分析目标风格与当前人格的兼容性 + + Args: + target_style: 目标风格 + group_id: 目标群组ID,用于解析该群组当前人格(缺失会误将 group_id 当作位置参数遗漏) + """ try: - current_persona = await self.get_current_persona() + current_persona = await self.get_current_persona(group_id) if not current_persona: return AnalysisResult( success=False, diff --git a/tests/unit/test_learning_chain_regressions.py b/tests/unit/test_learning_chain_regressions.py index 9e95a359..d3273d3f 100644 --- a/tests/unit/test_learning_chain_regressions.py +++ b/tests/unit/test_learning_chain_regressions.py @@ -2477,3 +2477,84 @@ async def test_update_persona_with_style_forwards_group_and_persona(monkeypatch) kwargs = updater._update_style_based_features_with_maibot.await_args.kwargs assert kwargs["group_id"] == "123456789" assert kwargs["persona_id"] == "鲸娘" + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_expression_learning_merges_stored_bot_replies_for_batch(tmp_path): + """回归 issue #257:批量/审批链路只传用户消息时,应合并数据库 Bot 回复后才能学到模式。""" + from self_learning_EterU.models.orm.message import BotMessage + + config = PluginConfig( + data_dir=str(tmp_path), + db_type="sqlite", + enable_web_interface=False, + ) + db = SQLAlchemyDatabaseManager(config) + try: + assert await db.start() is True + now = time.time() + base = int(now) + async with db.get_session() as session: + session.add_all( + [ + BotMessage( + group_id="group-a", + message=f"机器人回复内容第{idx}条确实很离谱", + timestamp=base + idx * 2, + created_at=base, + ) + for idx in range(1, 6) + ] + ) + await session.commit() + + learner = ExpressionPatternLearner.__new__(ExpressionPatternLearner) + learner.db_manager = db + learner.config = config + learner.last_learning_times = {} + + # 只有用户发言(无 bot),模拟批量/审批链路的 filtered_messages + user_messages = [ + { + "sender_id": f"user-{idx}", + "sender_name": f"User {idx}", + "message": f"这是用户第{idx}条足够长的学习消息内容", + "timestamp": now + idx * 2 - 1, + "group_id": "group-a", + } + for idx in range(1, 6) + ] + + ok = await learner.trigger_learning_for_group( + "group-a", user_messages, persona_id="bot-a" + ) + assert ok is True + patterns = await learner.get_expression_patterns("group-a", persona_id="bot-a") + assert len(patterns) >= 2 + finally: + await db.stop() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_expression_learning_merge_skipped_when_bot_present_or_no_db(): + """实时链路已预先合并 bot 消息,不应重复查询;无 db 时直接返回。""" + learner = ExpressionPatternLearner.__new__(ExpressionPatternLearner) + + learner.db_manager = None + user_only = [{"sender_id": "user-a", "message": "hi", "timestamp": 1}] + assert await learner._merge_bot_messages_for_group("g", user_only) is user_only + + def _boom(): + raise AssertionError("不应在已含 bot 消息时查询数据库") + + learner.db_manager = SimpleNamespace(get_session=_boom) + already_merged = [ + {"sender_id": "user-a", "message": "hi", "timestamp": 1}, + {"sender_id": "bot", "message": "yo", "timestamp": 2}, + ] + assert ( + await learner._merge_bot_messages_for_group("g", already_merged) + is already_merged + ) From 627707931c1e832211892bcb937f2a97b3e92210 Mon Sep 17 00:00:00 2001 From: EterUltimate <1831303476@qq.com> Date: Sat, 19 Sep 2026 12:49:45 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(analysis):=20Bot=20=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E9=99=90=E5=AE=9A=E6=97=B6=E9=97=B4=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E5=B9=B6=E5=85=88=E5=A4=9A=E5=8F=96=E5=80=99=E9=80=89?= =?UTF-8?q?=E5=86=8D=E8=BF=87=E6=BB=A4=EF=BC=88=E5=93=8D=E5=BA=94=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 Sourcery 意见:将合并查询限定在这批用户消息的时间窗口内,避免与无关/更晚的 bot 回复误配对;将 limit 改为先多取候选(fetch_limit=3x,>=60)再在内存过滤并截断,避免被 ignore 样本占满导致有效回复被漏。新增窗口边界回归测试。 --- .../analysis/expression_pattern_learner.py | 41 ++++++++++-- tests/unit/test_learning_chain_regressions.py | 62 +++++++++++++++++++ 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/services/analysis/expression_pattern_learner.py b/services/analysis/expression_pattern_learner.py index 2487f098..5a205ed6 100644 --- a/services/analysis/expression_pattern_learner.py +++ b/services/analysis/expression_pattern_learner.py @@ -133,6 +133,14 @@ def _msg_timestamp(msg: Any) -> float: except (TypeError, ValueError): return 0.0 + @staticmethod + def _msg_content(msg: Any) -> str: + if hasattr(msg, "message"): + return str(getattr(msg, "message", "") or "") + if isinstance(msg, dict): + return str(msg.get("message", "") or "") + return "" + async def _merge_bot_messages_for_group( self, group_id: str, messages: List[Any] ) -> List[Any]: @@ -142,23 +150,46 @@ async def _merge_bot_messages_for_group( 此时直接返回避免重复查询;批量/审批链路传入的原始消息只有用户发言, 必须合并 bot 回复才能提取到 用户→bot 对话对(否则学习结果恒为空, 即 issue #257 日志中的“未获得有效结果”)。 + + 为避免跨窗口误配对:只取与这批用户消息时间窗口(末条向后一个宽容窗口) + 重叠的 Bot 回复;为避免“先 limit 后过滤”将有效回复滤空:先多取候选再在 + 内存过滤并截断到与用户消息相当的数量。 """ if not messages or not self.db_manager: return messages if any(self._msg_sender_id(m) == "bot" for m in messages): return messages try: - from sqlalchemy import desc, select + from sqlalchemy import select from ...models.orm.message import BotMessage from ..learning.sample_filter import should_ignore_learning_sample + user_ts = [ + self._msg_timestamp(m) + for m in messages + if self._msg_content(m) + ] + user_ts = [t for t in user_ts if t > 0] + if not user_ts: + return messages + + forward_window_seconds = 3600.0 + t_lo = min(user_ts) + t_hi = max(user_ts) + forward_window_seconds + # 先多取候选,再过滤/截断,避免被 ignore 样本占满 limit 导致有效回复被漏掉 + fetch_limit = max(len(messages) * 3, 60) + async with self.db_manager.get_session() as session: stmt = ( select(BotMessage) - .where(BotMessage.group_id == group_id) - .order_by(desc(BotMessage.timestamp)) - .limit(max(len(messages), 25)) + .where( + BotMessage.group_id == group_id, + BotMessage.timestamp >= t_lo, + BotMessage.timestamp <= t_hi, + ) + .order_by(BotMessage.timestamp.asc()) + .limit(fetch_limit) ) result = await session.execute(stmt) bot_msgs: List[Dict[str, Any]] = [] @@ -174,6 +205,8 @@ async def _merge_bot_messages_for_group( "timestamp": float(row.timestamp), } ) + if len(bot_msgs) >= len(messages): + break if not bot_msgs: return messages merged = list(messages) + bot_msgs diff --git a/tests/unit/test_learning_chain_regressions.py b/tests/unit/test_learning_chain_regressions.py index d3273d3f..d93da7ef 100644 --- a/tests/unit/test_learning_chain_regressions.py +++ b/tests/unit/test_learning_chain_regressions.py @@ -2558,3 +2558,65 @@ def _boom(): await learner._merge_bot_messages_for_group("g", already_merged) is already_merged ) + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_merge_bot_replies_bounds_to_message_time_window(tmp_path): + """回归审查意见:合并只取与这批用户消息时间窗口重叠的 Bot 回复,避免跨窗口误配对。""" + from self_learning_EterU.models.orm.message import BotMessage + + config = PluginConfig( + data_dir=str(tmp_path), + db_type="sqlite", + enable_web_interface=False, + ) + db = SQLAlchemyDatabaseManager(config) + try: + assert await db.start() is True + now = time.time() + base = int(now) + async with db.get_session() as session: + session.add_all( + [ + BotMessage( + group_id="group-a", + message="窗口内的有效机器人回复", + timestamp=base + 2, + created_at=base, + ), + BotMessage( + group_id="group-a", + message="窗口外很晚的无关机器人回复", + timestamp=base + 99999, + created_at=base, + ), + ] + ) + await session.commit() + + learner = ExpressionPatternLearner.__new__(ExpressionPatternLearner) + learner.db_manager = db + learner.config = config + + user_messages = [ + { + "sender_id": f"user-{idx}", + "sender_name": f"User {idx}", + "message": f"这是用户第{idx}条足够长的学习消息", + "timestamp": now + idx, + "group_id": "group-a", + } + for idx in range(1, 6) + ] + + merged = await learner._merge_bot_messages_for_group("group-a", user_messages) + bot_texts = [ + m.get("message") + for m in merged + if isinstance(m, dict) and m.get("sender_id") == "bot" + ] + assert "窗口内的有效机器人回复" in bot_texts + assert "窗口外很晚的无关机器人回复" not in bot_texts + finally: + await db.stop()