From 1b287366efa9b8854e62efead7325253c67b746e Mon Sep 17 00:00:00 2001 From: Yurzi Date: Wed, 10 Jun 2026 21:18:04 +0800 Subject: [PATCH 1/2] fix: wire metrics service to existing methods The big architecture refactor (e46b634) left MetricsService calling methods that no longer exist: - intelligence_metrics_service.calculate_metrics() - database_manager.get_diversity_metrics() - database_manager.get_affection_metrics() Each call raised AttributeError on every request, spamming error logs and making the three WebUI metrics endpoints always return zeroed fallback data. Rewire the three methods to the real APIs: - intelligence: gather inputs via get_detailed_metrics / get_all_user_affections, then call calculate_learning_efficiency and map LearningEfficiencyMetrics to {overall_score, dimensions, trends} - affection: compute from get_all_user_affections (average, user count, high/low counts, distribution buckets) - diversity: best-effort estimate from style patterns, no error spam Co-Authored-By: Claude Opus 4.8 --- webui/services/metrics_service.py | 122 +++++++++++++++++++++++++----- 1 file changed, 104 insertions(+), 18 deletions(-) diff --git a/webui/services/metrics_service.py b/webui/services/metrics_service.py index 8dc32b6d..76112bda 100644 --- a/webui/services/metrics_service.py +++ b/webui/services/metrics_service.py @@ -39,11 +39,54 @@ async def get_intelligence_metrics(self, group_id: str = 'default') -> Dict[str, } try: - metrics = await self.intelligence_metrics_service.calculate_metrics(group_id) - return metrics if metrics else { - 'overall_score': 0, - 'dimensions': {}, - 'trends': [] + # 从数据库收集学习效率计算所需的输入指标 + total_messages = 0 + filtered_messages = 0 + style_patterns = 0 + persona_updates = 0 + affection_users = 0 + + db = self.database_manager + if db: + try: + detailed = await db.get_detailed_metrics(group_id) + if isinstance(detailed, dict): + msgs = detailed.get('messages', {}) or {} + learning = detailed.get('learning', {}) or {} + total_messages = int(msgs.get('raw', 0) or 0) + filtered_messages = int(msgs.get('filtered', 0) or 0) + style_patterns = int(learning.get('style_patterns', 0) or 0) + persona_updates = int(learning.get('persona_reviews', 0) or 0) + except Exception as e: + logger.warning(f"获取详细指标失败: {e}") + + try: + affections = await db.get_all_user_affections(group_id) + affection_users = len(affections) if affections else 0 + except Exception as e: + logger.warning(f"获取好感度用户数失败: {e}") + + metrics = await self.intelligence_metrics_service.calculate_learning_efficiency( + total_messages=total_messages, + filtered_messages=filtered_messages, + style_patterns_learned=style_patterns, + persona_updates_count=persona_updates, + affection_users_count=affection_users, + ) + + return { + 'overall_score': round(metrics.overall_efficiency, 1), + 'dimensions': { + 'message_filter_rate': round(metrics.message_filter_rate, 1), + 'content_refine_quality': round(metrics.content_refine_quality, 1), + 'style_learning_progress': round(metrics.style_learning_progress, 1), + 'persona_update_quality': round(metrics.persona_update_quality, 1), + 'jargon_learning_score': round(metrics.jargon_learning_score, 1), + 'social_relation_score': round(metrics.social_relation_score, 1), + 'affection_score': round(metrics.affection_score, 1), + 'active_strategies_count': metrics.active_strategies_count, + }, + 'trends': [], } except Exception as e: logger.error(f"获取智能指标失败: {e}", exc_info=True) @@ -73,12 +116,25 @@ async def get_diversity_metrics(self, group_id: str = 'default') -> Dict[str, An } try: - diversity = await self.database_manager.get_diversity_metrics(group_id) - return diversity if diversity else { - 'vocabulary_diversity': 0, - 'topic_diversity': 0, - 'style_diversity': 0, - 'total_score': 0 + # 当前架构没有独立的多样性数据源,基于已学到的风格模式做近似估算 + style_patterns = 0 + detailed = await self.database_manager.get_detailed_metrics(group_id) + if isinstance(detailed, dict): + learning = detailed.get('learning', {}) or {} + style_patterns = int(learning.get('style_patterns', 0) or 0) + + style_diversity = min(style_patterns * 2, 100) + vocabulary_diversity = 0 + topic_diversity = 0 + total_score = round( + (style_diversity + vocabulary_diversity + topic_diversity) / 3, 1 + ) + + return { + 'vocabulary_diversity': vocabulary_diversity, + 'topic_diversity': topic_diversity, + 'style_diversity': style_diversity, + 'total_score': total_score, } except Exception as e: logger.error(f"获取多样性指标失败: {e}", exc_info=True) @@ -110,13 +166,43 @@ async def get_affection_metrics(self, group_id: str = 'default') -> Dict[str, An } try: - affection = await self.database_manager.get_affection_metrics(group_id) - return affection if affection else { - 'average_affection': 0, - 'total_users': 0, - 'high_affection_count': 0, - 'low_affection_count': 0, - 'distribution': [] + affections = await self.database_manager.get_all_user_affections(group_id) + affections = affections or [] + + levels = [] + for item in affections: + try: + levels.append(int(item.get('affection_level', 0) or 0)) + except (TypeError, ValueError): + continue + + total_users = len(levels) + average_affection = round(sum(levels) / total_users, 1) if total_users else 0 + high_affection_count = sum(1 for lvl in levels if lvl >= 70) + low_affection_count = sum(1 for lvl in levels if lvl <= 30) + + # 好感度分布 (0-20, 21-40, 41-60, 61-80, 81-100) + buckets = [ + ('0-20', 0, 20), + ('21-40', 21, 40), + ('41-60', 41, 60), + ('61-80', 61, 80), + ('81-100', 81, 100), + ] + distribution = [ + { + 'range': label, + 'count': sum(1 for lvl in levels if low <= lvl <= high), + } + for label, low, high in buckets + ] + + return { + 'average_affection': average_affection, + 'total_users': total_users, + 'high_affection_count': high_affection_count, + 'low_affection_count': low_affection_count, + 'distribution': distribution, } except Exception as e: logger.error(f"获取好感度指标失败: {e}", exc_info=True) From 0c18eaff5553c13b7c5aa99084d8c02816a854a6 Mon Sep 17 00:00:00 2001 From: Yurzi Date: Wed, 10 Jun 2026 21:25:10 +0800 Subject: [PATCH 2/2] test: cover MetricsService endpoints with real API Update the stale conftest mocks that mirrored the removed methods (get_diversity_metrics / get_affection_metrics / calculate_metrics) to the real API now used by MetricsService (get_detailed_metrics / get_all_user_affections / calculate_learning_efficiency). Add integration tests for /api/intelligence_metrics, /api/affection_metrics and /api/diversity_metrics asserting they return real computed data with no error key, guarding against the AttributeError regression. Co-Authored-By: Claude Opus 4.8 --- tests/conftest.py | 51 ++++---- .../test_metrics_service_endpoints.py | 118 ++++++++++++++++++ 2 files changed, 147 insertions(+), 22 deletions(-) create mode 100644 tests/integration/test_metrics_service_endpoints.py diff --git a/tests/conftest.py b/tests/conftest.py index 9ff82479..317c13e5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ """ import pytest import asyncio +from types import SimpleNamespace from typing import Dict, Any, Optional from unittest.mock import Mock, AsyncMock, MagicMock from datetime import datetime @@ -114,20 +115,23 @@ def mock_database_manager(): manager.get_chat_message_detail = AsyncMock(return_value=None) manager.delete_chat_message = AsyncMock(return_value=True) - # Metrics methods - manager.get_diversity_metrics = AsyncMock(return_value={ - 'vocabulary_diversity': 0.5, - 'topic_diversity': 0.6, - 'style_diversity': 0.7, - 'total_score': 0.6 - }) - manager.get_affection_metrics = AsyncMock(return_value={ - 'average_affection': 50, - 'total_users': 10, - 'high_affection_count': 3, - 'low_affection_count': 2, - 'distribution': [] + # Metrics methods (real API used by MetricsService after refactor) + manager.get_detailed_metrics = AsyncMock(return_value={ + 'messages': {'raw': 100, 'filtered': 40, 'bot': 10}, + 'learning': { + 'persona_reviews': 4, + 'style_reviews': 3, + 'batches': 2, + 'style_patterns': 6, + }, + 'group_id': 'default', }) + manager.get_all_user_affections = AsyncMock(return_value=[ + {'user_id': 'u1', 'affection_level': 80}, + {'user_id': 'u2', 'affection_level': 50}, + {'user_id': 'u3', 'affection_level': 20}, + ]) + manager.get_total_affection = AsyncMock(return_value=150) return manager @@ -178,15 +182,18 @@ def mock_intelligence_metrics_service(): """Mock IntelligenceMetricsService""" service = AsyncMock() - service.calculate_metrics = AsyncMock(return_value={ - 'overall_score': 75, - 'dimensions': { - 'coherence': 80, - 'relevance': 75, - 'creativity': 70 - }, - 'trends': [] - }) + # Real API: returns a LearningEfficiencyMetrics-like object + service.calculate_learning_efficiency = AsyncMock(return_value=SimpleNamespace( + overall_efficiency=72.5, + message_filter_rate=40.0, + content_refine_quality=0.0, + style_learning_progress=60.0, + persona_update_quality=50.0, + jargon_learning_score=0.0, + social_relation_score=0.0, + affection_score=30.0, + active_strategies_count=0, + )) return service diff --git a/tests/integration/test_metrics_service_endpoints.py b/tests/integration/test_metrics_service_endpoints.py new file mode 100644 index 00000000..2666de1d --- /dev/null +++ b/tests/integration/test_metrics_service_endpoints.py @@ -0,0 +1,118 @@ +"""Integration tests for MetricsService-backed metrics endpoints. + +These guard against the regression where MetricsService called methods that no +longer existed after the architecture refactor (calculate_metrics / +get_diversity_metrics / get_affection_metrics), making the endpoints always +return zeroed fallback data with a logged AttributeError. +""" + +from types import SimpleNamespace + +import pytest +from quart import Quart + +import webui.blueprints.metrics as metrics_module +from webui.blueprints.metrics import metrics_bp + + +class DummyDatabaseManager: + async def get_detailed_metrics(self, group_id=None): + return { + "messages": {"raw": 100, "filtered": 40, "bot": 10}, + "learning": { + "persona_reviews": 4, + "style_reviews": 3, + "batches": 2, + "style_patterns": 6, + }, + "group_id": group_id, + } + + async def get_all_user_affections(self, group_id): + return [ + {"user_id": "u1", "affection_level": 80}, + {"user_id": "u2", "affection_level": 50}, + {"user_id": "u3", "affection_level": 20}, + ] + + +class DummyIntelligenceMetricsService: + async def calculate_learning_efficiency(self, **kwargs): + self.last_kwargs = kwargs + return SimpleNamespace( + overall_efficiency=72.5, + message_filter_rate=40.0, + content_refine_quality=0.0, + style_learning_progress=60.0, + persona_update_quality=50.0, + jargon_learning_score=0.0, + social_relation_score=0.0, + affection_score=30.0, + active_strategies_count=0, + ) + + +@pytest.fixture +async def app(monkeypatch): + app = Quart(__name__) + app.config["TESTING"] = True + app.secret_key = "test-secret-key" + + container = SimpleNamespace( + database_manager=DummyDatabaseManager(), + intelligence_metrics_service=DummyIntelligenceMetricsService(), + ) + monkeypatch.setattr(metrics_module, "get_container", lambda: container) + + app.register_blueprint(metrics_bp) + yield app + + +@pytest.fixture +async def client(app): + return app.test_client() + + +@pytest.mark.asyncio +async def test_intelligence_metrics_uses_learning_efficiency(client): + response = await client.get("/api/intelligence_metrics?group_id=g1") + + assert response.status_code == 200 + data = await response.get_json() + + assert "error" not in data + assert data["overall_score"] == 72.5 + assert data["dimensions"]["message_filter_rate"] == 40.0 + assert data["dimensions"]["affection_score"] == 30.0 + assert data["dimensions"]["active_strategies_count"] == 0 + assert data["trends"] == [] + + +@pytest.mark.asyncio +async def test_affection_metrics_computed_from_user_affections(client): + response = await client.get("/api/affection_metrics?group_id=g1") + + assert response.status_code == 200 + data = await response.get_json() + + assert "error" not in data + assert data["total_users"] == 3 + assert data["average_affection"] == 50.0 + assert data["high_affection_count"] == 1 # level 80 >= 70 + assert data["low_affection_count"] == 1 # level 20 <= 30 + # buckets partition every user exactly once + assert sum(b["count"] for b in data["distribution"]) == 3 + + +@pytest.mark.asyncio +async def test_diversity_metrics_estimated_from_style_patterns(client): + response = await client.get("/api/diversity_metrics?group_id=g1") + + assert response.status_code == 200 + data = await response.get_json() + + assert "error" not in data + assert data["style_diversity"] == 12 # 6 patterns * 2 + assert data["vocabulary_diversity"] == 0 + assert data["topic_diversity"] == 0 + assert data["total_score"] == 4.0 # (12 + 0 + 0) / 3