From f0a4b3b8706e5a474be58b8082dd010b16746c70 Mon Sep 17 00:00:00 2001 From: NickMo Date: Wed, 25 Feb 2026 10:50:08 +0800 Subject: [PATCH 01/16] fix(learning): save filtered messages to DB in batch learning path Batch learning processed messages but never wrote them to the filtered_messages table. The WebUI filtered count (COUNT(*) on that table) therefore always stayed at 0. Now persist each filtered message after the filtering step. --- .../core_learning/progressive_learning.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/services/core_learning/progressive_learning.py b/services/core_learning/progressive_learning.py index 4851c82..951ac4c 100644 --- a/services/core_learning/progressive_learning.py +++ b/services/core_learning/progressive_learning.py @@ -250,7 +250,26 @@ async def _execute_learning_batch(self, group_id: str, relearn_mode: bool = Fals logger.debug("没有通过筛选的消息") await self._mark_messages_processed(unprocessed_messages) return - + + # 2.5 将筛选后的消息写入 FilteredMessage 表(供 WebUI 统计) + saved_count = 0 + for msg in filtered_messages: + try: + await self.message_collector.add_filtered_message({ + "raw_message_id": msg.get("id"), + "message": msg.get("message", ""), + "sender_id": msg.get("sender_id", ""), + "group_id": msg.get("group_id", group_id), + "timestamp": msg.get("timestamp", int(time.time())), + "confidence": msg.get("relevance_score", 1.0), + "filter_reason": msg.get("filter_reason", "batch_learning"), + }) + saved_count += 1 + except Exception: + pass # best-effort, don't block learning + if saved_count: + logger.debug(f"已保存 {saved_count}/{len(filtered_messages)} 条筛选消息到 FilteredMessage 表") + # 3. 获取当前人格设置 (针对特定群组) current_persona = await self._get_current_persona(group_id) From 7a6a5bf22e66c7429b2721b97980dbea5d6d512b Mon Sep 17 00:00:00 2001 From: NickMo Date: Wed, 25 Feb 2026 10:50:16 +0800 Subject: [PATCH 02/16] fix(webui): use neutral fallback for response speed when no LLM data When no LLM call stats exist the fallback avgResponseTime was 2000ms, yielding a response speed score of 0. Use a neutral 50 instead so the radar chart does not mislead users into thinking performance is bad. --- web_res/static/js/macos/apps/Dashboard.js | 7 ++++--- web_res/static/js/script.js | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/web_res/static/js/macos/apps/Dashboard.js b/web_res/static/js/macos/apps/Dashboard.js index f6a59e5..0e26727 100644 --- a/web_res/static/js/macos/apps/Dashboard.js +++ b/web_res/static/js/macos/apps/Dashboard.js @@ -555,14 +555,15 @@ window.AppDashboard = { llmModels.length) * 100 : 0; - // 响应速度 + // 响应速度(无 LLM 数据时不显示为 0,而是显示为中性值) var avgResponseTime = llmModels.length > 0 ? llmModels.reduce(function (s, m) { return s + (m.avg_response_time_ms || 0); }, 0) / llmModels.length - : 2000; - var responseSpeed = Math.max(0, 100 - avgResponseTime / 20); + : 0; + var responseSpeed = + llmModels.length > 0 ? Math.max(0, 100 - avgResponseTime / 20) : 50; // 系统稳定性 var sm = stats.system_metrics || {}; var systemStability = diff --git a/web_res/static/js/script.js b/web_res/static/js/script.js index 99d808b..81c7b25 100644 --- a/web_res/static/js/script.js +++ b/web_res/static/js/script.js @@ -938,8 +938,9 @@ function initializeSystemStatusRadar() { (sum, model) => sum + (model.avg_response_time_ms || 0), 0, ) / llmModels.length - : 2000; - const responseSpeed = Math.max(0, 100 - avgResponseTime / 20); // 2000ms = 0分,0ms = 100分 + : 0; + const responseSpeed = + llmModels.length > 0 ? Math.max(0, 100 - avgResponseTime / 20) : 50; // 系统稳定性 (基于CPU和内存使用率) const systemMetrics = stats.system_metrics || {}; From a9a99f4aa5e1ad97b7fa6a263d3478546dc4001b Mon Sep 17 00:00:00 2001 From: NickMo Date: Wed, 25 Feb 2026 14:02:47 +0800 Subject: [PATCH 03/16] fix(persona): use global default persona in WebUI instead of random UMO When multiple provider configs are active simultaneously, get_default_persona_for_web() used next(iter(dict.values())) to pick an arbitrary UMO from the group_id_to_unified_origin mapping, causing the displayed persona to change on every WebUI refresh. Pass None to get_default_persona_v3() to always return the stable global default persona. Per-group persona lookup remains available via get_persona_for_group(group_id). --- persona_web_manager.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/persona_web_manager.py b/persona_web_manager.py index 010ccd3..3930853 100644 --- a/persona_web_manager.py +++ b/persona_web_manager.py @@ -143,8 +143,9 @@ async def get_all_personas_for_web(self) -> List[Dict[str, Any]]: async def get_default_persona_for_web(self) -> Dict[str, Any]: """获取默认人格,格式化为Web界面需要的格式 - 使用 group_id_to_unified_origin 映射中的 UMO 来获取当前活跃配置的人格, - 而非始终返回 default 配置的人格。 + 始终传入 None 调用 get_default_persona_v3 以获取 AstrBot 全局默认人格, + 避免在多配置文件场景下因随机选取 UMO 而导致每次返回不同人格。 + 如需查看特定配置的人格,应通过 get_persona_for_group 并明确指定 group_id。 """ fallback = { "persona_id": "default", @@ -157,14 +158,9 @@ async def get_default_persona_for_web(self) -> Dict[str, Any]: return fallback try: - # 尝试从映射中获取一个 UMO,以加载当前活跃配置的人格 - umo = None - if self.group_id_to_unified_origin: - # 取任意一个 UMO(通常同一配置文件下的群组共享同一配置) - umo = next(iter(self.group_id_to_unified_origin.values()), None) - + # 获取全局默认人格,不依赖 group_id_to_unified_origin 映射 default_persona = await self._run_on_main_loop( - self.persona_manager.get_default_persona_v3(umo) + self.persona_manager.get_default_persona_v3(None) ) if default_persona: From aaf94997523d1b13e2febea2992f4a8eba4eb85c Mon Sep 17 00:00:00 2001 From: NickMo Date: Wed, 25 Feb 2026 23:22:02 +0800 Subject: [PATCH 04/16] fix(db): correct ORM field mappings for psychological state and mood persistence - Fix PsychologicalStateComponent attribute error by mapping to correct ORM columns (category/state_type instead of non-existent component_name) - Fix PsychologicalComponentRepository to use composite_state_id FK instead of mismatched state_id string column - Fix PsychologicalHistoryRepository.add_history to use correct ORM field names (old_state_type, new_state_type, change_reason, etc.) - Fix BotMoodRepository refresh failure after async commit by wrapping session.refresh in try/except with debug logging - Fix NoneType len() error in FrameworkLLMAdapter by normalizing contexts parameter to empty list before provider calls - Fix WebUI jargon dropdown showing undefined by adding group_id and group_name fields to get_jargon_groups API response --- core/framework_llm_adapter.py | 12 +++++ repositories/bot_mood_repository.py | 9 +++- repositories/psychological_repository.py | 51 ++++++++++++------- services/database/facades/jargon_facade.py | 6 ++- .../enhanced_psychological_state_manager.py | 16 ++++-- 5 files changed, 68 insertions(+), 26 deletions(-) diff --git a/core/framework_llm_adapter.py b/core/framework_llm_adapter.py index d5ffe2a..c5f8df7 100644 --- a/core/framework_llm_adapter.py +++ b/core/framework_llm_adapter.py @@ -243,6 +243,10 @@ async def filter_chat_completion( # 尝试延迟初始化 self._try_lazy_init() + # 确保 contexts 不为 None,避免 Provider 内部调用 len(None) + if contexts is None: + contexts = [] + if not self.filter_provider: logger.warning("筛选Provider未配置,尝试使用备选Provider或降级处理") # 尝试使用其他可用的Provider作为备选 @@ -301,6 +305,10 @@ async def refine_chat_completion( # 尝试延迟初始化 self._try_lazy_init() + # 确保 contexts 不为 None,避免 Provider 内部调用 len(None) + if contexts is None: + contexts = [] + if not self.refine_provider: logger.warning("提炼Provider未配置,尝试使用备选Provider或降级处理") # 尝试使用其他可用的Provider作为备选 @@ -359,6 +367,10 @@ async def reinforce_chat_completion( # 尝试延迟初始化 self._try_lazy_init() + # 确保 contexts 不为 None,避免 Provider 内部调用 len(None) + if contexts is None: + contexts = [] + if not self.reinforce_provider: logger.warning("强化Provider未配置,尝试使用备选Provider或降级处理") # 尝试使用其他可用的Provider作为备选 diff --git a/repositories/bot_mood_repository.py b/repositories/bot_mood_repository.py index b8f9d99..7048b1e 100644 --- a/repositories/bot_mood_repository.py +++ b/repositories/bot_mood_repository.py @@ -55,7 +55,14 @@ async def save(self, mood_data: Dict[str, Any]) -> Optional[BotMood]: mood = BotMood(**mood_data) self.session.add(mood) await self.session.commit() - await self.session.refresh(mood) + try: + await self.session.refresh(mood) + except Exception as refresh_err: + # 部分 async 驱动在 commit 后 refresh 可能失败, + # 此时 mood 对象已持久化且 ID 已赋值,可安全返回 + logger.debug( + f"[BotMoodRepository] refresh after commit skipped: {refresh_err}" + ) return mood except Exception as e: await self.session.rollback() diff --git a/repositories/psychological_repository.py b/repositories/psychological_repository.py index ccc74f3..96b07ec 100644 --- a/repositories/psychological_repository.py +++ b/repositories/psychological_repository.py @@ -98,51 +98,57 @@ async def get_components( 获取状态的所有组件 Args: - state_id: 状态 ID + state_id: 复合心理状态记录的主键 ID Returns: List[PsychologicalStateComponent]: 组件列表 """ - return await self.find_many(state_id=state_id) + return await self.find_many(composite_state_id=state_id) async def update_component( self, state_id: int, component_name: str, value: float, - threshold: float = None + threshold: float = None, + group_id: str = "", + state_id_str: str = "" ) -> Optional[PsychologicalStateComponent]: """ 更新组件值 Args: - state_id: 状态 ID - component_name: 组件名称 + state_id: 复合心理状态记录的主键 ID + component_name: 组件类别名称(category) value: 组件值 threshold: 阈值 + group_id: 群组 ID (创建新组件时使用) + state_id_str: 状态标识符 (创建新组件时使用) Returns: Optional[PsychologicalStateComponent]: 组件对象 """ component = await self.find_one( - state_id=state_id, - component_name=component_name + composite_state_id=state_id, + category=component_name ) if component: component.value = value if threshold is not None: component.threshold = threshold - component.updated_at = int(time.time()) return await self.update(component) else: + now = int(time.time()) return await self.create( - state_id=state_id, - component_name=component_name, + composite_state_id=state_id, + group_id=group_id, + state_id=state_id_str or f"{group_id}:{component_name}", + category=component_name, + state_type=component_name, value=value, - threshold=threshold or 0.5, - created_at=int(time.time()), - updated_at=int(time.time()) + threshold=threshold or 0.3, + start_time=now, ) @@ -158,7 +164,9 @@ async def add_history( from_state: str, to_state: str, trigger_event: str = None, - intensity_change: float = 0.0 + intensity_change: float = 0.0, + group_id: str = "", + category: str = "" ) -> Optional[PsychologicalStateHistory]: """ 添加历史记录 @@ -169,16 +177,21 @@ async def add_history( to_state: 结束状态 trigger_event: 触发事件 intensity_change: 强度变化 + group_id: 群组 ID + category: 状态类别 Returns: Optional[PsychologicalStateHistory]: 历史记录 """ return await self.create( - state_id=state_id, - from_state=from_state, - to_state=to_state, - trigger_event=trigger_event, - intensity_change=intensity_change, + group_id=group_id, + state_id=str(state_id), + category=category or "unknown", + old_state_type=from_state, + new_state_type=to_state or "", + old_value=0.0, + new_value=intensity_change, + change_reason=trigger_event, timestamp=int(time.time()) ) diff --git a/services/database/facades/jargon_facade.py b/services/database/facades/jargon_facade.py index 7ca2282..21d8ce6 100644 --- a/services/database/facades/jargon_facade.py +++ b/services/database/facades/jargon_facade.py @@ -743,8 +743,12 @@ async def get_jargon_groups(self) -> List[Dict]: groups = [] for row in rows: try: + chat_id = row.chat_id or '' groups.append({ - 'chat_id': row.chat_id, + 'group_id': chat_id, + 'group_name': chat_id, + 'id': chat_id, + 'chat_id': chat_id, 'count': row.count or 0 }) except Exception as row_error: diff --git a/services/state/enhanced_psychological_state_manager.py b/services/state/enhanced_psychological_state_manager.py index af4ffc2..a515938 100644 --- a/services/state/enhanced_psychological_state_manager.py +++ b/services/state/enhanced_psychological_state_manager.py @@ -200,10 +200,12 @@ async def get_current_state( state_components = [] for comp in components: state_components.append(PsychologicalStateComponent( - dimension=comp.component_name, - state_type=comp.component_name, # TODO: 需要解析类型 + category=comp.category, + state_type=comp.state_type, value=comp.value, - threshold=comp.threshold + threshold=comp.threshold, + description=comp.description or "", + start_time=float(comp.start_time) if comp.start_time else time.time() )) composite_state = CompositePsychologicalState( @@ -267,7 +269,9 @@ async def update_state( await component_repo.update_component( state.id, dimension, - new_value + new_value, + group_id=group_id, + state_id_str=f"{group_id}:{user_id}" ) # 记录历史 @@ -276,7 +280,9 @@ async def update_state( from_state=state.overall_state, to_state=str(new_state_type), trigger_event=trigger_event, - intensity_change=0.0 + intensity_change=0.0, + group_id=group_id, + category=dimension ) # 清除缓存 From 82ebe84286795585e83d8d43ef992e3f5e88dcb8 Mon Sep 17 00:00:00 2001 From: NickMo Date: Wed, 25 Feb 2026 23:23:37 +0800 Subject: [PATCH 05/16] test(unit): add unit tests for core modules and expand coverage config Add unit tests for config, constants, exceptions, interfaces, patterns, cache_manager, json_utils, security_utils, guardrails_models, learning_quality_monitor, and tiered_learning_trigger. Update pytest.ini to include core, utils, and services in coverage reporting. --- pytest.ini | 20 +- tests/integration/__init__.py | 0 tests/unit/__init__.py | 0 tests/unit/test_cache_manager.py | 251 +++++++++ tests/unit/test_config.py | 379 +++++++++++++ tests/unit/test_constants.py | 147 +++++ tests/unit/test_exceptions.py | 110 ++++ tests/unit/test_guardrails_models.py | 264 +++++++++ tests/unit/test_interfaces.py | 244 ++++++++ tests/unit/test_json_utils.py | 391 +++++++++++++ tests/unit/test_learning_quality_monitor.py | 586 ++++++++++++++++++++ tests/unit/test_patterns.py | 446 +++++++++++++++ tests/unit/test_security_utils.py | 306 ++++++++++ tests/unit/test_tiered_learning_trigger.py | 441 +++++++++++++++ 14 files changed, 3583 insertions(+), 2 deletions(-) create mode 100644 tests/integration/__init__.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_cache_manager.py create mode 100644 tests/unit/test_config.py create mode 100644 tests/unit/test_constants.py create mode 100644 tests/unit/test_exceptions.py create mode 100644 tests/unit/test_guardrails_models.py create mode 100644 tests/unit/test_interfaces.py create mode 100644 tests/unit/test_json_utils.py create mode 100644 tests/unit/test_learning_quality_monitor.py create mode 100644 tests/unit/test_patterns.py create mode 100644 tests/unit/test_security_utils.py create mode 100644 tests/unit/test_tiered_learning_trigger.py diff --git a/pytest.ini b/pytest.ini index f51256a..d4008a1 100644 --- a/pytest.ini +++ b/pytest.ini @@ -23,6 +23,12 @@ addopts = # Strict markers (only registered markers allowed) --strict-markers # Coverage options + --cov=config + --cov=constants + --cov=exceptions + --cov=core + --cov=utils + --cov=services --cov=webui --cov-report=html --cov-report=term-missing @@ -40,7 +46,10 @@ markers = auth: Authentication related tests service: Service layer tests blueprint: Blueprint/route tests - security: Security-related tests + core: Core module tests + config: Configuration tests + utils: Utility module tests + quality: Quality monitoring tests # Log output log_cli = true @@ -53,7 +62,14 @@ asyncio_mode = auto # Coverage options [coverage:run] -source = webui +source = + config + constants + exceptions + core + utils + services + webui omit = */tests/* */test_*.py diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_cache_manager.py b/tests/unit/test_cache_manager.py new file mode 100644 index 0000000..01b93f0 --- /dev/null +++ b/tests/unit/test_cache_manager.py @@ -0,0 +1,251 @@ +""" +Unit tests for CacheManager + +Tests the unified cache management system: +- Cache get/set/delete/clear operations +- Named cache isolation (affection, memory, state, etc.) +- Hit rate statistics tracking +- Cache stats reporting +- Global singleton management +- Unknown cache name handling +""" +import pytest +from unittest.mock import patch + +from utils.cache_manager import CacheManager, get_cache_manager, cached, async_cached + + +@pytest.mark.unit +@pytest.mark.utils +class TestCacheManagerOperations: + """Test basic CacheManager CRUD operations.""" + + def test_set_and_get(self): + """Test setting and getting a cache value.""" + mgr = CacheManager() + mgr.set("general", "key1", "value1") + + result = mgr.get("general", "key1") + assert result == "value1" + + def test_get_nonexistent_key(self): + """Test getting a nonexistent key returns None.""" + mgr = CacheManager() + + result = mgr.get("general", "nonexistent") + assert result is None + + def test_delete_existing_key(self): + """Test deleting an existing cache entry.""" + mgr = CacheManager() + mgr.set("general", "key1", "value1") + + mgr.delete("general", "key1") + + assert mgr.get("general", "key1") is None + + def test_delete_nonexistent_key(self): + """Test deleting a nonexistent key does not raise.""" + mgr = CacheManager() + mgr.delete("general", "nonexistent") # Should not raise + + def test_clear_specific_cache(self): + """Test clearing a specific named cache.""" + mgr = CacheManager() + mgr.set("affection", "k1", "v1") + mgr.set("affection", "k2", "v2") + + mgr.clear("affection") + + assert mgr.get("affection", "k1") is None + assert mgr.get("affection", "k2") is None + + def test_clear_all_caches(self): + """Test clearing all caches at once.""" + mgr = CacheManager() + mgr.set("affection", "k1", "v1") + mgr.set("memory", "k2", "v2") + mgr.set("general", "k3", "v3") + + mgr.clear_all() + + assert mgr.get("affection", "k1") is None + assert mgr.get("memory", "k2") is None + assert mgr.get("general", "k3") is None + + +@pytest.mark.unit +@pytest.mark.utils +class TestCacheManagerIsolation: + """Test cache name isolation between different caches.""" + + def test_different_caches_are_isolated(self): + """Test same key in different caches are independent.""" + mgr = CacheManager() + mgr.set("affection", "shared_key", "affection_value") + mgr.set("memory", "shared_key", "memory_value") + + assert mgr.get("affection", "shared_key") == "affection_value" + assert mgr.get("memory", "shared_key") == "memory_value" + + @pytest.mark.parametrize("cache_name", [ + "affection", "memory", "state", "relation", + "context", "embedding_query", + "conversation", "summary", "general", + ]) + def test_all_named_caches_accessible(self, cache_name): + """Test all named caches are accessible.""" + mgr = CacheManager() + mgr.set(cache_name, "test_key", "test_value") + + result = mgr.get(cache_name, "test_key") + assert result == "test_value" + + def test_unknown_cache_name_returns_none(self): + """Test accessing an unknown cache name returns None.""" + mgr = CacheManager() + + result = mgr.get("unknown_cache", "key1") + assert result is None + + def test_set_to_unknown_cache_does_not_raise(self): + """Test setting to an unknown cache does not raise.""" + mgr = CacheManager() + mgr.set("unknown_cache", "key1", "value1") # Should not raise + + +@pytest.mark.unit +@pytest.mark.utils +class TestCacheManagerStats: + """Test cache statistics and hit rate tracking.""" + + def test_hit_rate_empty(self): + """Test hit rates with no operations.""" + mgr = CacheManager() + + stats = mgr.get_hit_rates() + assert stats == {} + + def test_hit_rate_tracking(self): + """Test hit/miss tracking across operations.""" + mgr = CacheManager() + mgr.set("general", "key1", "value1") + + # Hit + mgr.get("general", "key1") + # Miss + mgr.get("general", "nonexistent") + + stats = mgr.get_hit_rates() + assert "general" in stats + assert stats["general"]["hits"] == 1 + assert stats["general"]["misses"] == 1 + assert stats["general"]["hit_rate"] == 0.5 + + def test_get_stats_for_cache(self): + """Test getting stats for a specific cache.""" + mgr = CacheManager() + mgr.set("affection", "k1", "v1") + + stats = mgr.get_stats("affection") + + assert "size" in stats + assert "maxsize" in stats + assert stats["size"] == 1 + + def test_get_stats_unknown_cache(self): + """Test getting stats for unknown cache returns empty dict.""" + mgr = CacheManager() + + stats = mgr.get_stats("unknown") + assert stats == {} + + +@pytest.mark.unit +@pytest.mark.utils +class TestCachedDecorator: + """Test the synchronous cached decorator.""" + + def test_cached_decorator_caches_result(self): + """Test cached decorator returns cached result on second call.""" + mgr = CacheManager() + call_count = 0 + + @cached(cache_name="general", key_func=lambda x: f"key_{x}", manager=mgr) + def expensive_func(x): + nonlocal call_count + call_count += 1 + return x * 2 + + result1 = expensive_func(5) + result2 = expensive_func(5) + + assert result1 == 10 + assert result2 == 10 + assert call_count == 1 # Only called once + + def test_cached_decorator_different_keys(self): + """Test cached decorator uses correct keys for different inputs.""" + mgr = CacheManager() + + @cached(cache_name="general", key_func=lambda x: f"key_{x}", manager=mgr) + def add_one(x): + return x + 1 + + assert add_one(1) == 2 + assert add_one(2) == 3 + + +@pytest.mark.unit +@pytest.mark.utils +class TestAsyncCachedDecorator: + """Test the asynchronous cached decorator.""" + + @pytest.mark.asyncio + async def test_async_cached_decorator(self): + """Test async cached decorator caches result.""" + mgr = CacheManager() + call_count = 0 + + @async_cached( + cache_name="general", + key_func=lambda x: f"async_key_{x}", + manager=mgr, + ) + async def async_expensive_func(x): + nonlocal call_count + call_count += 1 + return x * 3 + + result1 = await async_expensive_func(7) + result2 = await async_expensive_func(7) + + assert result1 == 21 + assert result2 == 21 + assert call_count == 1 + + +@pytest.mark.unit +@pytest.mark.utils +class TestGlobalCacheManager: + """Test global singleton cache manager.""" + + def test_get_cache_manager_returns_instance(self): + """Test get_cache_manager returns a CacheManager instance.""" + # Reset global to ensure clean state + import utils.cache_manager as module + module._global_cache_manager = None + + mgr = get_cache_manager() + + assert isinstance(mgr, CacheManager) + + def test_get_cache_manager_returns_same_instance(self): + """Test get_cache_manager always returns the same singleton.""" + import utils.cache_manager as module + module._global_cache_manager = None + + mgr1 = get_cache_manager() + mgr2 = get_cache_manager() + + assert mgr1 is mgr2 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..cdc9d4f --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,379 @@ +""" +Unit tests for PluginConfig + +Tests the plugin configuration management including: +- Default value initialization +- Configuration creation from dict +- Configuration validation +- File persistence (save/load) +- Boundary value verification +""" +import os +import json +import tempfile +import pytest +from unittest.mock import patch, MagicMock + +from config import PluginConfig + + +@pytest.mark.unit +@pytest.mark.config +class TestPluginConfigDefaults: + """Test PluginConfig default value initialization.""" + + def test_create_default_instance(self): + """Test creating a default PluginConfig instance.""" + config = PluginConfig() + + assert config.enable_message_capture is True + assert config.enable_auto_learning is True + assert config.enable_realtime_learning is False + assert config.enable_web_interface is True + assert config.web_interface_port == 7833 + assert config.web_interface_host == "0.0.0.0" + + def test_create_default_classmethod(self): + """Test the create_default classmethod.""" + config = PluginConfig.create_default() + + assert isinstance(config, PluginConfig) + assert config.learning_interval_hours == 6 + assert config.min_messages_for_learning == 50 + assert config.max_messages_per_batch == 200 + + def test_default_learning_parameters(self): + """Test default learning parameter values.""" + config = PluginConfig() + + assert config.message_min_length == 5 + assert config.message_max_length == 500 + assert config.confidence_threshold == 0.7 + assert config.relevance_threshold == 0.6 + assert config.style_analysis_batch_size == 100 + assert config.style_update_threshold == 0.6 + + def test_default_database_settings(self): + """Test default database configuration values.""" + config = PluginConfig() + + assert config.db_type == "sqlite" + assert config.mysql_host == "localhost" + assert config.mysql_port == 3306 + assert config.postgresql_host == "localhost" + assert config.postgresql_port == 5432 + assert config.max_connections == 10 + + def test_default_affection_settings(self): + """Test default affection system configuration.""" + config = PluginConfig() + + assert config.enable_affection_system is True + assert config.max_total_affection == 250 + assert config.max_user_affection == 100 + assert config.affection_decay_rate == 0.95 + + def test_default_provider_ids_none(self): + """Test provider IDs default to None.""" + config = PluginConfig() + + assert config.filter_provider_id is None + assert config.refine_provider_id is None + assert config.reinforce_provider_id is None + assert config.embedding_provider_id is None + assert config.rerank_provider_id is None + + def test_sqlalchemy_always_true(self): + """Test that use_sqlalchemy is always True (hardcoded).""" + config = PluginConfig() + assert config.use_sqlalchemy is True + + +@pytest.mark.unit +@pytest.mark.config +class TestPluginConfigFromDict: + """Test PluginConfig creation from configuration dict.""" + + def test_create_from_basic_config(self): + """Test creating config from a basic configuration dict.""" + raw_config = { + 'Self_Learning_Basic': { + 'enable_message_capture': False, + 'enable_auto_learning': False, + 'web_interface_port': 8080, + } + } + + config = PluginConfig.create_from_config(raw_config, data_dir="/tmp/test") + + assert config.enable_message_capture is False + assert config.enable_auto_learning is False + assert config.web_interface_port == 8080 + assert config.data_dir == "/tmp/test" + + def test_create_from_config_with_model_settings(self): + """Test config creation with model configuration.""" + raw_config = { + 'Model_Configuration': { + 'filter_provider_id': 'provider_1', + 'refine_provider_id': 'provider_2', + 'reinforce_provider_id': 'provider_3', + } + } + + config = PluginConfig.create_from_config(raw_config, data_dir="/tmp/test") + + assert config.filter_provider_id == 'provider_1' + assert config.refine_provider_id == 'provider_2' + assert config.reinforce_provider_id == 'provider_3' + + def test_create_from_config_missing_data_dir(self): + """Test config creation with empty data_dir uses fallback.""" + config = PluginConfig.create_from_config({}, data_dir="") + + assert config.data_dir == "./data/self_learning_data" + + def test_create_from_config_with_database_settings(self): + """Test config creation with database settings.""" + raw_config = { + 'Database_Settings': { + 'db_type': 'mysql', + 'mysql_host': '192.168.1.100', + 'mysql_port': 3307, + 'mysql_user': 'admin', + 'mysql_password': 'secret', + 'mysql_database': 'test_db', + } + } + + config = PluginConfig.create_from_config(raw_config, data_dir="/tmp/test") + + assert config.db_type == 'mysql' + assert config.mysql_host == '192.168.1.100' + assert config.mysql_port == 3307 + assert config.mysql_user == 'admin' + assert config.mysql_database == 'test_db' + + def test_create_from_config_with_v2_settings(self): + """Test config creation with v2 architecture settings.""" + raw_config = { + 'V2_Architecture_Settings': { + 'embedding_provider_id': 'embed_provider', + 'rerank_provider_id': 'rerank_provider', + 'knowledge_engine': 'lightrag', + 'memory_engine': 'mem0', + } + } + + config = PluginConfig.create_from_config(raw_config, data_dir="/tmp/test") + + assert config.embedding_provider_id == 'embed_provider' + assert config.rerank_provider_id == 'rerank_provider' + assert config.knowledge_engine == 'lightrag' + assert config.memory_engine == 'mem0' + + def test_create_from_empty_config(self): + """Test config creation from empty dict uses all defaults.""" + config = PluginConfig.create_from_config({}, data_dir="/tmp/test") + + assert config.enable_message_capture is True + assert config.learning_interval_hours == 6 + assert config.db_type == 'sqlite' + + def test_extra_fields_ignored(self): + """Test that extra/unknown fields are ignored.""" + config = PluginConfig( + unknown_field_1="value1", + unknown_field_2=42, + ) + assert not hasattr(config, 'unknown_field_1') + + +@pytest.mark.unit +@pytest.mark.config +class TestPluginConfigValidation: + """Test PluginConfig validation logic.""" + + def test_valid_config_no_errors(self): + """Test validation of a valid default config.""" + config = PluginConfig( + filter_provider_id="provider_1", + refine_provider_id="provider_2", + ) + errors = config.validate_config() + + # Should have no blocking errors (may have warnings for reinforce) + blocking_errors = [e for e in errors if not e.startswith(" ")] + assert len(blocking_errors) == 0 + + def test_invalid_learning_interval(self): + """Test validation catches invalid learning interval.""" + config = PluginConfig(learning_interval_hours=0) + errors = config.validate_config() + + assert any("学习间隔必须大于0" in e for e in errors) + + def test_invalid_min_messages(self): + """Test validation catches invalid min messages for learning.""" + config = PluginConfig(min_messages_for_learning=0) + errors = config.validate_config() + + assert any("最少学习消息数量必须大于0" in e for e in errors) + + def test_invalid_max_batch_size(self): + """Test validation catches invalid max batch size.""" + config = PluginConfig(max_messages_per_batch=-1) + errors = config.validate_config() + + assert any("每批最大消息数量必须大于0" in e for e in errors) + + def test_invalid_message_length_range(self): + """Test validation catches min_length >= max_length.""" + config = PluginConfig(message_min_length=500, message_max_length=100) + errors = config.validate_config() + + assert any("最小长度必须小于最大长度" in e for e in errors) + + def test_invalid_confidence_threshold(self): + """Test validation catches confidence threshold out of range.""" + config = PluginConfig(confidence_threshold=1.5) + errors = config.validate_config() + + assert any("置信度阈值必须在0-1之间" in e for e in errors) + + def test_invalid_style_threshold(self): + """Test validation catches style update threshold out of range.""" + config = PluginConfig(style_update_threshold=-0.1) + errors = config.validate_config() + + assert any("风格更新阈值必须在0-1之间" in e for e in errors) + + def test_no_providers_configured(self): + """Test validation warns when no providers are configured.""" + config = PluginConfig( + filter_provider_id=None, + refine_provider_id=None, + reinforce_provider_id=None, + ) + errors = config.validate_config() + + assert any("至少需要配置一个模型提供商ID" in e for e in errors) + + def test_partial_providers_configured(self): + """Test validation with only some providers configured.""" + config = PluginConfig( + filter_provider_id="provider_1", + refine_provider_id=None, + reinforce_provider_id=None, + ) + errors = config.validate_config() + + # Should have warnings but no blocking errors + blocking_errors = [e for e in errors if not e.startswith(" ")] + assert len(blocking_errors) == 0 + + +@pytest.mark.unit +@pytest.mark.config +class TestPluginConfigSerialization: + """Test PluginConfig serialization and deserialization.""" + + def test_to_dict(self): + """Test converting config to dict.""" + config = PluginConfig( + enable_message_capture=False, + web_interface_port=9090, + ) + + config_dict = config.to_dict() + + assert isinstance(config_dict, dict) + assert config_dict['enable_message_capture'] is False + assert config_dict['web_interface_port'] == 9090 + assert 'learning_interval_hours' in config_dict + + def test_save_to_file_success(self): + """Test saving config to file.""" + config = PluginConfig() + + with tempfile.NamedTemporaryFile( + mode='w', suffix='.json', delete=False + ) as f: + filepath = f.name + + try: + result = config.save_to_file(filepath) + + assert result is True + assert os.path.exists(filepath) + + with open(filepath, 'r', encoding='utf-8') as f: + saved_data = json.load(f) + assert saved_data['enable_message_capture'] is True + finally: + os.unlink(filepath) + + def test_load_from_file_success(self): + """Test loading config from existing file.""" + config_data = { + 'enable_message_capture': False, + 'web_interface_port': 9999, + 'learning_interval_hours': 12, + } + + with tempfile.NamedTemporaryFile( + mode='w', suffix='.json', delete=False + ) as f: + json.dump(config_data, f) + filepath = f.name + + try: + loaded_config = PluginConfig.load_from_file(filepath) + + assert loaded_config.enable_message_capture is False + assert loaded_config.web_interface_port == 9999 + assert loaded_config.learning_interval_hours == 12 + finally: + os.unlink(filepath) + + def test_load_from_nonexistent_file(self): + """Test loading config from nonexistent file returns defaults.""" + loaded_config = PluginConfig.load_from_file("/nonexistent/path.json") + + assert loaded_config.enable_message_capture is True + assert loaded_config.learning_interval_hours == 6 + + def test_load_from_file_with_data_dir(self): + """Test loading config with explicit data_dir override.""" + config_data = {'enable_message_capture': True} + + with tempfile.NamedTemporaryFile( + mode='w', suffix='.json', delete=False + ) as f: + json.dump(config_data, f) + filepath = f.name + + try: + loaded_config = PluginConfig.load_from_file( + filepath, data_dir="/custom/data/dir" + ) + + assert loaded_config.data_dir == "/custom/data/dir" + finally: + os.unlink(filepath) + + def test_load_from_corrupt_file(self): + """Test loading config from corrupt file returns defaults.""" + with tempfile.NamedTemporaryFile( + mode='w', suffix='.json', delete=False + ) as f: + f.write("this is not valid json {{{") + filepath = f.name + + try: + loaded_config = PluginConfig.load_from_file(filepath) + + # Should return default config + assert loaded_config.enable_message_capture is True + finally: + os.unlink(filepath) diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py new file mode 100644 index 0000000..badadb2 --- /dev/null +++ b/tests/unit/test_constants.py @@ -0,0 +1,147 @@ +""" +Unit tests for constants module + +Tests the update type normalization and review source resolution: +- normalize_update_type exact and fuzzy matching +- get_review_source_from_update_type classification +- Legacy format backward compatibility +""" +import pytest + +from constants import ( + UPDATE_TYPE_PROGRESSIVE_PERSONA_LEARNING, + UPDATE_TYPE_STYLE_LEARNING, + UPDATE_TYPE_EXPRESSION_LEARNING, + UPDATE_TYPE_TRADITIONAL, + LEGACY_UPDATE_TYPE_MAPPING, + normalize_update_type, + get_review_source_from_update_type, +) + + +@pytest.mark.unit +class TestNormalizeUpdateType: + """Test normalize_update_type function.""" + + def test_empty_input_returns_traditional(self): + """Test empty or None input returns traditional type.""" + assert normalize_update_type("") == UPDATE_TYPE_TRADITIONAL + assert normalize_update_type(None) == UPDATE_TYPE_TRADITIONAL + + def test_exact_match_progressive_learning(self): + """Test exact match for progressive_learning legacy key.""" + result = normalize_update_type("progressive_learning") + assert result == UPDATE_TYPE_PROGRESSIVE_PERSONA_LEARNING + + def test_exact_match_style_learning(self): + """Test exact match for style_learning.""" + result = normalize_update_type("style_learning") + assert result == UPDATE_TYPE_STYLE_LEARNING + + def test_exact_match_expression_learning(self): + """Test exact match for expression_learning.""" + result = normalize_update_type("expression_learning") + assert result == UPDATE_TYPE_EXPRESSION_LEARNING + + def test_legacy_chinese_progressive_style(self): + """Test legacy Chinese format for progressive style analysis.""" + result = normalize_update_type("渐进式学习-风格分析") + assert result == UPDATE_TYPE_PROGRESSIVE_PERSONA_LEARNING + + def test_legacy_chinese_progressive_persona(self): + """Test legacy Chinese format for progressive persona update.""" + result = normalize_update_type("渐进式学习-人格更新") + assert result == UPDATE_TYPE_PROGRESSIVE_PERSONA_LEARNING + + def test_fuzzy_match_chinese_progressive(self): + """Test fuzzy match with Chinese progressive learning keyword.""" + result = normalize_update_type("渐进式学习-新类型") + assert result == UPDATE_TYPE_PROGRESSIVE_PERSONA_LEARNING + + def test_fuzzy_match_english_progressive(self): + """Test fuzzy match with English progressive keyword.""" + result = normalize_update_type("PROGRESSIVE_update") + assert result == UPDATE_TYPE_PROGRESSIVE_PERSONA_LEARNING + + def test_unknown_type_returns_traditional(self): + """Test unknown type returns traditional.""" + result = normalize_update_type("some_unknown_type") + assert result == UPDATE_TYPE_TRADITIONAL + + def test_already_normalized_value(self): + """Test passing an already normalized constant.""" + result = normalize_update_type(UPDATE_TYPE_PROGRESSIVE_PERSONA_LEARNING) + assert result == UPDATE_TYPE_PROGRESSIVE_PERSONA_LEARNING + + +@pytest.mark.unit +class TestGetReviewSourceFromUpdateType: + """Test get_review_source_from_update_type function.""" + + def test_progressive_persona_learning_source(self): + """Test progressive persona learning maps to persona_learning.""" + result = get_review_source_from_update_type( + UPDATE_TYPE_PROGRESSIVE_PERSONA_LEARNING + ) + assert result == 'persona_learning' + + def test_style_learning_source(self): + """Test style learning maps to style_learning.""" + result = get_review_source_from_update_type(UPDATE_TYPE_STYLE_LEARNING) + assert result == 'style_learning' + + def test_expression_learning_source(self): + """Test expression learning maps to persona_learning.""" + result = get_review_source_from_update_type( + UPDATE_TYPE_EXPRESSION_LEARNING + ) + assert result == 'persona_learning' + + def test_traditional_source(self): + """Test traditional update maps to traditional.""" + result = get_review_source_from_update_type(UPDATE_TYPE_TRADITIONAL) + assert result == 'traditional' + + def test_unknown_type_defaults_to_traditional(self): + """Test unknown update type defaults to traditional source.""" + result = get_review_source_from_update_type("random_unknown_type") + assert result == 'traditional' + + def test_legacy_format_normalization(self): + """Test legacy Chinese format is normalized before classification.""" + result = get_review_source_from_update_type("渐进式学习-风格分析") + assert result == 'persona_learning' + + def test_empty_string(self): + """Test empty string defaults to traditional.""" + result = get_review_source_from_update_type("") + assert result == 'traditional' + + +@pytest.mark.unit +class TestLegacyMapping: + """Test legacy update type mapping completeness.""" + + def test_all_legacy_keys_mapped(self): + """Test all legacy keys exist in the mapping.""" + expected_keys = { + "渐进式学习-风格分析", + "渐进式学习-人格更新", + "progressive_learning", + "style_learning", + "expression_learning", + } + + assert set(LEGACY_UPDATE_TYPE_MAPPING.keys()) == expected_keys + + def test_all_legacy_values_are_valid_constants(self): + """Test all legacy values map to valid update type constants.""" + valid_types = { + UPDATE_TYPE_PROGRESSIVE_PERSONA_LEARNING, + UPDATE_TYPE_STYLE_LEARNING, + UPDATE_TYPE_EXPRESSION_LEARNING, + UPDATE_TYPE_TRADITIONAL, + } + + for value in LEGACY_UPDATE_TYPE_MAPPING.values(): + assert value in valid_types, f"Invalid mapping value: {value}" diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 0000000..e0667a9 --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,110 @@ +""" +Unit tests for custom exception hierarchy + +Tests the exception class inheritance chain and instantiation: +- Base exception class +- All derived exception types +- Exception message propagation +- isinstance checks for polymorphic handling +""" +import pytest + +from exceptions import ( + SelfLearningError, + ConfigurationError, + MessageCollectionError, + StyleAnalysisError, + PersonaUpdateError, + ModelAccessError, + DataStorageError, + LearningSchedulerError, + LearningError, + ServiceError, + ResponseError, + BackupError, + ExpressionLearningError, + MemoryGraphError, + TimeDecayError, + MessageAnalysisError, + KnowledgeGraphError, +) + + +@pytest.mark.unit +class TestExceptionHierarchy: + """Test exception class hierarchy and inheritance.""" + + def test_base_exception_is_exception(self): + """Test SelfLearningError is a proper Exception subclass.""" + assert issubclass(SelfLearningError, Exception) + + @pytest.mark.parametrize("exc_class", [ + ConfigurationError, + MessageCollectionError, + StyleAnalysisError, + PersonaUpdateError, + ModelAccessError, + DataStorageError, + LearningSchedulerError, + LearningError, + ServiceError, + ResponseError, + BackupError, + ExpressionLearningError, + MemoryGraphError, + TimeDecayError, + MessageAnalysisError, + KnowledgeGraphError, + ]) + def test_subclass_inherits_from_base(self, exc_class): + """Test all custom exceptions inherit from SelfLearningError.""" + assert issubclass(exc_class, SelfLearningError) + + @pytest.mark.parametrize("exc_class", [ + ConfigurationError, + MessageCollectionError, + StyleAnalysisError, + PersonaUpdateError, + ModelAccessError, + DataStorageError, + LearningSchedulerError, + LearningError, + ServiceError, + ResponseError, + BackupError, + ExpressionLearningError, + MemoryGraphError, + TimeDecayError, + MessageAnalysisError, + KnowledgeGraphError, + ]) + def test_exception_instantiation(self, exc_class): + """Test all exception classes can be instantiated with a message.""" + msg = f"Test error message for {exc_class.__name__}" + exc = exc_class(msg) + + assert str(exc) == msg + + def test_catch_specific_exception(self): + """Test catching a specific exception type.""" + with pytest.raises(ConfigurationError): + raise ConfigurationError("invalid config") + + def test_catch_base_exception_for_derived(self): + """Test catching base exception catches derived types.""" + with pytest.raises(SelfLearningError): + raise DataStorageError("storage failure") + + def test_exception_with_no_message(self): + """Test exception can be raised without a message.""" + exc = SelfLearningError() + assert str(exc) == "" + + def test_exception_chain(self): + """Test exception chaining with __cause__.""" + original = ValueError("original cause") + try: + raise ConfigurationError("config error") from original + except ConfigurationError as e: + assert e.__cause__ is original + assert str(e.__cause__) == "original cause" diff --git a/tests/unit/test_guardrails_models.py b/tests/unit/test_guardrails_models.py new file mode 100644 index 0000000..c72b012 --- /dev/null +++ b/tests/unit/test_guardrails_models.py @@ -0,0 +1,264 @@ +""" +Unit tests for Guardrails Pydantic validation models + +Tests the Pydantic model definitions used for structured LLM output: +- PsychologicalStateTransition validation +- GoalAnalysisResult validation +- ConversationIntentAnalysis defaults and validation +- RelationChange and SocialRelationAnalysis validation +- Field range constraints (ge, le, min_length, max_length) +""" +import pytest +from pydantic import ValidationError + +from utils.guardrails_manager import ( + PsychologicalStateTransition, + GoalAnalysisResult, + ConversationIntentAnalysis, + RelationChange, + SocialRelationAnalysis, +) + + +@pytest.mark.unit +@pytest.mark.utils +class TestPsychologicalStateTransition: + """Test PsychologicalStateTransition Pydantic model.""" + + def test_valid_creation(self): + """Test creating a valid state transition.""" + state = PsychologicalStateTransition( + new_state="愉悦", + confidence=0.85, + reason="Positive conversation detected", + ) + + assert state.new_state == "愉悦" + assert state.confidence == 0.85 + assert state.reason == "Positive conversation detected" + + def test_default_values(self): + """Test default confidence and reason values.""" + state = PsychologicalStateTransition(new_state="平静") + + assert state.confidence == 0.8 + assert state.reason == "" + + def test_state_name_too_long(self): + """Test state name longer than 20 chars is rejected.""" + with pytest.raises(ValidationError): + PsychologicalStateTransition(new_state="a" * 21) + + def test_empty_state_name(self): + """Test empty state name is rejected.""" + with pytest.raises(ValidationError): + PsychologicalStateTransition(new_state="") + + def test_confidence_below_zero(self): + """Test confidence below 0.0 is rejected.""" + with pytest.raises(ValidationError): + PsychologicalStateTransition(new_state="测试", confidence=-0.1) + + def test_confidence_above_one(self): + """Test confidence above 1.0 is rejected.""" + with pytest.raises(ValidationError): + PsychologicalStateTransition(new_state="测试", confidence=1.1) + + def test_confidence_boundary_values(self): + """Test confidence at exact boundaries (0.0 and 1.0).""" + s_low = PsychologicalStateTransition(new_state="低", confidence=0.0) + s_high = PsychologicalStateTransition(new_state="高", confidence=1.0) + + assert s_low.confidence == 0.0 + assert s_high.confidence == 1.0 + + def test_state_name_whitespace_stripped(self): + """Test state name with whitespace is stripped.""" + state = PsychologicalStateTransition(new_state=" 愉悦 ") + assert state.new_state == "愉悦" + + +@pytest.mark.unit +@pytest.mark.utils +class TestGoalAnalysisResult: + """Test GoalAnalysisResult Pydantic model.""" + + def test_valid_creation(self): + """Test creating a valid goal analysis result.""" + result = GoalAnalysisResult( + goal_type="emotional_support", + topic="工作压力", + confidence=0.85, + reasoning="User seems stressed", + ) + + assert result.goal_type == "emotional_support" + assert result.topic == "工作压力" + assert result.confidence == 0.85 + + def test_default_values(self): + """Test default values for optional fields.""" + result = GoalAnalysisResult( + goal_type="casual_chat", + topic="日常", + ) + + assert result.confidence == 0.7 + assert result.reasoning == "" + + def test_goal_type_too_long(self): + """Test goal_type exceeding 50 chars is rejected.""" + with pytest.raises(ValidationError): + GoalAnalysisResult( + goal_type="a" * 51, + topic="test", + ) + + def test_topic_too_long(self): + """Test topic exceeding 100 chars is rejected.""" + with pytest.raises(ValidationError): + GoalAnalysisResult( + goal_type="test", + topic="a" * 101, + ) + + def test_empty_goal_type(self): + """Test empty goal_type is rejected.""" + with pytest.raises(ValidationError): + GoalAnalysisResult(goal_type="", topic="test") + + def test_empty_topic(self): + """Test empty topic is rejected.""" + with pytest.raises(ValidationError): + GoalAnalysisResult(goal_type="test", topic="") + + +@pytest.mark.unit +@pytest.mark.utils +class TestConversationIntentAnalysis: + """Test ConversationIntentAnalysis Pydantic model.""" + + def test_default_values(self): + """Test all default values are correctly set.""" + intent = ConversationIntentAnalysis() + + assert intent.goal_switch_needed is False + assert intent.new_goal_type is None + assert intent.new_topic is None + assert intent.topic_completed is False + assert intent.stage_completed is False + assert intent.stage_adjustment_needed is False + assert intent.suggested_stage is None + assert intent.completion_signals == 0 + assert intent.user_engagement == 0.5 + assert intent.reasoning == "" + + def test_custom_values(self): + """Test setting custom values.""" + intent = ConversationIntentAnalysis( + goal_switch_needed=True, + new_goal_type="knowledge_sharing", + user_engagement=0.9, + completion_signals=3, + ) + + assert intent.goal_switch_needed is True + assert intent.new_goal_type == "knowledge_sharing" + assert intent.user_engagement == 0.9 + assert intent.completion_signals == 3 + + def test_engagement_below_zero(self): + """Test user_engagement below 0.0 is rejected.""" + with pytest.raises(ValidationError): + ConversationIntentAnalysis(user_engagement=-0.1) + + def test_engagement_above_one(self): + """Test user_engagement above 1.0 is rejected.""" + with pytest.raises(ValidationError): + ConversationIntentAnalysis(user_engagement=1.1) + + def test_negative_completion_signals(self): + """Test negative completion_signals is rejected.""" + with pytest.raises(ValidationError): + ConversationIntentAnalysis(completion_signals=-1) + + +@pytest.mark.unit +@pytest.mark.utils +class TestRelationChange: + """Test RelationChange Pydantic model.""" + + def test_valid_creation(self): + """Test creating a valid relation change.""" + change = RelationChange( + relation_type="挚友", + value_delta=0.1, + reason="Shared positive experience", + ) + + assert change.relation_type == "挚友" + assert change.value_delta == 0.1 + + def test_relation_type_too_long(self): + """Test relation_type exceeding 30 chars is rejected.""" + with pytest.raises(ValidationError): + RelationChange( + relation_type="a" * 31, + value_delta=0.1, + ) + + def test_value_delta_below_negative_one(self): + """Test value_delta below -1.0 is rejected.""" + with pytest.raises(ValidationError): + RelationChange(relation_type="test", value_delta=-1.1) + + def test_value_delta_above_one(self): + """Test value_delta above 1.0 is rejected.""" + with pytest.raises(ValidationError): + RelationChange(relation_type="test", value_delta=1.1) + + def test_boundary_values(self): + """Test boundary values for value_delta.""" + low = RelationChange(relation_type="低", value_delta=-1.0) + high = RelationChange(relation_type="高", value_delta=1.0) + + assert low.value_delta == -1.0 + assert high.value_delta == 1.0 + + +@pytest.mark.unit +@pytest.mark.utils +class TestSocialRelationAnalysis: + """Test SocialRelationAnalysis Pydantic model.""" + + def test_valid_creation(self): + """Test creating a valid social relation analysis.""" + analysis = SocialRelationAnalysis( + relations=[ + RelationChange(relation_type="友情", value_delta=0.05), + RelationChange(relation_type="信任", value_delta=0.02), + ], + overall_sentiment="positive", + ) + + assert len(analysis.relations) == 2 + assert analysis.overall_sentiment == "positive" + + def test_empty_relations(self): + """Test empty relations list is valid.""" + analysis = SocialRelationAnalysis(relations=[]) + assert len(analysis.relations) == 0 + + def test_default_sentiment(self): + """Test default overall_sentiment is neutral.""" + analysis = SocialRelationAnalysis(relations=[]) + assert analysis.overall_sentiment == "neutral" + + def test_max_five_relations(self): + """Test relations are capped at 5.""" + relations = [ + RelationChange(relation_type=f"type_{i}", value_delta=0.01) + for i in range(7) + ] + analysis = SocialRelationAnalysis(relations=relations) + assert len(analysis.relations) == 5 diff --git a/tests/unit/test_interfaces.py b/tests/unit/test_interfaces.py new file mode 100644 index 0000000..0643489 --- /dev/null +++ b/tests/unit/test_interfaces.py @@ -0,0 +1,244 @@ +""" +Unit tests for core interfaces module + +Tests the core data classes, enums, and interface definitions: +- MessageData dataclass construction and defaults +- AnalysisResult dataclass construction and defaults +- PersonaUpdateRecord dataclass construction and defaults +- ServiceLifecycle enum values +- LearningStrategyType enum values +- AnalysisType enum values +""" +import pytest +from unittest.mock import MagicMock + +from core.interfaces import ( + ServiceLifecycle, + MessageData, + AnalysisResult, + PersonaUpdateRecord, + LearningStrategyType, + AnalysisType, +) + + +@pytest.mark.unit +@pytest.mark.core +class TestMessageData: + """Test MessageData dataclass.""" + + def test_required_fields(self): + """Test creating MessageData with all required fields.""" + msg = MessageData( + sender_id="user_001", + sender_name="Alice", + message="Hello world", + group_id="group_001", + timestamp=1700000000.0, + platform="qq", + ) + + assert msg.sender_id == "user_001" + assert msg.sender_name == "Alice" + assert msg.message == "Hello world" + assert msg.group_id == "group_001" + assert msg.timestamp == 1700000000.0 + assert msg.platform == "qq" + + def test_optional_fields_default_none(self): + """Test optional fields default to None.""" + msg = MessageData( + sender_id="user_001", + sender_name="Alice", + message="Hello", + group_id="group_001", + timestamp=1700000000.0, + platform="qq", + ) + + assert msg.message_id is None + assert msg.reply_to is None + + def test_optional_fields_set_explicitly(self): + """Test optional fields can be set explicitly.""" + msg = MessageData( + sender_id="user_001", + sender_name="Alice", + message="Hello", + group_id="group_001", + timestamp=1700000000.0, + platform="qq", + message_id="msg_123", + reply_to="msg_100", + ) + + assert msg.message_id == "msg_123" + assert msg.reply_to == "msg_100" + + +@pytest.mark.unit +@pytest.mark.core +class TestAnalysisResult: + """Test AnalysisResult dataclass.""" + + def test_required_fields(self): + """Test creating AnalysisResult with required fields.""" + result = AnalysisResult( + success=True, + confidence=0.85, + data={"key": "value"}, + ) + + assert result.success is True + assert result.confidence == 0.85 + assert result.data == {"key": "value"} + + def test_default_values(self): + """Test AnalysisResult default values.""" + result = AnalysisResult( + success=True, + confidence=0.9, + data={}, + ) + + assert result.timestamp == 0.0 + assert result.error is None + assert result.consistency_score is None + + def test_with_error(self): + """Test AnalysisResult with error information.""" + result = AnalysisResult( + success=False, + confidence=0.0, + data={}, + error="Analysis failed due to insufficient data", + ) + + assert result.success is False + assert result.error == "Analysis failed due to insufficient data" + + def test_with_consistency_score(self): + """Test AnalysisResult with consistency score.""" + result = AnalysisResult( + success=True, + confidence=0.8, + data={"metrics": [1, 2, 3]}, + consistency_score=0.75, + ) + + assert result.consistency_score == 0.75 + + +@pytest.mark.unit +@pytest.mark.core +class TestPersonaUpdateRecord: + """Test PersonaUpdateRecord dataclass.""" + + def test_required_fields(self): + """Test creating PersonaUpdateRecord with required fields.""" + record = PersonaUpdateRecord( + timestamp=1700000000.0, + group_id="group_001", + update_type="prompt_update", + original_content="Original prompt", + new_content="New prompt", + reason="Style analysis update", + ) + + assert record.timestamp == 1700000000.0 + assert record.group_id == "group_001" + assert record.update_type == "prompt_update" + assert record.original_content == "Original prompt" + assert record.new_content == "New prompt" + assert record.reason == "Style analysis update" + + def test_default_values(self): + """Test PersonaUpdateRecord default values.""" + record = PersonaUpdateRecord( + timestamp=0.0, + group_id="g1", + update_type="test", + original_content="", + new_content="", + reason="", + ) + + assert record.confidence_score == 0.5 + assert record.id is None + assert record.status == "pending" + assert record.reviewer_comment is None + assert record.review_time is None + + def test_approved_record(self): + """Test PersonaUpdateRecord with approved status.""" + record = PersonaUpdateRecord( + timestamp=1700000000.0, + group_id="g1", + update_type="prompt_update", + original_content="old", + new_content="new", + reason="update", + id=42, + status="approved", + reviewer_comment="Looks good", + review_time=1700001000.0, + ) + + assert record.id == 42 + assert record.status == "approved" + assert record.reviewer_comment == "Looks good" + assert record.review_time == 1700001000.0 + + +@pytest.mark.unit +@pytest.mark.core +class TestServiceLifecycleEnum: + """Test ServiceLifecycle enum.""" + + def test_all_states_exist(self): + """Test all expected lifecycle states exist.""" + assert ServiceLifecycle.CREATED.value == "created" + assert ServiceLifecycle.INITIALIZING.value == "initializing" + assert ServiceLifecycle.RUNNING.value == "running" + assert ServiceLifecycle.STOPPING.value == "stopping" + assert ServiceLifecycle.STOPPED.value == "stopped" + assert ServiceLifecycle.ERROR.value == "error" + + def test_enum_count(self): + """Test the total number of lifecycle states.""" + assert len(ServiceLifecycle) == 6 + + +@pytest.mark.unit +@pytest.mark.core +class TestLearningStrategyTypeEnum: + """Test LearningStrategyType enum.""" + + def test_all_strategies_exist(self): + """Test all expected strategy types exist.""" + assert LearningStrategyType.PROGRESSIVE.value == "progressive" + assert LearningStrategyType.BATCH.value == "batch" + assert LearningStrategyType.REALTIME.value == "realtime" + assert LearningStrategyType.HYBRID.value == "hybrid" + + def test_enum_count(self): + """Test the total number of strategy types.""" + assert len(LearningStrategyType) == 4 + + +@pytest.mark.unit +@pytest.mark.core +class TestAnalysisTypeEnum: + """Test AnalysisType enum.""" + + def test_all_types_exist(self): + """Test all expected analysis types exist.""" + assert AnalysisType.STYLE.value == "style" + assert AnalysisType.SENTIMENT.value == "sentiment" + assert AnalysisType.TOPIC.value == "topic" + assert AnalysisType.BEHAVIOR.value == "behavior" + assert AnalysisType.QUALITY.value == "quality" + + def test_enum_count(self): + """Test the total number of analysis types.""" + assert len(AnalysisType) == 5 diff --git a/tests/unit/test_json_utils.py b/tests/unit/test_json_utils.py new file mode 100644 index 0000000..35b1dac --- /dev/null +++ b/tests/unit/test_json_utils.py @@ -0,0 +1,391 @@ +""" +Unit tests for JSON utilities module + +Tests LLM response parsing, markdown cleanup, and JSON validation: +- remove_thinking_content for various LLM thinking tags +- clean_markdown_blocks for code block removal +- clean_control_characters for sanitization +- extract_json_content for boundary detection +- fix_common_json_errors for auto-repair +- safe_parse_llm_json for end-to-end parsing +- validate_json_structure for schema validation +- detect_llm_provider for model name detection +""" +import pytest + +from utils.json_utils import ( + remove_thinking_content, + extract_thinking_content, + clean_markdown_blocks, + clean_control_characters, + extract_json_content, + fix_common_json_errors, + clean_llm_json_response, + safe_parse_llm_json, + validate_json_structure, + detect_llm_provider, + LLMProvider, + _convert_single_quotes, +) + + +@pytest.mark.unit +@pytest.mark.utils +class TestRemoveThinkingContent: + """Test removal of LLM thinking tags.""" + + def test_empty_input(self): + """Test empty input returns as-is.""" + assert remove_thinking_content("") == "" + assert remove_thinking_content(None) is None + + def test_remove_thinking_tags(self): + """Test removal of tags.""" + text = "Internal reasoningFinal answer" + result = remove_thinking_content(text) + assert "Internal reasoning" not in result + assert "Final answer" in result + + def test_remove_thought_tags(self): + """Test removal of tags.""" + text = "Analysis hereResult" + result = remove_thinking_content(text) + assert "Analysis here" not in result + assert "Result" in result + + def test_remove_reasoning_tags(self): + """Test removal of tags.""" + text = "Step 1, Step 2Output" + result = remove_thinking_content(text) + assert "Step 1" not in result + assert "Output" in result + + def test_remove_think_tags(self): + """Test removal of tags.""" + text = "Hmm let me thinkAnswer is 42" + result = remove_thinking_content(text) + assert "Hmm let me think" not in result + assert "Answer is 42" in result + + def test_remove_chinese_thinking_tags(self): + """Test removal of Chinese thinking tags.""" + text = "<思考>这是思考过程最终结果" + result = remove_thinking_content(text) + assert "这是思考过程" not in result + assert "最终结果" in result + + def test_multiline_thinking_content(self): + """Test removal of multiline thinking content.""" + text = "\nLine 1\nLine 2\nLine 3\nFinal" + result = remove_thinking_content(text) + assert "Line 1" not in result + assert "Final" in result + + def test_text_without_thinking_tags(self): + """Test text without thinking tags is unchanged.""" + text = "Just a regular response without any tags" + result = remove_thinking_content(text) + assert result == text + + +@pytest.mark.unit +@pytest.mark.utils +class TestExtractThinkingContent: + """Test extraction and separation of thinking content.""" + + def test_extract_thinking(self): + """Test extracting thinking content.""" + text = "My thoughtsAnswer" + cleaned, thoughts = extract_thinking_content(text) + + assert "Answer" in cleaned + assert len(thoughts) >= 1 + + def test_no_thinking_content(self): + """Test text without thinking content.""" + text = "Plain text response" + cleaned, thoughts = extract_thinking_content(text) + + assert cleaned == "Plain text response" + assert thoughts == [] + + def test_empty_input(self): + """Test empty input.""" + cleaned, thoughts = extract_thinking_content("") + assert cleaned == "" + assert thoughts == [] + + +@pytest.mark.unit +@pytest.mark.utils +class TestCleanMarkdownBlocks: + """Test markdown code block cleaning.""" + + def test_clean_json_code_block(self): + """Test cleaning ```json code block.""" + text = '```json\n{"key": "value"}\n```' + result = clean_markdown_blocks(text) + assert result == '{"key": "value"}' + + def test_clean_plain_code_block(self): + """Test cleaning plain ``` code block.""" + text = '```\n{"key": "value"}\n```' + result = clean_markdown_blocks(text) + assert result == '{"key": "value"}' + + def test_no_code_blocks(self): + """Test text without code blocks is unchanged.""" + text = '{"key": "value"}' + result = clean_markdown_blocks(text) + assert result == text + + def test_empty_input(self): + """Test empty input returns as-is.""" + assert clean_markdown_blocks("") == "" + assert clean_markdown_blocks(None) is None + + +@pytest.mark.unit +@pytest.mark.utils +class TestCleanControlCharacters: + """Test control character cleaning.""" + + def test_remove_null_bytes(self): + """Test removal of null bytes.""" + text = "hello\x00world" + result = clean_control_characters(text) + assert result == "helloworld" + + def test_preserve_tabs_and_newlines(self): + """Test preservation of tabs and newlines.""" + text = "hello\tworld\nfoo" + result = clean_control_characters(text) + assert result == text + + def test_empty_input(self): + """Test empty input.""" + assert clean_control_characters("") == "" + assert clean_control_characters(None) is None + + +@pytest.mark.unit +@pytest.mark.utils +class TestExtractJsonContent: + """Test JSON content extraction from mixed text.""" + + def test_extract_json_object(self): + """Test extracting JSON object from text.""" + text = 'Some text {"key": "value"} more text' + result = extract_json_content(text) + assert result == '{"key": "value"}' + + def test_extract_json_array(self): + """Test extracting JSON array from text.""" + text = 'Prefix [1, 2, 3] suffix' + result = extract_json_content(text) + assert result == '[1, 2, 3]' + + def test_no_json_content(self): + """Test text without JSON returns original.""" + text = "no json here" + result = extract_json_content(text) + assert result == text + + def test_nested_json(self): + """Test extracting nested JSON object.""" + text = '{"outer": {"inner": "value"}}' + result = extract_json_content(text) + assert result == text + + def test_empty_input(self): + """Test empty input.""" + assert extract_json_content("") == "" + + +@pytest.mark.unit +@pytest.mark.utils +class TestFixCommonJsonErrors: + """Test JSON error auto-repair.""" + + def test_fix_trailing_comma_object(self): + """Test fixing trailing comma in object.""" + text = '{"key": "value",}' + result = fix_common_json_errors(text) + assert result == '{"key": "value"}' + + def test_fix_trailing_comma_array(self): + """Test fixing trailing comma in array.""" + text = '[1, 2, 3,]' + result = fix_common_json_errors(text) + assert result == '[1, 2, 3]' + + def test_fix_python_true_false(self): + """Test fixing Python True/False/None to JSON equivalents.""" + text = '{"flag": True, "empty": None, "off": False}' + result = fix_common_json_errors(text) + assert ": true" in result + assert ": null" in result + assert ": false" in result + + def test_fix_nan_value(self): + """Test fixing NaN to null.""" + text = '{"score": nan}' + result = fix_common_json_errors(text) + assert ": null" in result + + def test_empty_input(self): + """Test empty input.""" + assert fix_common_json_errors("") == "" + + +@pytest.mark.unit +@pytest.mark.utils +class TestSafeParseLlmJson: + """Test end-to-end safe JSON parsing.""" + + def test_parse_clean_json(self): + """Test parsing clean JSON.""" + result = safe_parse_llm_json('{"key": "value"}') + assert result == {"key": "value"} + + def test_parse_json_in_markdown(self): + """Test parsing JSON wrapped in markdown code block.""" + text = '```json\n{"key": "value"}\n```' + result = safe_parse_llm_json(text) + assert result == {"key": "value"} + + def test_parse_json_with_thinking_tags(self): + """Test parsing JSON with thinking tags.""" + text = 'Analysis{"result": 42}' + result = safe_parse_llm_json(text) + assert result == {"result": 42} + + def test_parse_json_with_trailing_comma(self): + """Test parsing JSON with trailing comma.""" + text = '{"key": "value",}' + result = safe_parse_llm_json(text) + assert result == {"key": "value"} + + def test_parse_invalid_json_returns_fallback(self): + """Test invalid JSON returns fallback result.""" + result = safe_parse_llm_json("not json at all", fallback_result={"default": True}) + assert result == {"default": True} + + def test_parse_empty_input(self): + """Test empty input returns fallback.""" + result = safe_parse_llm_json("", fallback_result=None) + assert result is None + + def test_parse_json_array(self): + """Test parsing JSON array.""" + result = safe_parse_llm_json('[1, 2, 3]') + assert result == [1, 2, 3] + + def test_parse_with_single_quotes(self): + """Test parsing JSON with single quotes.""" + text = "{'key': 'value'}" + result = safe_parse_llm_json(text) + assert result == {"key": "value"} + + def test_parse_nested_json(self): + """Test parsing nested JSON structure.""" + text = '{"outer": {"inner": [1, 2, 3]}, "flag": true}' + result = safe_parse_llm_json(text) + assert result["outer"]["inner"] == [1, 2, 3] + assert result["flag"] is True + + +@pytest.mark.unit +@pytest.mark.utils +class TestValidateJsonStructure: + """Test JSON structure validation.""" + + def test_valid_with_required_fields(self): + """Test validation with all required fields present.""" + data = {"name": "Alice", "age": 30} + valid, msg = validate_json_structure( + data, required_fields=["name", "age"] + ) + assert valid is True + assert msg == "" + + def test_missing_required_fields(self): + """Test validation with missing required fields.""" + data = {"name": "Alice"} + valid, msg = validate_json_structure( + data, required_fields=["name", "age"] + ) + assert valid is False + assert "age" in msg + + def test_none_data(self): + """Test validation with None data.""" + valid, msg = validate_json_structure(None) + assert valid is False + + def test_type_check_success(self): + """Test validation with correct expected type.""" + valid, msg = validate_json_structure( + {"key": "value"}, expected_type=dict + ) + assert valid is True + + def test_type_check_failure(self): + """Test validation with incorrect expected type.""" + valid, msg = validate_json_structure( + [1, 2, 3], expected_type=dict + ) + assert valid is False + assert "dict" in msg + + +@pytest.mark.unit +@pytest.mark.utils +class TestDetectLlmProvider: + """Test LLM provider detection from model names.""" + + def test_detect_deepseek(self): + """Test detecting DeepSeek provider.""" + assert detect_llm_provider("deepseek-chat") == LLMProvider.DEEPSEEK + assert detect_llm_provider("deepseek-reasoner") == LLMProvider.DEEPSEEK + + def test_detect_anthropic(self): + """Test detecting Anthropic provider.""" + assert detect_llm_provider("claude-3-opus") == LLMProvider.ANTHROPIC + assert detect_llm_provider("claude-3.5-sonnet") == LLMProvider.ANTHROPIC + + def test_detect_openai(self): + """Test detecting OpenAI provider.""" + assert detect_llm_provider("gpt-4") == LLMProvider.OPENAI + assert detect_llm_provider("gpt-4o-mini") == LLMProvider.OPENAI + + def test_detect_unknown(self): + """Test detecting unknown provider.""" + assert detect_llm_provider("some-custom-model") == LLMProvider.GENERIC + + def test_detect_empty_input(self): + """Test detecting from empty input.""" + assert detect_llm_provider("") == LLMProvider.GENERIC + assert detect_llm_provider(None) == LLMProvider.GENERIC + + +@pytest.mark.unit +@pytest.mark.utils +class TestConvertSingleQuotes: + """Test single-to-double quote conversion.""" + + def test_basic_conversion(self): + """Test basic single quote to double quote conversion.""" + result = _convert_single_quotes("{'key': 'value'}") + assert result == '{"key": "value"}' + + def test_already_double_quotes(self): + """Test text with double quotes is unchanged.""" + text = '{"key": "value"}' + result = _convert_single_quotes(text) + assert result == text + + def test_empty_input(self): + """Test empty input.""" + assert _convert_single_quotes("") == "" + assert _convert_single_quotes(None) is None diff --git a/tests/unit/test_learning_quality_monitor.py b/tests/unit/test_learning_quality_monitor.py new file mode 100644 index 0000000..2d55baa --- /dev/null +++ b/tests/unit/test_learning_quality_monitor.py @@ -0,0 +1,586 @@ +""" +Unit tests for LearningQualityMonitor + +Tests the learning quality monitoring service: +- PersonaMetrics and LearningAlert dataclasses +- Consistency calculation (text similarity fallback) +- Style stability calculation +- Vocabulary diversity calculation +- Emotional balance calculation (simple fallback) +- Coherence calculation +- Quality alert generation +- Style drift detection +- Threshold dynamic adjustment +- Pause learning decision +- Quality report generation +""" +import pytest +from unittest.mock import patch, MagicMock, AsyncMock +from datetime import datetime, timedelta + +from services.quality.learning_quality_monitor import ( + LearningQualityMonitor, + PersonaMetrics, + LearningAlert, +) + + +def _create_monitor( + consistency_threshold=0.5, + stability_threshold=0.4, + drift_threshold=0.4, +) -> LearningQualityMonitor: + """Create a LearningQualityMonitor with mocked dependencies.""" + config = MagicMock() + context = MagicMock() + + monitor = LearningQualityMonitor( + config=config, + context=context, + llm_adapter=None, + prompts=None, + ) + monitor.consistency_threshold = consistency_threshold + monitor.stability_threshold = stability_threshold + monitor.drift_threshold = drift_threshold + + return monitor + + +def _make_messages(texts): + """Helper to create message dicts from text list.""" + return [{"message": text} for text in texts] + + +@pytest.mark.unit +@pytest.mark.quality +class TestPersonaMetrics: + """Test PersonaMetrics dataclass.""" + + def test_default_values(self): + """Test default metric values.""" + metrics = PersonaMetrics() + + assert metrics.consistency_score == 0.0 + assert metrics.style_stability == 0.0 + assert metrics.vocabulary_diversity == 0.0 + assert metrics.emotional_balance == 0.0 + assert metrics.coherence_score == 0.0 + + def test_custom_values(self): + """Test custom metric values.""" + metrics = PersonaMetrics( + consistency_score=0.85, + style_stability=0.9, + vocabulary_diversity=0.7, + emotional_balance=0.65, + coherence_score=0.8, + ) + + assert metrics.consistency_score == 0.85 + assert metrics.style_stability == 0.9 + + +@pytest.mark.unit +@pytest.mark.quality +class TestLearningAlert: + """Test LearningAlert dataclass.""" + + def test_alert_creation(self): + """Test creating a learning alert.""" + alert = LearningAlert( + alert_type="consistency", + severity="high", + message="Consistency dropped below threshold", + timestamp=datetime.now().isoformat(), + metrics={"consistency_score": 0.3}, + suggestions=["Review persona changes"], + ) + + assert alert.alert_type == "consistency" + assert alert.severity == "high" + assert len(alert.suggestions) == 1 + + +@pytest.mark.unit +@pytest.mark.quality +class TestConsistencyCalculation: + """Test persona consistency score calculation.""" + + @pytest.mark.asyncio + async def test_both_empty_personas(self): + """Test consistency when both personas are empty.""" + monitor = _create_monitor() + + score = await monitor._calculate_consistency( + {"prompt": ""}, {"prompt": ""} + ) + assert score == 0.7 + + @pytest.mark.asyncio + async def test_one_empty_persona(self): + """Test consistency when one persona is empty.""" + monitor = _create_monitor() + + score = await monitor._calculate_consistency( + {"prompt": "I am a helpful bot"}, {"prompt": ""} + ) + assert score == 0.6 + + @pytest.mark.asyncio + async def test_identical_personas(self): + """Test consistency when personas are identical.""" + monitor = _create_monitor() + prompt = "I am a friendly chatbot." + + score = await monitor._calculate_consistency( + {"prompt": prompt}, {"prompt": prompt} + ) + assert score == 0.95 + + @pytest.mark.asyncio + async def test_similar_personas_fallback(self): + """Test consistency using text similarity fallback (no LLM).""" + monitor = _create_monitor() + + score = await monitor._calculate_consistency( + {"prompt": "I am a helpful assistant."}, + {"prompt": "I am a helpful assistant. I like chatting."}, + ) + assert 0.4 <= score <= 1.0 + + +@pytest.mark.unit +@pytest.mark.quality +class TestTextSimilarity: + """Test text similarity fallback method.""" + + def test_identical_texts(self): + """Test identical texts return high similarity.""" + monitor = _create_monitor() + + score = monitor._calculate_text_similarity("hello world", "hello world") + assert score == 0.95 + + def test_empty_texts(self): + """Test empty texts return default.""" + monitor = _create_monitor() + + score = monitor._calculate_text_similarity("", "") + assert score == 0.6 + + def test_one_empty_text(self): + """Test one empty text returns default.""" + monitor = _create_monitor() + + score = monitor._calculate_text_similarity("hello", "") + assert score == 0.6 + + def test_different_texts(self): + """Test different texts return lower similarity.""" + monitor = _create_monitor() + + score = monitor._calculate_text_similarity("abc", "xyz") + assert 0.4 <= score <= 1.0 + + +@pytest.mark.unit +@pytest.mark.quality +class TestStyleStability: + """Test style stability calculation.""" + + @pytest.mark.asyncio + async def test_single_message_perfect_stability(self): + """Test single message returns perfect stability.""" + monitor = _create_monitor() + messages = _make_messages(["Hello!"]) + + score = await monitor._calculate_style_stability(messages) + assert score == 1.0 + + @pytest.mark.asyncio + async def test_identical_messages_high_stability(self): + """Test identical messages have high stability.""" + monitor = _create_monitor() + messages = _make_messages(["Hello!", "Hello!", "Hello!"]) + + score = await monitor._calculate_style_stability(messages) + assert score >= 0.8 + + @pytest.mark.asyncio + async def test_diverse_messages_lower_stability(self): + """Test diverse messages have lower stability.""" + monitor = _create_monitor() + messages = _make_messages([ + "Hi", + "This is a very long message with lots of words and punctuation! Really?", + "Ok", + ]) + + score = await monitor._calculate_style_stability(messages) + assert 0.0 <= score <= 1.0 + + +@pytest.mark.unit +@pytest.mark.quality +class TestVocabularyDiversity: + """Test vocabulary diversity calculation.""" + + @pytest.mark.asyncio + async def test_empty_messages(self): + """Test empty messages return zero diversity.""" + monitor = _create_monitor() + + score = await monitor._calculate_vocabulary_diversity([]) + assert score == 0.0 + + @pytest.mark.asyncio + async def test_single_word_messages(self): + """Test messages with same word have low diversity (actually 1.0).""" + monitor = _create_monitor() + messages = _make_messages(["hello", "hello", "hello"]) + + score = await monitor._calculate_vocabulary_diversity(messages) + # All same word: unique=1, total=3, ratio=0.33, *2=0.66 + assert 0.5 <= score <= 1.0 + + @pytest.mark.asyncio + async def test_unique_words_high_diversity(self): + """Test messages with all unique words have high diversity.""" + monitor = _create_monitor() + messages = _make_messages(["apple banana", "cherry date", "elderberry fig"]) + + score = await monitor._calculate_vocabulary_diversity(messages) + assert score >= 0.8 + + +@pytest.mark.unit +@pytest.mark.quality +class TestEmotionalBalance: + """Test emotional balance calculation (simple fallback).""" + + def test_neutral_messages(self): + """Test messages without emotional words return high balance.""" + monitor = _create_monitor() + messages = _make_messages(["今天天气不错", "我去了公园"]) + + score = monitor._simple_emotional_balance(messages) + assert score == 0.8 # No emotional words = neutral + + def test_positive_messages(self): + """Test messages with positive words.""" + monitor = _create_monitor() + messages = _make_messages(["好棒啊!", "真的很开心!喜欢!"]) + + score = monitor._simple_emotional_balance(messages) + assert 0.0 <= score <= 1.0 + + def test_negative_messages(self): + """Test messages with negative words.""" + monitor = _create_monitor() + messages = _make_messages(["不好", "真烦人,讨厌"]) + + score = monitor._simple_emotional_balance(messages) + assert 0.0 <= score <= 1.0 + + def test_balanced_messages(self): + """Test balanced positive and negative messages.""" + monitor = _create_monitor() + messages = _make_messages(["好开心", "不好"]) + + score = monitor._simple_emotional_balance(messages) + assert 0.0 <= score <= 1.0 + + +@pytest.mark.unit +@pytest.mark.quality +class TestCoherence: + """Test coherence calculation.""" + + @pytest.mark.asyncio + async def test_empty_persona(self): + """Test empty persona returns zero coherence.""" + monitor = _create_monitor() + + score = await monitor._calculate_coherence({"prompt": ""}) + assert score == 0.0 + + @pytest.mark.asyncio + async def test_single_sentence(self): + """Test single sentence returns high coherence.""" + monitor = _create_monitor() + + score = await monitor._calculate_coherence({"prompt": "我是一个友好的助手"}) + assert score == 0.8 + + @pytest.mark.asyncio + async def test_multiple_sentences(self): + """Test multiple sentences are evaluated.""" + monitor = _create_monitor() + prompt = "我是一个友好的助手。我喜欢帮助人。我会用中文交流。" + + score = await monitor._calculate_coherence({"prompt": prompt}) + assert 0.0 <= score <= 1.0 + + +@pytest.mark.unit +@pytest.mark.quality +class TestStyleDrift: + """Test style drift detection.""" + + def test_no_drift_identical_metrics(self): + """Test no drift when metrics are identical.""" + monitor = _create_monitor() + metrics = PersonaMetrics( + consistency_score=0.8, + style_stability=0.7, + vocabulary_diversity=0.6, + ) + + drift = monitor._calculate_style_drift(metrics, metrics) + assert drift == 0.0 + + def test_large_drift(self): + """Test large drift detection.""" + monitor = _create_monitor() + prev = PersonaMetrics( + consistency_score=0.9, + style_stability=0.8, + vocabulary_diversity=0.7, + ) + curr = PersonaMetrics( + consistency_score=0.3, + style_stability=0.2, + vocabulary_diversity=0.1, + ) + + drift = monitor._calculate_style_drift(prev, curr) + assert drift > 0.4 + + +@pytest.mark.unit +@pytest.mark.quality +class TestQualityAlerts: + """Test quality alert generation.""" + + @pytest.mark.asyncio + async def test_consistency_alert(self): + """Test alert is generated when consistency is below threshold.""" + monitor = _create_monitor(consistency_threshold=0.5) + metrics = PersonaMetrics(consistency_score=0.3) + + await monitor._check_quality_alerts(metrics) + + assert len(monitor.alerts_history) >= 1 + assert any(a.alert_type == "consistency" for a in monitor.alerts_history) + + @pytest.mark.asyncio + async def test_stability_alert(self): + """Test alert is generated when stability is below threshold.""" + monitor = _create_monitor(stability_threshold=0.4) + metrics = PersonaMetrics(style_stability=0.2) + + await monitor._check_quality_alerts(metrics) + + assert any(a.alert_type == "stability" for a in monitor.alerts_history) + + @pytest.mark.asyncio + async def test_no_alert_when_above_thresholds(self): + """Test no alerts when all metrics are above thresholds.""" + monitor = _create_monitor() + metrics = PersonaMetrics( + consistency_score=0.9, + style_stability=0.8, + vocabulary_diversity=0.7, + ) + + await monitor._check_quality_alerts(metrics) + assert len(monitor.alerts_history) == 0 + + @pytest.mark.asyncio + async def test_drift_alert_with_history(self): + """Test drift alert when historical metrics exist.""" + monitor = _create_monitor(drift_threshold=0.1) + # Add previous metrics + monitor.historical_metrics.append( + PersonaMetrics(consistency_score=0.9, style_stability=0.9, vocabulary_diversity=0.9) + ) + # Current metrics show significant change + current = PersonaMetrics( + consistency_score=0.3, + style_stability=0.3, + vocabulary_diversity=0.3, + ) + monitor.historical_metrics.append(current) + + await monitor._check_quality_alerts(current) + + assert any(a.alert_type == "drift" for a in monitor.alerts_history) + + +@pytest.mark.unit +@pytest.mark.quality +class TestShouldPauseLearning: + """Test learning pause decision logic.""" + + @pytest.mark.asyncio + async def test_no_history_no_pause(self): + """Test no pause with empty history.""" + monitor = _create_monitor() + + should_pause, reason = await monitor.should_pause_learning() + assert should_pause is False + + @pytest.mark.asyncio + async def test_pause_on_low_consistency(self): + """Test pause when consistency is critically low.""" + monitor = _create_monitor() + monitor.historical_metrics.append( + PersonaMetrics(consistency_score=0.3) + ) + + should_pause, reason = await monitor.should_pause_learning() + assert should_pause is True + assert "一致性" in reason + + @pytest.mark.asyncio + async def test_no_pause_above_threshold(self): + """Test no pause when metrics are acceptable.""" + monitor = _create_monitor() + monitor.historical_metrics.append( + PersonaMetrics(consistency_score=0.8) + ) + + should_pause, reason = await monitor.should_pause_learning() + assert should_pause is False + + +@pytest.mark.unit +@pytest.mark.quality +class TestQualityReport: + """Test quality report generation.""" + + @pytest.mark.asyncio + async def test_report_no_history(self): + """Test report with no historical data.""" + monitor = _create_monitor() + + report = await monitor.get_quality_report() + assert "error" in report + + @pytest.mark.asyncio + async def test_report_with_single_metric(self): + """Test report with single historical metric.""" + monitor = _create_monitor() + monitor.historical_metrics.append( + PersonaMetrics( + consistency_score=0.8, + style_stability=0.7, + vocabulary_diversity=0.6, + emotional_balance=0.5, + coherence_score=0.9, + ) + ) + + report = await monitor.get_quality_report() + + assert "current_metrics" in report + assert report["current_metrics"]["consistency_score"] == 0.8 + assert "trends" in report + assert "recommendations" in report + + @pytest.mark.asyncio + async def test_report_with_trends(self): + """Test report includes trend data when sufficient history exists.""" + monitor = _create_monitor() + monitor.historical_metrics.append( + PersonaMetrics(consistency_score=0.6, style_stability=0.5, vocabulary_diversity=0.4) + ) + monitor.historical_metrics.append( + PersonaMetrics(consistency_score=0.8, style_stability=0.7, vocabulary_diversity=0.6) + ) + + report = await monitor.get_quality_report() + + assert report["trends"]["consistency_trend"] == pytest.approx(0.2) + assert report["trends"]["stability_trend"] == pytest.approx(0.2) + + +@pytest.mark.unit +@pytest.mark.quality +class TestDynamicThresholdAdjustment: + """Test dynamic threshold adjustment based on history.""" + + @pytest.mark.asyncio + async def test_no_adjustment_insufficient_history(self): + """Test no adjustment with less than 5 historical entries.""" + monitor = _create_monitor(consistency_threshold=0.5) + + for _ in range(3): + monitor.historical_metrics.append(PersonaMetrics(consistency_score=0.9)) + + await monitor.adjust_thresholds_based_on_history() + + # Should remain unchanged + assert monitor.consistency_threshold == 0.5 + + @pytest.mark.asyncio + async def test_threshold_increases_on_good_performance(self): + """Test threshold increases when performance is consistently good.""" + monitor = _create_monitor(consistency_threshold=0.5) + + for _ in range(5): + monitor.historical_metrics.append( + PersonaMetrics(consistency_score=0.85, style_stability=0.75) + ) + + await monitor.adjust_thresholds_based_on_history() + + assert monitor.consistency_threshold == 0.55 # Increased by 0.05 + + +@pytest.mark.unit +@pytest.mark.quality +class TestHelperMethods: + """Test helper methods.""" + + def test_punctuation_ratio(self): + """Test punctuation ratio calculation.""" + monitor = _create_monitor() + + assert monitor._get_punctuation_ratio("") == 0.0 + assert monitor._get_punctuation_ratio("hello") == 0.0 + assert monitor._get_punctuation_ratio("你好,世界!") > 0.0 + + def test_count_emoji(self): + """Test emoji counting.""" + monitor = _create_monitor() + + assert monitor._count_emoji("hello") == 0 + # The emoji patterns defined in the source are empty strings, + # so this tests the current behavior + assert isinstance(monitor._count_emoji("hello 😀"), int) + + def test_recommendations_low_consistency(self): + """Test recommendations for low consistency.""" + monitor = _create_monitor() + metrics = PersonaMetrics(consistency_score=0.5) + + recs = monitor._generate_recommendations(metrics, []) + assert any("一致性" in r for r in recs) + + def test_recommendations_good_quality(self): + """Test recommendations for good quality.""" + monitor = _create_monitor() + metrics = PersonaMetrics(consistency_score=0.9, style_stability=0.8) + + recs = monitor._generate_recommendations(metrics, []) + assert any("良好" in r for r in recs) + + @pytest.mark.asyncio + async def test_stop(self): + """Test service stop.""" + monitor = _create_monitor() + + result = await monitor.stop() + assert result is True diff --git a/tests/unit/test_patterns.py b/tests/unit/test_patterns.py new file mode 100644 index 0000000..3e76501 --- /dev/null +++ b/tests/unit/test_patterns.py @@ -0,0 +1,446 @@ +""" +Unit tests for core design patterns module + +Tests the design pattern implementations: +- AsyncServiceBase lifecycle management +- LearningContextBuilder (builder pattern) +- StrategyFactory (factory + strategy patterns) +- ServiceRegistry (singleton + registry pattern) +- ProgressiveLearningStrategy execution +- BatchLearningStrategy execution +""" +import asyncio +import pytest +from unittest.mock import patch, MagicMock, AsyncMock +from datetime import datetime + +from core.interfaces import ( + ServiceLifecycle, + LearningStrategyType, + MessageData, +) +from core.patterns import ( + AsyncServiceBase, + LearningContext, + LearningContextBuilder, + ProgressiveLearningStrategy, + BatchLearningStrategy, + StrategyFactory, + ServiceRegistry, + SingletonABCMeta, +) + + +@pytest.mark.unit +@pytest.mark.core +class TestAsyncServiceBase: + """Test AsyncServiceBase lifecycle management.""" + + def _create_service(self, name: str = "test_service") -> AsyncServiceBase: + """Helper to create a concrete service instance.""" + return AsyncServiceBase(name) + + def test_initial_status_is_created(self): + """Test service starts in CREATED state.""" + service = self._create_service() + assert service.status == ServiceLifecycle.CREATED + + @pytest.mark.asyncio + async def test_start_transitions_to_running(self): + """Test starting service transitions to RUNNING.""" + service = self._create_service() + + result = await service.start() + + assert result is True + assert service.status == ServiceLifecycle.RUNNING + + @pytest.mark.asyncio + async def test_start_already_running(self): + """Test starting already running service returns True.""" + service = self._create_service() + await service.start() + + result = await service.start() + + assert result is True + assert service.status == ServiceLifecycle.RUNNING + + @pytest.mark.asyncio + async def test_stop_transitions_to_stopped(self): + """Test stopping service transitions to STOPPED.""" + service = self._create_service() + await service.start() + + result = await service.stop() + + assert result is True + assert service.status == ServiceLifecycle.STOPPED + + @pytest.mark.asyncio + async def test_stop_already_stopped(self): + """Test stopping already stopped service returns True.""" + service = self._create_service() + await service.start() + await service.stop() + + result = await service.stop() + + assert result is True + assert service.status == ServiceLifecycle.STOPPED + + @pytest.mark.asyncio + async def test_restart(self): + """Test restarting service.""" + service = self._create_service() + await service.start() + + result = await service.restart() + + assert result is True + assert service.status == ServiceLifecycle.RUNNING + + @pytest.mark.asyncio + async def test_is_running(self): + """Test is_running check.""" + service = self._create_service() + + assert await service.is_running() is False + + await service.start() + assert await service.is_running() is True + + await service.stop() + assert await service.is_running() is False + + @pytest.mark.asyncio + async def test_health_check(self): + """Test health check reflects running state.""" + service = self._create_service() + + assert await service.health_check() is False + + await service.start() + assert await service.health_check() is True + + @pytest.mark.asyncio + async def test_start_failure_transitions_to_error(self): + """Test service transitions to ERROR on start failure.""" + service = self._create_service() + service._do_start = AsyncMock(side_effect=RuntimeError("init failed")) + + result = await service.start() + + assert result is False + assert service.status == ServiceLifecycle.ERROR + + @pytest.mark.asyncio + async def test_stop_failure_transitions_to_error(self): + """Test service transitions to ERROR on stop failure.""" + service = self._create_service() + await service.start() + service._do_stop = AsyncMock(side_effect=RuntimeError("cleanup failed")) + + result = await service.stop() + + assert result is False + assert service.status == ServiceLifecycle.ERROR + + +@pytest.mark.unit +@pytest.mark.core +class TestLearningContextBuilder: + """Test LearningContextBuilder (builder pattern).""" + + def test_build_default_context(self): + """Test building context with default values.""" + context = LearningContextBuilder().build() + + assert isinstance(context, LearningContext) + assert context.messages == [] + assert context.strategy_type == LearningStrategyType.PROGRESSIVE + assert context.quality_threshold == 0.7 + assert context.max_iterations == 3 + assert context.metadata == {} + + def test_builder_chain(self): + """Test fluent builder chaining.""" + messages = [ + MessageData( + sender_id="u1", sender_name="Alice", + message="Hello", group_id="g1", + timestamp=1.0, platform="qq", + ) + ] + + context = ( + LearningContextBuilder() + .with_messages(messages) + .with_strategy(LearningStrategyType.BATCH) + .with_quality_threshold(0.9) + .with_max_iterations(5) + .with_metadata("source", "test") + .build() + ) + + assert len(context.messages) == 1 + assert context.strategy_type == LearningStrategyType.BATCH + assert context.quality_threshold == 0.9 + assert context.max_iterations == 5 + assert context.metadata["source"] == "test" + + +@pytest.mark.unit +@pytest.mark.core +class TestStrategyFactory: + """Test StrategyFactory (factory + strategy patterns).""" + + def test_create_progressive_strategy(self): + """Test creating progressive learning strategy.""" + config = {"batch_size": 25, "min_messages": 10} + strategy = StrategyFactory.create_strategy( + LearningStrategyType.PROGRESSIVE, config + ) + + assert isinstance(strategy, ProgressiveLearningStrategy) + assert strategy.config == config + + def test_create_batch_strategy(self): + """Test creating batch learning strategy.""" + config = {"batch_size": 100} + strategy = StrategyFactory.create_strategy( + LearningStrategyType.BATCH, config + ) + + assert isinstance(strategy, BatchLearningStrategy) + + def test_create_unsupported_strategy_raises(self): + """Test creating unsupported strategy raises ValueError.""" + with pytest.raises(ValueError, match="不支持的策略类型"): + StrategyFactory.create_strategy( + LearningStrategyType.REALTIME, {} + ) + + def test_register_custom_strategy(self): + """Test registering a custom strategy type.""" + + class CustomStrategy: + def __init__(self, config): + self.config = config + + StrategyFactory.register_strategy( + LearningStrategyType.REALTIME, CustomStrategy + ) + strategy = StrategyFactory.create_strategy( + LearningStrategyType.REALTIME, {"custom": True} + ) + + assert isinstance(strategy, CustomStrategy) + + # Cleanup: remove custom strategy to avoid test pollution + del StrategyFactory._strategies[LearningStrategyType.REALTIME] + + +@pytest.mark.unit +@pytest.mark.core +class TestProgressiveLearningStrategy: + """Test ProgressiveLearningStrategy execution logic.""" + + def _make_messages(self, count: int): + """Helper to create test messages.""" + return [ + MessageData( + sender_id=f"u{i}", sender_name=f"User{i}", + message=f"Message {i}", group_id="g1", + timestamp=float(i), platform="qq", + ) + for i in range(count) + ] + + @pytest.mark.asyncio + async def test_execute_learning_cycle_success(self): + """Test progressive learning cycle executes successfully.""" + strategy = ProgressiveLearningStrategy({"batch_size": 10}) + messages = self._make_messages(25) + + result = await strategy.execute_learning_cycle(messages) + + assert result.success is True + assert result.confidence > 0 + assert result.data["total_processed"] == 25 + assert result.data["batch_count"] == 3 + + @pytest.mark.asyncio + async def test_execute_learning_cycle_empty_messages(self): + """Test progressive learning cycle with empty messages.""" + strategy = ProgressiveLearningStrategy({"batch_size": 10}) + + result = await strategy.execute_learning_cycle([]) + + assert result.success is True + assert result.data["total_processed"] == 0 + + @pytest.mark.asyncio + async def test_should_learn_sufficient_messages(self): + """Test should_learn returns True when conditions are met.""" + strategy = ProgressiveLearningStrategy({ + "min_messages": 5, + "min_interval_hours": 0, + }) + context = { + "message_count": 10, + "last_learning_time": 0, + } + + result = await strategy.should_learn(context) + assert result is True + + @pytest.mark.asyncio + async def test_should_learn_insufficient_messages(self): + """Test should_learn returns False with insufficient messages.""" + strategy = ProgressiveLearningStrategy({ + "min_messages": 20, + "min_interval_hours": 0, + }) + context = { + "message_count": 5, + "last_learning_time": 0, + } + + result = await strategy.should_learn(context) + assert result is False + + +@pytest.mark.unit +@pytest.mark.core +class TestBatchLearningStrategy: + """Test BatchLearningStrategy execution logic.""" + + def _make_messages(self, count: int): + """Helper to create test messages.""" + return [ + MessageData( + sender_id=f"u{i}", sender_name=f"User{i}", + message=f"Message {i}", group_id="g1", + timestamp=float(i), platform="qq", + ) + for i in range(count) + ] + + @pytest.mark.asyncio + async def test_execute_learning_cycle_success(self): + """Test batch learning cycle executes successfully.""" + strategy = BatchLearningStrategy({"batch_size": 100}) + messages = self._make_messages(50) + + result = await strategy.execute_learning_cycle(messages) + + assert result.success is True + assert result.confidence > 0 + assert result.data["processed_count"] == 50 + + @pytest.mark.asyncio + async def test_should_learn_above_threshold(self): + """Test should_learn returns True when batch threshold is met.""" + strategy = BatchLearningStrategy({"batch_size": 20}) + context = {"message_count": 25} + + result = await strategy.should_learn(context) + assert result is True + + @pytest.mark.asyncio + async def test_should_learn_below_threshold(self): + """Test should_learn returns False below batch threshold.""" + strategy = BatchLearningStrategy({"batch_size": 100}) + context = {"message_count": 50} + + result = await strategy.should_learn(context) + assert result is False + + +@pytest.mark.unit +@pytest.mark.core +class TestServiceRegistry: + """Test ServiceRegistry (singleton + registry pattern).""" + + def _create_fresh_registry(self) -> ServiceRegistry: + """Create a fresh registry by clearing singleton cache.""" + # Clear singleton instance to avoid test pollution + SingletonABCMeta._instances.pop(ServiceRegistry, None) + return ServiceRegistry(service_stop_timeout=2) + + def test_register_service(self): + """Test registering a service.""" + registry = self._create_fresh_registry() + service = AsyncServiceBase("test_svc") + + registry.register_service("test_svc", service) + + assert registry.get_service("test_svc") is service + + def test_get_nonexistent_service(self): + """Test getting a nonexistent service returns None.""" + registry = self._create_fresh_registry() + + assert registry.get_service("nonexistent") is None + + def test_unregister_service(self): + """Test unregistering a service.""" + registry = self._create_fresh_registry() + service = AsyncServiceBase("test_svc") + registry.register_service("test_svc", service) + + result = registry.unregister_service("test_svc") + + assert result is True + assert registry.get_service("test_svc") is None + + def test_unregister_nonexistent_service(self): + """Test unregistering a nonexistent service returns False.""" + registry = self._create_fresh_registry() + + result = registry.unregister_service("nonexistent") + + assert result is False + + @pytest.mark.asyncio + async def test_start_all_services(self): + """Test starting all registered services.""" + registry = self._create_fresh_registry() + svc1 = AsyncServiceBase("svc1") + svc2 = AsyncServiceBase("svc2") + registry.register_service("svc1", svc1) + registry.register_service("svc2", svc2) + + result = await registry.start_all_services() + + assert result is True + assert svc1.status == ServiceLifecycle.RUNNING + assert svc2.status == ServiceLifecycle.RUNNING + + @pytest.mark.asyncio + async def test_stop_all_services(self): + """Test stopping all registered services.""" + registry = self._create_fresh_registry() + svc1 = AsyncServiceBase("svc1") + svc2 = AsyncServiceBase("svc2") + registry.register_service("svc1", svc1) + registry.register_service("svc2", svc2) + await registry.start_all_services() + + result = await registry.stop_all_services() + + assert result is True + assert svc1.status == ServiceLifecycle.STOPPED + assert svc2.status == ServiceLifecycle.STOPPED + + def test_get_service_status(self): + """Test getting status of all registered services.""" + registry = self._create_fresh_registry() + svc = AsyncServiceBase("svc1") + registry.register_service("svc1", svc) + + status = registry.get_service_status() + + assert "svc1" in status + assert status["svc1"] == "created" diff --git a/tests/unit/test_security_utils.py b/tests/unit/test_security_utils.py new file mode 100644 index 0000000..bd4b10e --- /dev/null +++ b/tests/unit/test_security_utils.py @@ -0,0 +1,306 @@ +""" +Unit tests for security utilities module + +Tests the security infrastructure: +- PasswordHasher: hash generation, salt handling, verification +- LoginAttemptTracker: attempt recording, lockout, rate limiting +- SecurityValidator: password strength, input sanitization, token validation +- Password migration: plaintext to hashed format +""" +import time +import pytest +from unittest.mock import patch + +from utils.security_utils import ( + PasswordHasher, + LoginAttemptTracker, + SecurityValidator, + migrate_password_to_hashed, + verify_password_with_migration, +) + + +@pytest.mark.unit +@pytest.mark.security +class TestPasswordHasher: + """Test PasswordHasher functionality.""" + + def test_hash_password_returns_tuple(self): + """Test hash_password returns (hash, salt) tuple.""" + hashed, salt = PasswordHasher.hash_password("test_password") + + assert isinstance(hashed, str) + assert isinstance(salt, str) + assert len(hashed) == 32 # MD5 hex digest length + assert len(salt) == 32 # 16 bytes = 32 hex chars + + def test_hash_password_with_custom_salt(self): + """Test hash_password with a provided salt.""" + hashed, salt = PasswordHasher.hash_password("test_password", salt="fixed_salt") + + assert salt == "fixed_salt" + assert len(hashed) == 32 + + def test_same_password_same_salt_same_hash(self): + """Test deterministic hashing with same password and salt.""" + h1, _ = PasswordHasher.hash_password("password", salt="salt123") + h2, _ = PasswordHasher.hash_password("password", salt="salt123") + + assert h1 == h2 + + def test_same_password_different_salt_different_hash(self): + """Test different salts produce different hashes.""" + h1, _ = PasswordHasher.hash_password("password", salt="salt_a") + h2, _ = PasswordHasher.hash_password("password", salt="salt_b") + + assert h1 != h2 + + def test_different_password_same_salt_different_hash(self): + """Test different passwords produce different hashes.""" + h1, _ = PasswordHasher.hash_password("password1", salt="same_salt") + h2, _ = PasswordHasher.hash_password("password2", salt="same_salt") + + assert h1 != h2 + + def test_verify_correct_password(self): + """Test password verification with correct password.""" + hashed, salt = PasswordHasher.hash_password("correct_password") + + result = PasswordHasher.verify_password("correct_password", hashed, salt) + assert result is True + + def test_verify_incorrect_password(self): + """Test password verification with incorrect password.""" + hashed, salt = PasswordHasher.hash_password("correct_password") + + result = PasswordHasher.verify_password("wrong_password", hashed, salt) + assert result is False + + +@pytest.mark.unit +@pytest.mark.security +class TestLoginAttemptTracker: + """Test LoginAttemptTracker functionality.""" + + def test_initial_state_not_locked(self): + """Test new IP is not locked.""" + tracker = LoginAttemptTracker(max_attempts=3) + + locked, remaining = tracker.is_locked("192.168.1.1") + assert locked is False + assert remaining == 0 + + def test_full_remaining_attempts_for_new_ip(self): + """Test new IP has full remaining attempts.""" + tracker = LoginAttemptTracker(max_attempts=5) + + remaining = tracker.get_remaining_attempts("192.168.1.1") + assert remaining == 5 + + def test_failed_attempt_decreases_remaining(self): + """Test failed attempt decreases remaining count.""" + tracker = LoginAttemptTracker(max_attempts=5) + + tracker.record_attempt("192.168.1.1", success=False) + + remaining = tracker.get_remaining_attempts("192.168.1.1") + assert remaining == 4 + + def test_lockout_after_max_attempts(self): + """Test IP is locked after max failed attempts.""" + tracker = LoginAttemptTracker(max_attempts=3, lockout_duration=300) + + for _ in range(3): + tracker.record_attempt("192.168.1.1", success=False) + + locked, remaining_seconds = tracker.is_locked("192.168.1.1") + assert locked is True + assert remaining_seconds > 0 + + def test_successful_login_clears_attempts(self): + """Test successful login clears failed attempt history.""" + tracker = LoginAttemptTracker(max_attempts=5) + + tracker.record_attempt("192.168.1.1", success=False) + tracker.record_attempt("192.168.1.1", success=False) + tracker.record_attempt("192.168.1.1", success=True) + + remaining = tracker.get_remaining_attempts("192.168.1.1") + assert remaining == 5 + + def test_different_ips_independent(self): + """Test tracking is independent per IP.""" + tracker = LoginAttemptTracker(max_attempts=3) + + tracker.record_attempt("192.168.1.1", success=False) + tracker.record_attempt("192.168.1.1", success=False) + + remaining_ip1 = tracker.get_remaining_attempts("192.168.1.1") + remaining_ip2 = tracker.get_remaining_attempts("192.168.1.2") + + assert remaining_ip1 == 1 + assert remaining_ip2 == 3 + + def test_clear_ip_record(self): + """Test clearing a specific IP record.""" + tracker = LoginAttemptTracker(max_attempts=3) + + tracker.record_attempt("192.168.1.1", success=False) + tracker.clear_ip_record("192.168.1.1") + + remaining = tracker.get_remaining_attempts("192.168.1.1") + assert remaining == 3 + + def test_clear_all_records(self): + """Test clearing all IP records.""" + tracker = LoginAttemptTracker(max_attempts=3) + + tracker.record_attempt("192.168.1.1", success=False) + tracker.record_attempt("192.168.1.2", success=False) + tracker.clear_all_records() + + assert tracker.get_remaining_attempts("192.168.1.1") == 3 + assert tracker.get_remaining_attempts("192.168.1.2") == 3 + + +@pytest.mark.unit +@pytest.mark.security +class TestSecurityValidator: + """Test SecurityValidator functionality.""" + + def test_strong_password(self): + """Test strong password validation.""" + result = SecurityValidator.validate_password_strength("Str0ng!P@ss") + + assert result['valid'] is True + assert result['strength'] == 'strong' + assert result['checks']['length'] is True + assert result['checks']['lowercase'] is True + assert result['checks']['uppercase'] is True + assert result['checks']['numbers'] is True + assert result['checks']['symbols'] is True + + def test_weak_password_short(self): + """Test weak password: too short.""" + result = SecurityValidator.validate_password_strength("abc") + + assert result['valid'] is False + assert result['strength'] == 'weak' + assert result['checks']['length'] is False + + def test_medium_password(self): + """Test medium strength password.""" + result = SecurityValidator.validate_password_strength("Password1") + + assert result['valid'] is True + assert result['strength'] in ('medium', 'strong') + + def test_extra_long_password_bonus(self): + """Test extra long password gets bonus score.""" + result = SecurityValidator.validate_password_strength("ThisIsAVeryLongPassword123!") + + assert result['score'] > 80 + + def test_sanitize_input_strips_whitespace(self): + """Test input sanitization strips whitespace.""" + result = SecurityValidator.sanitize_input(" hello world ") + assert result == "hello world" + + def test_sanitize_input_truncates(self): + """Test input sanitization truncates to max_length.""" + result = SecurityValidator.sanitize_input("a" * 300, max_length=10) + assert len(result) == 10 + + def test_sanitize_empty_input(self): + """Test sanitizing empty input.""" + assert SecurityValidator.sanitize_input("") == "" + assert SecurityValidator.sanitize_input(None) == "" + + def test_valid_session_token(self): + """Test valid session token validation.""" + token = "a" * 32 # 32-char hex string + assert SecurityValidator.is_valid_session_token(token) is True + + def test_invalid_session_token_too_short(self): + """Test invalid session token: too short.""" + assert SecurityValidator.is_valid_session_token("abc123") is False + + def test_invalid_session_token_non_hex(self): + """Test invalid session token: non-hex characters.""" + assert SecurityValidator.is_valid_session_token("z" * 32) is False + + def test_empty_session_token(self): + """Test empty session token is invalid.""" + assert SecurityValidator.is_valid_session_token("") is False + assert SecurityValidator.is_valid_session_token(None) is False + + +@pytest.mark.unit +@pytest.mark.security +class TestPasswordMigration: + """Test password migration from plaintext to hashed format.""" + + def test_migrate_plaintext_password(self): + """Test migrating plaintext password to hashed format.""" + old_config = {'password': 'my_password', 'must_change': False} + + new_config = migrate_password_to_hashed(old_config) + + assert 'password_hash' in new_config + assert 'salt' in new_config + assert new_config['version'] == 2 + assert new_config['migrated_from_plaintext'] is True + + def test_already_hashed_not_migrated(self): + """Test already hashed config is not re-migrated.""" + hashed_config = { + 'password_hash': 'existing_hash', + 'salt': 'existing_salt', + 'version': 2, + } + + result = migrate_password_to_hashed(hashed_config) + + assert result is hashed_config # Same object + + def test_verify_with_old_format(self): + """Test verification with old plaintext format triggers migration.""" + old_config = {'password': 'test_pass'} + + is_valid, new_config = verify_password_with_migration('test_pass', old_config) + + assert is_valid is True + assert 'password_hash' in new_config + + def test_verify_with_old_format_wrong_password(self): + """Test verification with wrong password in old format.""" + old_config = {'password': 'correct_pass'} + + is_valid, config = verify_password_with_migration('wrong_pass', old_config) + + assert is_valid is False + + def test_verify_with_new_format(self): + """Test verification with new hashed format.""" + hashed, salt = PasswordHasher.hash_password("secure_pwd") + new_config = {'password_hash': hashed, 'salt': salt} + + is_valid, config = verify_password_with_migration('secure_pwd', new_config) + + assert is_valid is True + + def test_verify_with_new_format_wrong_password(self): + """Test verification with wrong password in new format.""" + hashed, salt = PasswordHasher.hash_password("correct_pwd") + new_config = {'password_hash': hashed, 'salt': salt} + + is_valid, config = verify_password_with_migration('wrong_pwd', new_config) + + assert is_valid is False + + def test_verify_with_missing_hash_or_salt(self): + """Test verification fails gracefully when hash or salt is missing.""" + config = {'password_hash': '', 'salt': ''} + + is_valid, _ = verify_password_with_migration('any_pwd', config) + assert is_valid is False diff --git a/tests/unit/test_tiered_learning_trigger.py b/tests/unit/test_tiered_learning_trigger.py new file mode 100644 index 0000000..7fd600c --- /dev/null +++ b/tests/unit/test_tiered_learning_trigger.py @@ -0,0 +1,441 @@ +""" +Unit tests for TieredLearningTrigger + +Tests the two-tier learning trigger mechanism: +- Tier 1 registration and execution (per-message, concurrent) +- Tier 2 registration and gated execution (batch, cooldown/threshold) +- Error isolation between Tier 1 operations +- BatchTriggerPolicy configuration +- force_tier2 fast-path triggering +- Per-group state tracking and statistics +""" +import asyncio +import time +import pytest +from unittest.mock import AsyncMock, patch + +from core.interfaces import MessageData +from services.quality.tiered_learning_trigger import ( + TieredLearningTrigger, + BatchTriggerPolicy, + TriggerResult, + _GroupTriggerState, +) + + +def _make_message(text: str = "test message", group_id: str = "g1") -> MessageData: + """Helper to create a test MessageData instance.""" + return MessageData( + sender_id="user1", + sender_name="TestUser", + message=text, + group_id=group_id, + timestamp=time.time(), + platform="test", + ) + + +@pytest.mark.unit +@pytest.mark.quality +class TestTieredLearningTriggerRegistration: + """Test callback registration for both tiers.""" + + def test_register_tier1_success(self): + """Test registering a valid Tier 1 async callback.""" + trigger = TieredLearningTrigger() + + async def callback(msg, gid): + pass + + trigger.register_tier1("test_op", callback) + assert "test_op" in trigger._tier1_ops + + def test_register_tier1_none_callback_ignored(self): + """Test registering None callback is silently ignored.""" + trigger = TieredLearningTrigger() + trigger.register_tier1("test_op", None) + assert "test_op" not in trigger._tier1_ops + + def test_register_tier1_sync_callback_raises(self): + """Test registering a sync callback raises TypeError.""" + trigger = TieredLearningTrigger() + + def sync_callback(msg, gid): + pass + + with pytest.raises(TypeError, match="must be an async function"): + trigger.register_tier1("bad_op", sync_callback) + + def test_register_tier2_success(self): + """Test registering a valid Tier 2 async callback.""" + trigger = TieredLearningTrigger() + + async def callback(gid): + pass + + trigger.register_tier2("batch_op", callback) + assert "batch_op" in trigger._tier2_ops + + def test_register_tier2_with_custom_policy(self): + """Test registering Tier 2 with custom policy.""" + trigger = TieredLearningTrigger() + policy = BatchTriggerPolicy(message_threshold=50, cooldown_seconds=300.0) + + async def callback(gid): + pass + + trigger.register_tier2("batch_op", callback, policy=policy) + + stored_callback, stored_policy = trigger._tier2_ops["batch_op"] + assert stored_policy.message_threshold == 50 + assert stored_policy.cooldown_seconds == 300.0 + + def test_register_tier2_default_policy(self): + """Test registering Tier 2 uses default policy when none specified.""" + trigger = TieredLearningTrigger() + + async def callback(gid): + pass + + trigger.register_tier2("batch_op", callback) + + _, stored_policy = trigger._tier2_ops["batch_op"] + assert stored_policy.message_threshold == 15 + assert stored_policy.cooldown_seconds == 120.0 + + def test_register_tier2_sync_callback_raises(self): + """Test registering a sync Tier 2 callback raises TypeError.""" + trigger = TieredLearningTrigger() + + def sync_callback(gid): + pass + + with pytest.raises(TypeError, match="must be an async function"): + trigger.register_tier2("bad_op", sync_callback) + + +@pytest.mark.unit +@pytest.mark.quality +class TestTier1Execution: + """Test Tier 1 per-message execution.""" + + @pytest.mark.asyncio + async def test_tier1_executes_for_every_message(self): + """Test Tier 1 callbacks execute for every incoming message.""" + trigger = TieredLearningTrigger() + call_count = 0 + + async def tier1_callback(msg, gid): + nonlocal call_count + call_count += 1 + + trigger.register_tier1("counter", tier1_callback) + + for _ in range(5): + await trigger.process_message(_make_message(), "g1") + + assert call_count == 5 + + @pytest.mark.asyncio + async def test_tier1_concurrent_execution(self): + """Test multiple Tier 1 callbacks run concurrently.""" + trigger = TieredLearningTrigger() + execution_order = [] + + async def op_a(msg, gid): + execution_order.append("a") + + async def op_b(msg, gid): + execution_order.append("b") + + trigger.register_tier1("op_a", op_a) + trigger.register_tier1("op_b", op_b) + + await trigger.process_message(_make_message(), "g1") + + assert "a" in execution_order + assert "b" in execution_order + + @pytest.mark.asyncio + async def test_tier1_error_isolation(self): + """Test one Tier 1 failure does not affect others.""" + trigger = TieredLearningTrigger() + healthy_called = False + + async def failing_op(msg, gid): + raise RuntimeError("Tier 1 failure") + + async def healthy_op(msg, gid): + nonlocal healthy_called + healthy_called = True + + trigger.register_tier1("failing", failing_op) + trigger.register_tier1("healthy", healthy_op) + + result = await trigger.process_message(_make_message(), "g1") + + assert healthy_called is True + assert result.tier1_details["failing"] is False + assert result.tier1_details["healthy"] is True + assert result.tier1_ok is False + + @pytest.mark.asyncio + async def test_tier1_all_success(self): + """Test tier1_ok is True when all operations succeed.""" + trigger = TieredLearningTrigger() + + async def ok_op(msg, gid): + pass + + trigger.register_tier1("op1", ok_op) + trigger.register_tier1("op2", ok_op) + + result = await trigger.process_message(_make_message(), "g1") + + assert result.tier1_ok is True + assert all(result.tier1_details.values()) + + +@pytest.mark.unit +@pytest.mark.quality +class TestTier2Execution: + """Test Tier 2 batch execution with gating.""" + + @pytest.mark.asyncio + async def test_tier2_triggers_on_message_threshold(self): + """Test Tier 2 fires when message count reaches threshold.""" + trigger = TieredLearningTrigger() + tier2_called = False + + async def tier1_noop(msg, gid): + pass + + async def tier2_callback(gid): + nonlocal tier2_called + tier2_called = True + + trigger.register_tier1("noop", tier1_noop) + trigger.register_tier2( + "batch", tier2_callback, + policy=BatchTriggerPolicy(message_threshold=3, cooldown_seconds=9999), + ) + + for _ in range(3): + result = await trigger.process_message(_make_message(), "g1") + + assert tier2_called is True + + @pytest.mark.asyncio + async def test_tier2_does_not_trigger_below_threshold(self): + """Test Tier 2 does not fire below message threshold.""" + trigger = TieredLearningTrigger() + tier2_called = False + + async def tier1_noop(msg, gid): + pass + + async def tier2_callback(gid): + nonlocal tier2_called + tier2_called = True + + trigger.register_tier1("noop", tier1_noop) + trigger.register_tier2( + "batch", tier2_callback, + policy=BatchTriggerPolicy(message_threshold=100, cooldown_seconds=9999), + ) + + # Process only 2 messages + for _ in range(2): + await trigger.process_message(_make_message(), "g1") + + assert tier2_called is False + + @pytest.mark.asyncio + async def test_tier2_triggers_on_cooldown_expiry(self): + """Test Tier 2 fires when cooldown expires even below threshold.""" + trigger = TieredLearningTrigger() + tier2_called = False + + async def tier1_noop(msg, gid): + pass + + async def tier2_callback(gid): + nonlocal tier2_called + tier2_called = True + + trigger.register_tier1("noop", tier1_noop) + trigger.register_tier2( + "batch", tier2_callback, + policy=BatchTriggerPolicy(message_threshold=9999, cooldown_seconds=0.0), + ) + + # First message initializes state; cooldown=0 means always ready + # But _get_state initializes last_op_times to now, so the first + # process_message won't trigger. We need to manually adjust. + state = trigger._get_state("g1") + state.last_op_times["batch"] = 0.0 # Long ago + + result = await trigger.process_message(_make_message(), "g1") + + assert tier2_called is True + assert result.tier2_triggered is True + + @pytest.mark.asyncio + async def test_tier2_resets_counter_after_trigger(self): + """Test message counter resets after Tier 2 trigger.""" + trigger = TieredLearningTrigger() + + async def tier1_noop(msg, gid): + pass + + async def tier2_callback(gid): + pass + + trigger.register_tier1("noop", tier1_noop) + trigger.register_tier2( + "batch", tier2_callback, + policy=BatchTriggerPolicy(message_threshold=2, cooldown_seconds=9999), + ) + + await trigger.process_message(_make_message(), "g1") + await trigger.process_message(_make_message(), "g1") + + state = trigger._states["g1"] + assert state.message_count == 0 # Reset after trigger + + @pytest.mark.asyncio + async def test_tier2_error_handling(self): + """Test Tier 2 failure is captured in result.""" + trigger = TieredLearningTrigger() + + async def tier1_noop(msg, gid): + pass + + async def failing_tier2(gid): + raise RuntimeError("Batch failure") + + trigger.register_tier1("noop", tier1_noop) + trigger.register_tier2( + "batch", failing_tier2, + policy=BatchTriggerPolicy(message_threshold=1, cooldown_seconds=9999), + ) + + result = await trigger.process_message(_make_message(), "g1") + + assert result.tier2_triggered is True + assert result.tier2_details["batch"] is False + + +@pytest.mark.unit +@pytest.mark.quality +class TestForceTier2: + """Test force_tier2 fast-path triggering.""" + + @pytest.mark.asyncio + async def test_force_tier2_success(self): + """Test force triggering a registered Tier 2 operation.""" + trigger = TieredLearningTrigger() + called_with_group = None + + async def tier2_callback(gid): + nonlocal called_with_group + called_with_group = gid + + trigger.register_tier2("batch", tier2_callback) + + result = await trigger.force_tier2("batch", "g1") + + assert result is True + assert called_with_group == "g1" + + @pytest.mark.asyncio + async def test_force_tier2_unregistered_operation(self): + """Test forcing an unregistered operation returns False.""" + trigger = TieredLearningTrigger() + + result = await trigger.force_tier2("nonexistent", "g1") + assert result is False + + @pytest.mark.asyncio + async def test_force_tier2_failure(self): + """Test force_tier2 returns False on callback failure.""" + trigger = TieredLearningTrigger() + + async def failing_callback(gid): + raise RuntimeError("force failure") + + trigger.register_tier2("batch", failing_callback) + + result = await trigger.force_tier2("batch", "g1") + assert result is False + + +@pytest.mark.unit +@pytest.mark.quality +class TestGroupStats: + """Test per-group statistics.""" + + def test_stats_for_unknown_group(self): + """Test stats for unknown group returns inactive.""" + trigger = TieredLearningTrigger() + + stats = trigger.get_group_stats("unknown_group") + assert stats == {"active": False} + + @pytest.mark.asyncio + async def test_stats_after_processing(self): + """Test stats reflect processing state.""" + trigger = TieredLearningTrigger() + + async def tier1_noop(msg, gid): + pass + + trigger.register_tier1("noop", tier1_noop) + + await trigger.process_message(_make_message(), "g1") + await trigger.process_message(_make_message(), "g1") + + stats = trigger.get_group_stats("g1") + assert stats["active"] is True + assert stats["total_processed"] == 2 + assert stats["message_count"] == 2 # No Tier 2 to reset + + @pytest.mark.asyncio + async def test_stats_consecutive_tier1_errors(self): + """Test consecutive Tier 1 error tracking.""" + trigger = TieredLearningTrigger() + + async def failing_op(msg, gid): + raise RuntimeError("fail") + + trigger.register_tier1("failing", failing_op) + + await trigger.process_message(_make_message(), "g1") + await trigger.process_message(_make_message(), "g1") + + stats = trigger.get_group_stats("g1") + assert stats["consecutive_tier1_errors"] == 2 + + +@pytest.mark.unit +@pytest.mark.quality +class TestBatchTriggerPolicy: + """Test BatchTriggerPolicy dataclass.""" + + def test_default_values(self): + """Test default policy values.""" + policy = BatchTriggerPolicy() + assert policy.message_threshold == 15 + assert policy.cooldown_seconds == 120.0 + + def test_custom_values(self): + """Test custom policy values.""" + policy = BatchTriggerPolicy(message_threshold=50, cooldown_seconds=600.0) + assert policy.message_threshold == 50 + assert policy.cooldown_seconds == 600.0 + + def test_policy_is_frozen(self): + """Test policy dataclass is immutable.""" + policy = BatchTriggerPolicy() + with pytest.raises(AttributeError): + policy.message_threshold = 99 From 136431ee84e46366694de5d84dadd83230c4ce56 Mon Sep 17 00:00:00 2001 From: NickMo Date: Mon, 2 Mar 2026 15:27:37 +0800 Subject: [PATCH 06/16] fix(db): disable SSL for MySQL 8 connections and harden session lifecycle - Add ssl=False to aiomysql.connect() and engine connect_args to prevent struct.unpack failures caused by MySQL 8 default TLS handshake - Wrap MySQL connection with asyncio.wait_for (15s timeout) for clearer error reporting on connection failures - Remove redundant session.close() in BaseFacade.get_session() and DomainRouter.get_session() to avoid double-close issues under StaticPool, which caused "Cannot operate on a closed database" errors - Add engine liveness guard in BaseFacade before yielding sessions --- core/database/engine.py | 1 + services/database/facades/_base.py | 14 +++- .../database/sqlalchemy_database_manager.py | 68 ++++++++++++------- 3 files changed, 54 insertions(+), 29 deletions(-) diff --git a/core/database/engine.py b/core/database/engine.py index 0208aec..cff0c54 100644 --- a/core/database/engine.py +++ b/core/database/engine.py @@ -138,6 +138,7 @@ def _create_mysql_engine(self): connect_args={ 'connect_timeout': 10, 'charset': 'utf8mb4', + 'ssl': False, } ) diff --git a/services/database/facades/_base.py b/services/database/facades/_base.py index c877aa2..ea8cf4d 100644 --- a/services/database/facades/_base.py +++ b/services/database/facades/_base.py @@ -26,13 +26,21 @@ def __init__(self, engine: DatabaseEngine, config: PluginConfig): @asynccontextmanager async def get_session(self): - """获取异步数据库会话(上下文管理器)""" + """获取异步数据库会话(上下文管理器) + + 自动处理会话的创建、提交和回滚。 + 使用 ``async with session`` 确保事务完整性, + 不再在 finally 中重复调用 close 以避免 + StaticPool 场景下的连接状态异常。 + """ + if self.engine is None or self.engine.engine is None: + raise RuntimeError("数据库引擎未初始化或已关闭") session = self.engine.get_session() try: async with session: yield session - finally: - await session.close() + except Exception: + raise @staticmethod def _row_to_dict(obj: Any, fields: Optional[List[str]] = None) -> Dict[str, Any]: diff --git a/services/database/sqlalchemy_database_manager.py b/services/database/sqlalchemy_database_manager.py index 99ec3a6..586d30f 100644 --- a/services/database/sqlalchemy_database_manager.py +++ b/services/database/sqlalchemy_database_manager.py @@ -150,37 +150,53 @@ def _get_database_url(self) -> str: return f"sqlite:///{db_path}" async def _ensure_mysql_database_exists(self): - """确保 MySQL 数据库存在""" - try: - import aiomysql - host = getattr(self.config, 'mysql_host', 'localhost') - port = getattr(self.config, 'mysql_port', 3306) - user = getattr(self.config, 'mysql_user', 'root') - password = getattr(self.config, 'mysql_password', '') - database = getattr(self.config, 'mysql_database', 'astrbot_self_learning') + """确保 MySQL 数据库存在 + + 使用 aiomysql 直连 MySQL 服务器以检查/创建目标数据库。 + 显式禁用 SSL 以避免 MySQL 8 默认 TLS 握手导致的 + struct.unpack 解包异常。 + """ + import aiomysql + host = getattr(self.config, 'mysql_host', 'localhost') + port = getattr(self.config, 'mysql_port', 3306) + user = getattr(self.config, 'mysql_user', 'root') + password = getattr(self.config, 'mysql_password', '') + database = getattr(self.config, 'mysql_database', 'astrbot_self_learning') - conn = await aiomysql.connect( - host=host, port=port, user=user, - password=password, charset='utf8mb4', + try: + conn = await asyncio.wait_for( + aiomysql.connect( + host=host, port=port, user=user, + password=password, charset='utf8mb4', + ssl=False, connect_timeout=10, + ), + timeout=15, ) - try: - async with conn.cursor() as cursor: + except asyncio.TimeoutError: + logger.error("[DomainRouter] 连接 MySQL 超时 (15s)") + raise + except Exception as e: + logger.error(f"[DomainRouter] 连接 MySQL 失败: {e}") + raise + + try: + async with conn.cursor() as cursor: + await cursor.execute( + "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = %s", + (database,), + ) + if not await cursor.fetchone(): + logger.info(f"[DomainRouter] 数据库 {database} 不存在,正在创建...") await cursor.execute( - "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = %s", - (database,), + f"CREATE DATABASE `{database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" ) - if not await cursor.fetchone(): - logger.info(f"[DomainRouter] 数据库 {database} 不存在,正在创建…") - await cursor.execute( - f"CREATE DATABASE `{database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" - ) - await conn.commit() - logger.info(f"[DomainRouter] 数据库 {database} 创建成功") - finally: - conn.close() + await conn.commit() + logger.info(f"[DomainRouter] 数据库 {database} 创建成功") except Exception as e: logger.error(f"[DomainRouter] 确保 MySQL 数据库存在失败: {e}") raise + finally: + conn.close() # Infrastructure: session @@ -206,8 +222,8 @@ async def get_session(self): try: async with session: yield session - finally: - await session.close() + except Exception: + raise # Domain delegates: AffectionFacade From cd454d66dfa0f43e3f3b7ede129cfd1a65e8b65d Mon Sep 17 00:00:00 2001 From: NickMo Date: Mon, 2 Mar 2026 15:27:53 +0800 Subject: [PATCH 07/16] fix(jargon): serialize dict/list meaning to JSON string before DB write LLM inference occasionally returns the meaning field as a dict or list instead of a plain string, causing sqlite3.ProgrammingError on UPDATE. - JargonFacade.update_jargon: convert dict/list meaning to JSON string - JargonInferenceEngine.infer_meaning: normalize meaning type from both inference passes before returning results --- services/database/facades/jargon_facade.py | 8 +++++++- services/jargon/jargon_miner.py | 19 +++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/services/database/facades/jargon_facade.py b/services/database/facades/jargon_facade.py index 21d8ce6..a7f3961 100644 --- a/services/database/facades/jargon_facade.py +++ b/services/database/facades/jargon_facade.py @@ -152,7 +152,13 @@ async def update_jargon(self, jargon_data: Dict[str, Any]) -> bool: if 'raw_content' in jargon_data: record.raw_content = jargon_data['raw_content'] if 'meaning' in jargon_data: - record.meaning = jargon_data['meaning'] + meaning_val = jargon_data['meaning'] + if isinstance(meaning_val, dict): + record.meaning = json.dumps(meaning_val, ensure_ascii=False) + elif isinstance(meaning_val, list): + record.meaning = json.dumps(meaning_val, ensure_ascii=False) + else: + record.meaning = str(meaning_val) if meaning_val is not None else None if 'is_jargon' in jargon_data: record.is_jargon = jargon_data['is_jargon'] if 'count' in jargon_data: diff --git a/services/jargon/jargon_miner.py b/services/jargon/jargon_miner.py index 331a4b9..9f85733 100644 --- a/services/jargon/jargon_miner.py +++ b/services/jargon/jargon_miner.py @@ -116,7 +116,13 @@ async def infer_meaning( logger.info(f"黑话 {content} 信息不足,等待下次推断") return {'no_info': True} - meaning1 = inference1.get('meaning', '').strip() + meaning1_raw = inference1.get('meaning', '') + if isinstance(meaning1_raw, dict): + meaning1 = json.dumps(meaning1_raw, ensure_ascii=False) + elif isinstance(meaning1_raw, list): + meaning1 = json.dumps(meaning1_raw, ensure_ascii=False) + else: + meaning1 = str(meaning1_raw).strip() if meaning1_raw else '' if not meaning1: return {'no_info': True} @@ -153,9 +159,18 @@ async def infer_meaning( is_similar = comparison.get('is_similar', False) is_jargon = not is_similar + if is_jargon: + final_meaning = meaning1 + else: + meaning2_raw = inference2.get('meaning', '') + if isinstance(meaning2_raw, (dict, list)): + final_meaning = json.dumps(meaning2_raw, ensure_ascii=False) + else: + final_meaning = str(meaning2_raw).strip() if meaning2_raw else '' + return { 'is_jargon': is_jargon, - 'meaning': meaning1 if is_jargon else inference2.get('meaning', ''), + 'meaning': final_meaning, 'no_info': False } From 0756435369983066e99a619c85bcb325949de531 Mon Sep 17 00:00:00 2001 From: NickMo Date: Mon, 2 Mar 2026 15:28:11 +0800 Subject: [PATCH 08/16] fix(state): use defensive getattr for ORM-to-dataclass component mapping Prevents AttributeError when ORM lazy-load fails or schema diverges from the expected PsychologicalStateComponent fields. Each component conversion is individually guarded with try/except and diagnostic logging. --- .../enhanced_psychological_state_manager.py | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/services/state/enhanced_psychological_state_manager.py b/services/state/enhanced_psychological_state_manager.py index a515938..71f4842 100644 --- a/services/state/enhanced_psychological_state_manager.py +++ b/services/state/enhanced_psychological_state_manager.py @@ -196,17 +196,26 @@ async def get_current_state( # 获取组件 components = await component_repo.get_components(state.id) - # 转换为 CompositePsychologicalState + # 转换 ORM 组件对象为 dataclass 实例 + # 使用 getattr 进行防御性读取,防止 ORM 属性未加载时抛出 + # AttributeError(如 lazy-load 失败或 schema 不一致) state_components = [] for comp in components: - state_components.append(PsychologicalStateComponent( - category=comp.category, - state_type=comp.state_type, - value=comp.value, - threshold=comp.threshold, - description=comp.description or "", - start_time=float(comp.start_time) if comp.start_time else time.time() - )) + try: + state_components.append(PsychologicalStateComponent( + category=getattr(comp, 'category', 'unknown'), + state_type=getattr(comp, 'state_type', 'unknown'), + value=float(getattr(comp, 'value', 0.5)), + threshold=float(getattr(comp, 'threshold', 0.3)), + description=getattr(comp, 'description', '') or "", + start_time=float(getattr(comp, 'start_time', 0)) + if getattr(comp, 'start_time', None) else time.time() + )) + except Exception as comp_err: + self._logger.warning( + f"[增强型心理状态] 转换组件失败: {comp_err}, " + f"comp_type={type(comp).__name__}" + ) composite_state = CompositePsychologicalState( group_id=group_id, From 584e6af20cc6f7515b6bbae662ffb5a78f6ae9ba Mon Sep 17 00:00:00 2001 From: NickMo Date: Mon, 2 Mar 2026 15:28:27 +0800 Subject: [PATCH 09/16] fix(webui): resolve persona review revert crash and reviewed list gaps - Guard all blueprint routes against None JSON body to prevent NoneType.get AttributeError on revert/batch operations - Add persona_learning_ and style_ ID prefixes to reviewed list entries to match the pending list format used by the frontend - Filter out records with None IDs from the reviewed list - Wrap each data source fetch in independent try/except so a single source failure does not suppress the other sources --- webui/blueprints/persona_reviews.py | 8 ++++- webui/services/persona_review_service.py | 43 ++++++++++++++++++------ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/webui/blueprints/persona_reviews.py b/webui/blueprints/persona_reviews.py index 7d07261..70f4570 100644 --- a/webui/blueprints/persona_reviews.py +++ b/webui/blueprints/persona_reviews.py @@ -37,6 +37,8 @@ async def review_persona_update(update_id: str): """审查人格更新内容 (批准/拒绝)""" try: data = await request.get_json() + if not data: + return jsonify({"error": "Request body is required"}), 400 action = data.get("action") comment = data.get("comment", "") modified_content = data.get("modified_content") @@ -84,7 +86,7 @@ async def get_reviewed_persona_updates(): async def revert_persona_update(update_id: str): """撤回人格更新审查""" try: - data = await request.get_json() + data = await request.get_json() or {} reason = data.get("reason", "撤回审查决定") container = get_container() @@ -128,6 +130,8 @@ async def batch_delete_persona_updates(): """批量删除人格更新审查记录""" try: data = await request.get_json() + if not data: + return jsonify({"error": "Request body is required"}), 400 update_ids = data.get('update_ids', []) if not update_ids or not isinstance(update_ids, list): @@ -153,6 +157,8 @@ async def batch_review_persona_updates(): """批量审查人格更新记录""" try: data = await request.get_json() + if not data: + return jsonify({"error": "Request body is required"}), 400 update_ids = data.get('update_ids', []) action = data.get('action') comment = data.get('comment', '') diff --git a/webui/services/persona_review_service.py b/webui/services/persona_review_service.py index 8e6dcd7..9144d39 100644 --- a/webui/services/persona_review_service.py +++ b/webui/services/persona_review_service.py @@ -596,25 +596,46 @@ async def get_reviewed_persona_updates( # 从传统人格更新审查获取 if self.persona_updater: - traditional_updates = await self.persona_updater.get_reviewed_persona_updates(limit, offset, status_filter) - reviewed_updates.extend(traditional_updates) + try: + traditional_updates = await self.persona_updater.get_reviewed_persona_updates(limit, offset, status_filter) + if traditional_updates: + reviewed_updates.extend(traditional_updates) + except Exception as e: + logger.warning(f"获取传统已审查人格更新失败: {e}") # 从人格学习审查获取 if self.database_manager: - persona_learning_updates = await self.database_manager.get_reviewed_persona_learning_updates(limit, offset, status_filter) - reviewed_updates.extend(persona_learning_updates) + try: + persona_learning_updates = await self.database_manager.get_reviewed_persona_learning_updates(limit, offset, status_filter) + if persona_learning_updates: + # 为人格学习记录添加前缀 ID + for update in persona_learning_updates: + if update.get('id') is not None: + update['id'] = f"persona_learning_{update['id']}" + reviewed_updates.extend(persona_learning_updates) + except Exception as e: + logger.warning(f"获取已审查人格学习更新失败: {e}") # 从风格学习审查获取 if self.database_manager: - style_updates = await self.database_manager.get_reviewed_style_learning_updates(limit, offset, status_filter) - # 将风格审查转换为统一格式 - for update in style_updates: - if 'id' in update: - update['id'] = f"style_{update['id']}" - reviewed_updates.extend(style_updates) + try: + style_updates = await self.database_manager.get_reviewed_style_learning_updates(limit, offset, status_filter) + if style_updates: + for update in style_updates: + if update.get('id') is not None: + update['id'] = f"style_{update['id']}" + reviewed_updates.extend(style_updates) + except Exception as e: + logger.warning(f"获取已审查风格学习更新失败: {e}") + + # 过滤掉无效记录(id 为 None 或空的条目) + reviewed_updates = [ + u for u in reviewed_updates + if u and u.get('id') is not None + ] # 按审查时间排序 - reviewed_updates.sort(key=lambda x: x.get('review_time', 0), reverse=True) + reviewed_updates.sort(key=lambda x: x.get('review_time') or 0, reverse=True) return { "success": True, From 68b6628f0698ecb4603b0c6687b40555c202f5c4 Mon Sep 17 00:00:00 2001 From: NickMo Date: Mon, 2 Mar 2026 15:28:41 +0800 Subject: [PATCH 10/16] fix(webui): prefer facade over direct repository for style statistics Prioritize DomainRouter.get_style_learning_statistics() facade method instead of directly instantiating StyleLearningReviewRepository, which avoids session lifecycle mismatch under concurrent dashboard requests. --- webui/services/learning_service.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/webui/services/learning_service.py b/webui/services/learning_service.py index ffbb697..7a53c48 100644 --- a/webui/services/learning_service.py +++ b/webui/services/learning_service.py @@ -40,9 +40,13 @@ async def get_style_learning_results(self) -> Dict[str, Any]: if self.db_manager: try: - # 优先使用ORM Repository获取统计数据 - if hasattr(self.db_manager, 'get_session'): - # 使用ORM方式获取统计 + # 优先使用 Facade 方法获取统计数据 + if hasattr(self.db_manager, 'get_style_learning_statistics'): + real_stats = await self.db_manager.get_style_learning_statistics() + if real_stats: + results_data['statistics'].update(real_stats) + elif hasattr(self.db_manager, 'get_session'): + # 降级到 Repository 方式 from ...repositories.learning_repository import StyleLearningReviewRepository async with self.db_manager.get_session() as session: @@ -52,11 +56,6 @@ async def get_style_learning_results(self) -> Dict[str, Any]: results_data['statistics'].update(real_stats) logger.debug(f"使用ORM获取风格学习统计: {real_stats}") - else: - # 降级到传统数据库方法 - real_stats = await self.db_manager.get_style_learning_statistics() - if real_stats: - results_data['statistics'].update(real_stats) # 获取进度数据(保持原有逻辑) real_progress = await self.db_manager.get_style_progress_data() From 0377bc15b7b96ee5039c0396f55647e632031861 Mon Sep 17 00:00:00 2001 From: NickMo Date: Mon, 2 Mar 2026 15:34:12 +0800 Subject: [PATCH 11/16] fix(db): replace SQLite StaticPool with NullPool to resolve concurrent access errors StaticPool shares a single physical connection across all async sessions, causing transaction state corruption and "Cannot operate on a closed database" errors when WebUI dashboard issues concurrent API requests. NullPool creates an independent connection per session; combined with WAL mode already in place, this allows safe concurrent reads while serializing writes. SQLite connection creation cost is negligible (file handle open), so there is no meaningful performance regression. --- core/database/engine.py | 8 +++++--- services/database/facades/_base.py | 3 +-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/core/database/engine.py b/core/database/engine.py index cff0c54..a2a99f2 100644 --- a/core/database/engine.py +++ b/core/database/engine.py @@ -8,7 +8,7 @@ 避免 "Task got Future attached to a different loop" 错误。 """ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import StaticPool +from sqlalchemy.pool import NullPool from sqlalchemy import types as sa_types from astrbot.api import logger from typing import Optional @@ -90,11 +90,13 @@ def _create_sqlite_engine(self): logger.info(f"[DatabaseEngine] 创建数据库目录: {db_dir}") # SQLite 配置 - # StaticPool reuses a single connection, avoiding per-query overhead + # NullPool: 每个 session 独立创建/关闭连接,避免 StaticPool + # 单连接共享导致的并发事务状态污染和 "closed database" 错误。 + # SQLite 建连成本极低(打开文件句柄),配合 WAL 模式可安全并发读。 engine = create_async_engine( db_url, echo=self.echo, - poolclass=StaticPool, + poolclass=NullPool, connect_args={ 'check_same_thread': False, 'timeout': 30, diff --git a/services/database/facades/_base.py b/services/database/facades/_base.py index ea8cf4d..19aea50 100644 --- a/services/database/facades/_base.py +++ b/services/database/facades/_base.py @@ -30,8 +30,7 @@ async def get_session(self): 自动处理会话的创建、提交和回滚。 使用 ``async with session`` 确保事务完整性, - 不再在 finally 中重复调用 close 以避免 - StaticPool 场景下的连接状态异常。 + 不再在 finally 中重复调用 close 以避免连接状态异常。 """ if self.engine is None or self.engine.engine is None: raise RuntimeError("数据库引擎未初始化或已关闭") From 4e9bcb80ae79999fe62013501c78f84b8246e807 Mon Sep 17 00:00:00 2001 From: NickMo Date: Mon, 2 Mar 2026 15:38:47 +0800 Subject: [PATCH 12/16] fix(commands): guard all command handlers against uninitialized services Add null checks for _command_handlers in all 6 command wrappers in main.py to prevent NoneType attribute errors when bootstrap() fails. Add service availability checks in affection_status (affection_manager) and set_mood (temporary_persona_updater, affection_manager) handlers to return user-friendly messages instead of crashing. --- main.py | 18 ++++++++++++++++++ services/commands/handlers.py | 12 ++++++++++++ 2 files changed, 30 insertions(+) diff --git a/main.py b/main.py index de11aa2..3fe3071 100644 --- a/main.py +++ b/main.py @@ -231,6 +231,9 @@ async def inject_diversity_to_llm_request(self, event: AstrMessageEvent, req=Non @filter.permission_type(PermissionType.ADMIN) async def learning_status_command(self, event: AstrMessageEvent): """查看学习状态""" + if not self._command_handlers: + yield event.plain_result("插件服务未就绪,请检查启动日志") + return async for result in self._command_handlers.learning_status(event): yield result @@ -238,6 +241,9 @@ async def learning_status_command(self, event: AstrMessageEvent): @filter.permission_type(PermissionType.ADMIN) async def start_learning_command(self, event: AstrMessageEvent): """手动启动学习""" + if not self._command_handlers: + yield event.plain_result("插件服务未就绪,请检查启动日志") + return async for result in self._command_handlers.start_learning(event): yield result @@ -245,6 +251,9 @@ async def start_learning_command(self, event: AstrMessageEvent): @filter.permission_type(PermissionType.ADMIN) async def stop_learning_command(self, event: AstrMessageEvent): """停止学习""" + if not self._command_handlers: + yield event.plain_result("插件服务未就绪,请检查启动日志") + return async for result in self._command_handlers.stop_learning(event): yield result @@ -252,6 +261,9 @@ async def stop_learning_command(self, event: AstrMessageEvent): @filter.permission_type(PermissionType.ADMIN) async def force_learning_command(self, event: AstrMessageEvent): """强制执行一次学习周期""" + if not self._command_handlers: + yield event.plain_result("插件服务未就绪,请检查启动日志") + return async for result in self._command_handlers.force_learning(event): yield result @@ -259,6 +271,9 @@ async def force_learning_command(self, event: AstrMessageEvent): @filter.permission_type(PermissionType.ADMIN) async def affection_status_command(self, event: AstrMessageEvent): """查看好感度状态""" + if not self._command_handlers: + yield event.plain_result("插件服务未就绪,请检查启动日志") + return async for result in self._command_handlers.affection_status(event): yield result @@ -266,5 +281,8 @@ async def affection_status_command(self, event: AstrMessageEvent): @filter.permission_type(PermissionType.ADMIN) async def set_mood_command(self, event: AstrMessageEvent): """手动设置bot情绪""" + if not self._command_handlers: + yield event.plain_result("插件服务未就绪,请检查启动日志") + return async for result in self._command_handlers.set_mood(event): yield result diff --git a/services/commands/handlers.py b/services/commands/handlers.py index 102d52a..19b3a54 100644 --- a/services/commands/handlers.py +++ b/services/commands/handlers.py @@ -257,6 +257,10 @@ async def affection_status(self, event: Any) -> AsyncGenerator: yield event.plain_result(CommandMessages.AFFECTION_DISABLED) return + if not self._affection_manager: + yield event.plain_result("好感度管理器未初始化,请检查启动日志") + return + affection_status = await self._affection_manager.get_affection_status(group_id) current_mood = None @@ -324,6 +328,14 @@ async def set_mood(self, event: Any) -> AsyncGenerator: yield event.plain_result(CommandMessages.AFFECTION_DISABLED) return + if not self._temporary_persona_updater: + yield event.plain_result("临时人格更新器未初始化,无法设置情绪") + return + + if not self._affection_manager: + yield event.plain_result("好感度管理器未初始化,无法设置情绪") + return + args = event.get_message_str().split()[1:] if len(args) < 1: yield event.plain_result( From a3bf381b9622084959078a40d82a54499adc62d7 Mon Sep 17 00:00:00 2001 From: NickMo Date: Mon, 2 Mar 2026 15:46:16 +0800 Subject: [PATCH 13/16] fix(webui): remove gc.collect() calls that cause CPU spike during plugin unload Explicit gc.collect() in both Server.stop() and WebUIManager.stop() forces Python to traverse the entire object graph of the unloading plugin (~200 modules, circular ORM references). Each call takes 80+ seconds of 100% CPU. Python's cyclic GC handles cleanup automatically on its own schedule; forcing it during shutdown is unnecessary. --- webui/manager.py | 2 -- webui/server.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/webui/manager.py b/webui/manager.py index f0bd910..5b78bca 100644 --- a/webui/manager.py +++ b/webui/manager.py @@ -1,6 +1,5 @@ """WebUI 服务器全生命周期管理 — 创建、启动、停止、服务注册""" import asyncio -import gc import sys from typing import Optional, Any, Dict, TYPE_CHECKING @@ -181,7 +180,6 @@ async def stop(self) -> None: try: logger.info(f"正在停止 Web 服务器 (端口: {_server_instance.port})...") await _server_instance.stop() - gc.collect() if sys.platform == "win32": logger.info("Windows 环境:等待端口资源释放...") diff --git a/webui/server.py b/webui/server.py index cd0b107..ccfc747 100644 --- a/webui/server.py +++ b/webui/server.py @@ -4,7 +4,6 @@ """ import os import sys -import gc import asyncio import socket import threading @@ -192,7 +191,6 @@ async def stop(self): Server._instance = None self._initialized = False - gc.collect() logger.info("[WebUI] 服务器已停止") except Exception as e: From c5d623b0854851c53ab44158616db2425da006cf Mon Sep 17 00:00:00 2001 From: NickMo Date: Mon, 2 Mar 2026 15:55:02 +0800 Subject: [PATCH 14/16] fix(webui): map style learning fields to unified format in reviewed list StyleLearningReview ORM uses different field names (type, few_shots_content, description) than the frontend expects (update_type, proposed_content, confidence_score). The pending review path already transforms these fields, but the reviewed history path passed raw facade data through unchanged, causing empty content, "unknown" type, and 0% confidence display. --- webui/services/persona_review_service.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/webui/services/persona_review_service.py b/webui/services/persona_review_service.py index 9144d39..89e7ee7 100644 --- a/webui/services/persona_review_service.py +++ b/webui/services/persona_review_service.py @@ -622,8 +622,15 @@ async def get_reviewed_persona_updates( style_updates = await self.database_manager.get_reviewed_style_learning_updates(limit, offset, status_filter) if style_updates: for update in style_updates: - if update.get('id') is not None: - update['id'] = f"style_{update['id']}" + # 转换风格学习字段为前端统一格式 + update['id'] = f"style_{update['id']}" if update.get('id') is not None else None + update['update_type'] = update.get('type', UPDATE_TYPE_STYLE_LEARNING) + update['original_content'] = update.get('original_content', '') + update['new_content'] = update.get('few_shots_content', '') + update['proposed_content'] = update.get('few_shots_content', '') + update['confidence_score'] = update.get('confidence_score', 0.9) + update['reason'] = update.get('description', '') + update['review_source'] = 'style_learning' reviewed_updates.extend(style_updates) except Exception as e: logger.warning(f"获取已审查风格学习更新失败: {e}") From aa71e62aa129f7854cbfec44fc7b4704b481e8b8 Mon Sep 17 00:00:00 2001 From: NickMo Date: Mon, 2 Mar 2026 15:58:31 +0800 Subject: [PATCH 15/16] chore(release): bump version to Next-2.0.6 and update changelog --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- README_EN.md | 2 +- metadata.yaml | 2 +- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c52cb5..b052273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ 所有重要更改都将记录在此文件中。 +## [Next-2.0.6] - 2026-03-02 + +### 新功能 + +#### WebUI 监听地址可配置 +- 新增 `web_interface_host` 配置项,允许用户自定义 WebUI 监听地址(默认 `0.0.0.0`) + +### Bug 修复 + +#### SQLite 并发访问错误 +- 修复 SQLite 连接池使用 `StaticPool`(共享单连接)导致 WebUI 并发请求事务状态污染的问题 +- 改为 `NullPool`,每个会话获取独立连接,消除 "Cannot operate on a closed database" 错误 + +#### 插件卸载 CPU 100% +- 移除 WebUI 关停流程中 `server.py` 和 `manager.py` 的两次 `gc.collect()` 调用 +- 每次 `gc.collect()` 遍历 ~200 个模块的对象图耗时 80+ 秒,导致卸载期间 CPU 满载 + +#### 命令处理器空指针 +- 为全部 6 个管理命令(`learning_status`、`start_learning`、`stop_learning`、`force_learning`、`affection_status`、`set_mood`)添加空值守卫 +- 当 `bootstrap()` 失败导致 `_command_handlers` 为 `None` 时,返回友好提示而非抛出 `'NoneType' object has no attribute` 异常 + +#### 人格审查系统 +- 修复撤回操作崩溃和已审查列表数据缺失问题 +- 修复风格学习审查记录在已审查历史中显示空内容、类型"未知"、置信度 0.0% 的问题,补全 `StyleLearningReview` 到前端统一格式的字段映射 +- WebUI 风格统计查询改用 Facade 而非直接 Repository 调用 + +#### MySQL 8 连接 +- 禁用 MySQL 8 默认 SSL 要求,解决 `ssl.SSLError` 连接失败 +- 强化会话生命周期管理 + +#### ORM 字段映射 +- 修正心理状态和情绪持久化的 ORM 字段映射 +- 使用防御性 `getattr` 处理 ORM-to-dataclass 组件映射中的缺失属性 + +#### 其他修复 +- WebUI 使用全局默认人格代替随机 UMO +- WebUI 响应速度指标无 LLM 数据时使用中性回退值 +- 黑话 meaning 字段 dict/list 类型序列化为 JSON 字符串后写入数据库 +- 批量学习路径中正确保存筛选后的消息到数据库 +- 防护 `background_tasks` 在关停序列中的访问安全 + +### 测试 +- 新增核心模块单元测试,扩展覆盖率配置 + ## [Next-2.0.5] - 2026-02-24 ### Bug 修复 diff --git a/README.md b/README.md index 5464e80..c2fc134 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@
-[![Version](https://img.shields.io/badge/version-Next--2.0.0-blue.svg)](https://github.com/NickCharlie/astrbot_plugin_self_learning) [![License](https://img.shields.io/badge/license-GPLv3-green.svg)](LICENSE) [![AstrBot](https://img.shields.io/badge/AstrBot-%3E%3D4.11.4-orange.svg)](https://github.com/Soulter/AstrBot) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) +[![Version](https://img.shields.io/badge/version-Next--2.0.6-blue.svg)](https://github.com/NickCharlie/astrbot_plugin_self_learning) [![License](https://img.shields.io/badge/license-GPLv3-green.svg)](LICENSE) [![AstrBot](https://img.shields.io/badge/AstrBot-%3E%3D4.11.4-orange.svg)](https://github.com/Soulter/AstrBot) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) [核心功能](#-我们能做什么) · [快速开始](#-快速开始) · [管理界面](#-可视化管理界面) · [社区交流](#-社区交流) · [贡献指南](CONTRIBUTING.md) diff --git a/README_EN.md b/README_EN.md index 250354c..1a993ec 100644 --- a/README_EN.md +++ b/README_EN.md @@ -14,7 +14,7 @@
-[![Version](https://img.shields.io/badge/version-Next--2.0.0-blue.svg)](https://github.com/NickCharlie/astrbot_plugin_self_learning) [![License](https://img.shields.io/badge/license-GPLv3-green.svg)](LICENSE) [![AstrBot](https://img.shields.io/badge/AstrBot-%3E%3D4.11.4-orange.svg)](https://github.com/Soulter/AstrBot) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) +[![Version](https://img.shields.io/badge/version-Next--2.0.6-blue.svg)](https://github.com/NickCharlie/astrbot_plugin_self_learning) [![License](https://img.shields.io/badge/license-GPLv3-green.svg)](LICENSE) [![AstrBot](https://img.shields.io/badge/AstrBot-%3E%3D4.11.4-orange.svg)](https://github.com/Soulter/AstrBot) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) [Features](#what-we-can-do) · [Quick Start](#quick-start) · [Web UI](#visual-management-interface) · [Community](#community) · [Contributing](CONTRIBUTING.md) diff --git a/metadata.yaml b/metadata.yaml index 5634dc3..9070d2d 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -2,7 +2,7 @@ name: "astrbot_plugin_self_learning" author: "NickMo" display_name: "self-learning" description: "SELF LEARNING 自主学习插件 — 让 AI 聊天机器人自主学习对话风格、理解群组黑话、管理社交关系与好感度、自适应人格演化,像真人一样自然对话。(使用前必须手动备份人格数据)" -version: "Next-2.0.5" +version: "Next-2.0.6" repo: "https://github.com/NickCharlie/astrbot_plugin_self_learning" tags: - "自学习" From f43537517062bd474d4faca82bc5de600e02e6b1 Mon Sep 17 00:00:00 2001 From: NickMo Date: Mon, 2 Mar 2026 16:01:52 +0800 Subject: [PATCH 16/16] docs: sync sponsorship section and QR code from main --- README.md | 8 ++++++++ image/afdian-NickMo.jpeg | Bin 0 -> 74316 bytes 2 files changed, 8 insertions(+) create mode 100644 image/afdian-NickMo.jpeg diff --git a/README.md b/README.md index c2fc134..6af5a80 100644 --- a/README.md +++ b/README.md @@ -229,10 +229,18 @@ http://localhost:7833 --- +--- +
**如果觉得有帮助,欢迎 Star 支持!** +### 赞助支持 + +如果这个项目对你有帮助,欢迎通过爱发电赞助支持开发者持续维护: + +爱发电赞助二维码 + [回到顶部](#astrbot-自主学习插件)
diff --git a/image/afdian-NickMo.jpeg b/image/afdian-NickMo.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..31523873f20081729a374c541f2644181dc79195 GIT binary patch literal 74316 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%0DM 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 fcw$jewH=U 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

=4ka=<}q=<#qx(GKhpiv>OkTfBMOIAmF?b^CBeP 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 H@2UR*$q<_a literal 0 HcmV?d00001