from __future__ import annotations import ast import hashlib import unittest from pathlib import Path import llm_stream import mentor_agent from backend.features.mentor import agent as canonical_agent from backend.llm import stream as canonical_stream APP_ROOT = Path(__file__).resolve().parents[1] ORIGINAL_ROOT = APP_ROOT.parent MENTOR_SERVICE_METHODS = { "mentor_setup", "save_mentor_preferences", "mentor_stream", "mentor_messages", "clear_mentor_messages", "_validate_mentor_history", "_build_mentor_context", "_mentor_market_matrix", } LLM_SERVICE_METHODS = { "_personal_llm_profile", "_platform_llm_profile", "_profile_configured", "_resolved_llm_profile", "llm_primary_api_key", "llm_primary_base_url", "llm_primary_model", "llm_fallback_api_key", "llm_fallback_base_url", "llm_fallback_model", "llm_source", "llm_configured", "llm_fallback_configured", "save_llm_settings", "save_llm_mode", "test_llm_profile", "_validate_llm_profile", "llm_access_status", "_platform_usage_today", "_platform_usage_today_for_user", "test_system_llm_profile", } MENTOR_REPOSITORY_METHODS = { "save_mentor_exchange", "list_mentor_messages", "delete_mentor_messages", "list_mentor_preferences", "save_mentor_preferences", } LLM_REPOSITORY_METHODS = {"record_llm_usage", "count_llm_usage_since"} def sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def class_methods(path: Path, class_name: str) -> dict[str, str]: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) owner = next( node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == class_name ) return { node.name: ast.dump(node, include_attributes=False) for node in owner.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) } def assignments(path: Path, names: set[str]) -> dict[str, str]: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) result = {} for node in tree.body: if not isinstance(node, ast.Assign) or len(node.targets) != 1: continue target = node.targets[0] if isinstance(target, ast.Name) and target.id in names: result[target.id] = ast.dump(node.value, include_attributes=False) return result class MentorLLMSliceSourceEquivalenceTests(unittest.TestCase): def assert_methods_equal( self, original_path: Path, original_class: str, migrated_path: Path, migrated_class: str, expected: set[str], ) -> None: original = class_methods(original_path, original_class) migrated = class_methods(migrated_path, migrated_class) self.assertEqual(set(migrated), expected) for name in sorted(expected): self.assertEqual(migrated[name], original[name], name) def test_mentor_agent_uses_shared_transport_and_stream_accumulator_is_exact(self) -> None: mentor_source = (APP_ROOT / "backend" / "features" / "mentor" / "agent.py").read_text( encoding="utf-8" ) self.assertIn("llm_transport.stream_chat_completion", mentor_source) self.assertNotIn("urllib.request", mentor_source) self.assertEqual( sha256(ORIGINAL_ROOT / "llm_stream.py"), sha256(APP_ROOT / "backend" / "llm" / "stream.py"), ) def test_compatibility_modules_are_canonical_module_objects(self) -> None: self.assertIs(mentor_agent, canonical_agent) self.assertIs(llm_stream, canonical_stream) def test_mentor_service_methods_are_exact_original_ast(self) -> None: self.assert_methods_equal( ORIGINAL_ROOT / "server.py", "DashboardService", APP_ROOT / "backend" / "features" / "mentor" / "service.py", "MentorServiceMixin", MENTOR_SERVICE_METHODS, ) def test_llm_service_methods_are_exact_original_ast(self) -> None: original = class_methods(ORIGINAL_ROOT / "server.py", "DashboardService") migrated = class_methods( APP_ROOT / "backend" / "llm" / "service.py", "LLMServiceMixin" ) self.assertEqual(set(migrated), LLM_SERVICE_METHODS) adapted = {"_platform_usage_today", "_platform_usage_today_for_user"} for name in sorted(LLM_SERVICE_METHODS - adapted): self.assertEqual(migrated[name], original[name], name) source = (APP_ROOT / "backend" / "llm" / "service.py").read_text( encoding="utf-8" ) self.assertIn( "return self._platform_usage_today_for_user(self.current_user_id)", source ) self.assertIn("def _platform_usage_today_for_user(self, user_id: int)", source) def test_mentor_and_llm_repositories_are_exact_original_ast(self) -> None: self.assert_methods_equal( ORIGINAL_ROOT / "database.py", "ReviewDatabase", APP_ROOT / "backend" / "features" / "mentor" / "repository.py", "MentorRepositoryMixin", MENTOR_REPOSITORY_METHODS, ) self.assert_methods_equal( ORIGINAL_ROOT / "database.py", "ReviewDatabase", APP_ROOT / "backend" / "llm" / "repository.py", "LLMAuditRepositoryMixin", LLM_REPOSITORY_METHODS, ) def test_mentor_data_profiles_are_exact_original_values(self) -> None: names = {"MENTOR_DATA_PROFILES", "MENTOR_INDEX_UNIVERSE", "MENTOR_ETF_UNIVERSE"} self.assertEqual( assignments(ORIGINAL_ROOT / "server.py", names), assignments( APP_ROOT / "backend" / "features" / "mentor" / "service.py", names, ), ) def test_original_classes_no_longer_duplicate_moved_methods(self) -> None: remaining_service = class_methods( APP_ROOT / "backend" / "application.py", "DashboardService" ) remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase") remaining_http = class_methods( APP_ROOT / "backend" / "application.py", "RequestHandler" ) self.assertTrue(MENTOR_SERVICE_METHODS.isdisjoint(remaining_service)) self.assertTrue(LLM_SERVICE_METHODS.isdisjoint(remaining_service)) self.assertTrue(MENTOR_REPOSITORY_METHODS.isdisjoint(remaining_database)) self.assertTrue(LLM_REPOSITORY_METHODS.isdisjoint(remaining_database)) self.assertTrue( {"stream_mentor_chat", "save_llm_settings", "save_llm_mode", "test_llm_settings"} .isdisjoint(remaining_http) ) def test_http_mixins_preserve_stream_and_model_endpoints(self) -> None: mentor_http = class_methods( APP_ROOT / "backend" / "features" / "mentor" / "http.py", "MentorHttpMixin", ) llm_http = class_methods(APP_ROOT / "backend" / "llm" / "http.py", "LLMHttpMixin") self.assertEqual(set(mentor_http), {"stream_mentor_chat"}) self.assertEqual( set(llm_http), {"save_llm_settings", "save_llm_mode", "test_llm_settings"} ) if __name__ == "__main__": unittest.main()