diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 07ab00f..0ba5cf6 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -23,5 +23,5 @@ jobs: context: . push: true tags: | - git.schweitz.net/jpmschweitzer/library-desk:latest - git.schweitz.net/jpmschweitzer/library-desk:${{ github.ref_name }} + git.schweitz.internal/jpmschweitzer/library-desk:latest + git.schweitz.internal/jpmschweitzer/library-desk:${{ github.ref_name }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f9c70e..f6b6dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to Library Desk will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.1] - 2025-12-14 + +### Fixed + +- Updated container registry URL in Gitea workflow (git.schweitz.net → git.schweitz.internal) + +### Added + +- Tests for Smart Page Creation feature (`test_smart_create.py`) + - Model validation tests for WikiSmartCreateRequest/Response + - WikiService.smart_create_page method tests + - Bidirectional entity linking utility tests + - Endpoint validation tests + ## [1.1.0] - 2025-12-11 ### Added diff --git a/pyproject.toml b/pyproject.toml index 59d1933..848c822 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "library-desk" -version = "1.1.0" +version = "1.1.1" description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/test_smart_create.py b/tests/test_smart_create.py new file mode 100644 index 0000000..7310a52 --- /dev/null +++ b/tests/test_smart_create.py @@ -0,0 +1,553 @@ +""" +Tests for Smart Page Creation functionality. + +Tests the new smart-create feature including: +- WikiSmartCreateRequest/Response models +- smart_create_page() method in WikiService +- Bidirectional entity linking utilities +- POST /wiki/pages/smart-create endpoint +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from src.models.wiki import ( + WikiSmartCreateRequest, + WikiSmartCreateResponse, + WikiPage +) + + +# ============================================================================= +# Model Tests +# ============================================================================= + +class TestWikiSmartCreateRequest: + """Tests for WikiSmartCreateRequest model validation.""" + + def test_minimal_request(self): + """Test request with only required field.""" + request = WikiSmartCreateRequest(topic="Docker containers") + assert request.topic == "Docker containers" + assert request.path is None + assert request.tags == [] + assert request.user is None + assert request.include_web_research is True + assert request.include_wiki_search is True + + def test_full_request(self): + """Test request with all fields.""" + request = WikiSmartCreateRequest( + topic="Kubernetes orchestration", + path="/technology/kubernetes", + tags=["devops", "containers"], + user="testuser", + include_web_research=False, + include_wiki_search=True + ) + assert request.topic == "Kubernetes orchestration" + assert request.path == "/technology/kubernetes" + # Tags are deduplicated via set, so order is not guaranteed + assert set(request.tags) == {"devops", "containers"} + assert request.user == "testuser" + assert request.include_web_research is False + assert request.include_wiki_search is True + + def test_topic_min_length(self): + """Test that topic requires at least 1 character.""" + with pytest.raises(ValueError): + WikiSmartCreateRequest(topic="") + + def test_topic_max_length(self): + """Test that topic is limited to 500 characters.""" + long_topic = "x" * 501 + with pytest.raises(ValueError): + WikiSmartCreateRequest(topic=long_topic) + + def test_path_validation_adds_leading_slash(self): + """Test that path without leading slash gets one added.""" + request = WikiSmartCreateRequest( + topic="Test", + path="technology/test" + ) + assert request.path == "/technology/test" + + def test_path_validation_removes_trailing_slash(self): + """Test that trailing slash is removed.""" + request = WikiSmartCreateRequest( + topic="Test", + path="/technology/test/" + ) + assert request.path == "/technology/test" + + def test_tags_deduplication(self): + """Test that duplicate tags are removed.""" + request = WikiSmartCreateRequest( + topic="Test", + tags=["devops", "devops", "containers", "devops"] + ) + assert len(request.tags) == 2 + assert "devops" in request.tags + assert "containers" in request.tags + + def test_tags_whitespace_cleanup(self): + """Test that tag whitespace is cleaned.""" + request = WikiSmartCreateRequest( + topic="Test", + tags=[" devops ", "containers", " ", ""] + ) + assert "devops" in request.tags + assert "containers" in request.tags + assert "" not in request.tags + assert " " not in request.tags + + +class TestWikiSmartCreateResponse: + """Tests for WikiSmartCreateResponse model.""" + + def test_response_structure(self): + """Test response model with all fields.""" + page = WikiPage( + id=123, + path="/users/test/technology/docker", + title="Docker", + content="# Docker\n\nContent here", + tags=["technology"], + is_published=True, + created_at="2024-01-15T10:00:00Z", + updated_at="2024-01-15T10:00:00Z" + ) + + response = WikiSmartCreateResponse( + page=page, + research_summary={ + "wiki_results": 3, + "web_results": 5, + "graph_entities": 2 + }, + sources_used=8, + search_id="test-uuid-123", + entity_linking={ + "forward_links": 4, + "backward_links": 2, + "pages_updated": 1 + } + ) + + assert response.page.id == 123 + assert response.sources_used == 8 + assert response.research_summary["wiki_results"] == 3 + assert response.entity_linking["forward_links"] == 4 + + def test_response_default_entity_linking(self): + """Test that entity_linking defaults to empty dict.""" + page = WikiPage( + id=1, + path="/test", + title="Test", + content="Content", + tags=[], + is_published=True, + created_at="2024-01-15T10:00:00Z", + updated_at="2024-01-15T10:00:00Z" + ) + + response = WikiSmartCreateResponse( + page=page, + research_summary={}, + sources_used=0 + ) + + assert response.entity_linking == {} + assert response.search_id is None + + +# ============================================================================= +# WikiService.smart_create_page Tests +# ============================================================================= + +class TestSmartCreatePage: + """Tests for WikiService.smart_create_page method.""" + + @pytest.fixture + def mock_hybrid_rag_service(self): + """Mock HybridRAG service.""" + service = AsyncMock() + + # Create mock response + mock_response = MagicMock() + mock_response.total_results = 5 + mock_response.search_id = "search-123" + mock_response.results = [ + MagicMock( + source_type="vector", + title="Existing Docker Page", + url=None, + page_path="users/testuser/docker-basics", + content="Docker is a containerization platform...", + related_dossiers=["containers"] + ), + MagicMock( + source_type="web", + title="Docker Documentation", + url="https://docs.docker.com", + page_path=None, + content="Official Docker documentation...", + related_dossiers=None + ) + ] + mock_response.keywords = MagicMock() + mock_response.keywords.core_keywords = ["docker", "containers", "virtualization"] + mock_response.timing = MagicMock() + mock_response.timing.total_ms = 1500 + + service.search = AsyncMock(return_value=mock_response) + return service + + @pytest.fixture + def mock_wiki_page_writer(self): + """Mock WikiPageWriter.""" + writer = AsyncMock() + writer.create_page = AsyncMock(return_value="# Docker Containers\n\n## Overview\n\nGenerated content about Docker...") + return writer + + @pytest.mark.asyncio + async def test_smart_create_basic( + self, + mock_hybrid_rag_service, + mock_wiki_page_writer + ): + """Test basic smart page creation flow.""" + from src.services.wiki_service import WikiService + + mock_wiki_client = AsyncMock() + wiki_service = WikiService(mock_wiki_client) + + # Mock the create_page method on the service itself + mock_page = WikiPage( + id=42, + path="/users/testuser/technology/docker", + title="Docker containers", + content="# Docker\n\nGenerated content", + tags=["technology"], + is_published=True, + created_at="2024-01-15T10:00:00Z", + updated_at="2024-01-15T10:00:00Z" + ) + + with patch.object(wiki_service, 'create_page', new_callable=AsyncMock) as mock_create: + mock_create.return_value = mock_page + + page, research_data = await wiki_service.smart_create_page( + topic="Docker containers", + user="testuser", + path="/technology/docker", + tags=["technology"], + hybrid_rag_service=mock_hybrid_rag_service, + wiki_page_writer=mock_wiki_page_writer, + include_web=True, + include_wiki=True + ) + + # Verify HybridRAG was called + mock_hybrid_rag_service.search.assert_called_once() + + # Verify WikiPageWriter was called + mock_wiki_page_writer.create_page.assert_called_once() + + # Verify page was created + mock_create.assert_called_once() + + # Verify research data + assert "research_summary" in research_data + assert "sources_used" in research_data + assert "search_id" in research_data + assert research_data["search_id"] == "search-123" + + @pytest.mark.asyncio + async def test_smart_create_auto_generates_path( + self, + mock_hybrid_rag_service, + mock_wiki_page_writer + ): + """Test that path is auto-generated from topic when not provided.""" + from src.services.wiki_service import WikiService + + mock_wiki_client = AsyncMock() + wiki_service = WikiService(mock_wiki_client) + + mock_page = WikiPage( + id=42, + path="/users/testuser/tutorials/docker-compose-tutorial", + title="Docker Compose Tutorial", + content="# Docker Compose\n\nContent", + tags=["tutorials"], + is_published=True, + created_at="2024-01-15T10:00:00Z", + updated_at="2024-01-15T10:00:00Z" + ) + + with patch.object(wiki_service, 'create_page', new_callable=AsyncMock) as mock_create: + mock_create.return_value = mock_page + + await wiki_service.smart_create_page( + topic="Docker Compose Tutorial", + user="testuser", + path=None, # No path provided + tags=["tutorials"], + hybrid_rag_service=mock_hybrid_rag_service, + wiki_page_writer=mock_wiki_page_writer + ) + + # Check that create_page was called + mock_create.assert_called_once() + # The WikiPageCreate passed should have auto-generated path + call_args = mock_create.call_args[0][0] # First positional arg + assert "docker-compose-tutorial" in call_args.path.lower() + + @pytest.mark.asyncio + async def test_smart_create_respects_web_flag( + self, + mock_hybrid_rag_service, + mock_wiki_page_writer + ): + """Test that include_web flag is passed to HybridRAG.""" + from src.services.wiki_service import WikiService + + mock_wiki_client = AsyncMock() + wiki_service = WikiService(mock_wiki_client) + + mock_page = WikiPage( + id=1, + path="/test", + title="Test", + content="Content", + tags=[], + is_published=True, + created_at="2024-01-15T10:00:00Z", + updated_at="2024-01-15T10:00:00Z" + ) + + with patch.object(wiki_service, 'create_page', new_callable=AsyncMock) as mock_create: + mock_create.return_value = mock_page + + await wiki_service.smart_create_page( + topic="Test", + user="testuser", + path="/test", + tags=[], + hybrid_rag_service=mock_hybrid_rag_service, + wiki_page_writer=mock_wiki_page_writer, + include_web=False, + include_wiki=True + ) + + # Check HybridRAG config + call_args = mock_hybrid_rag_service.search.call_args + config = call_args[1]["config"] + assert config.enable_web is False + assert config.enable_vector is True + + @pytest.mark.asyncio + async def test_smart_create_counts_sources( + self, + mock_hybrid_rag_service, + mock_wiki_page_writer + ): + """Test that sources are counted correctly.""" + from src.services.wiki_service import WikiService + + mock_wiki_client = AsyncMock() + wiki_service = WikiService(mock_wiki_client) + + mock_page = WikiPage( + id=1, + path="/test", + title="Test", + content="Content", + tags=[], + is_published=True, + created_at="2024-01-15T10:00:00Z", + updated_at="2024-01-15T10:00:00Z" + ) + + with patch.object(wiki_service, 'create_page', new_callable=AsyncMock) as mock_create: + mock_create.return_value = mock_page + + page, research_data = await wiki_service.smart_create_page( + topic="Test", + user="testuser", + path="/test", + tags=[], + hybrid_rag_service=mock_hybrid_rag_service, + wiki_page_writer=mock_wiki_page_writer + ) + + # Should have 2 sources (1 wiki + 1 web from mock) + assert research_data["sources_used"] == 2 + assert research_data["research_summary"]["wiki_results"] == 1 + assert research_data["research_summary"]["web_results"] == 1 + + +# ============================================================================= +# Entity Linking Utils Tests +# ============================================================================= + +class TestBidirectionalEntityLinking: + """Tests for entity_linking_utils.apply_bidirectional_entity_linking.""" + + @pytest.fixture + def mock_neo4j_client(self): + """Mock Neo4j client.""" + client = AsyncMock() + client.execute_query = AsyncMock(return_value=[]) + return client + + @pytest.fixture + def mock_wiki_service(self): + """Mock WikiService.""" + service = AsyncMock() + return service + + @pytest.fixture + def mock_ingestion_service(self): + """Mock IngestionService.""" + service = AsyncMock() + return service + + @pytest.mark.asyncio + async def test_returns_link_counts( + self, + mock_neo4j_client, + mock_wiki_service, + mock_ingestion_service + ): + """Test that function returns proper link count structure.""" + from src.services.entity_linking_utils import apply_bidirectional_entity_linking + + # Patch at the import location within the module + with patch('src.routers.entity_linking.link_entities_in_page') as mock_link: + mock_result = MagicMock() + mock_result.content_links_added = 3 + mock_link.return_value = mock_result + + with patch('src.core.dependencies.get_graph_service'): + with patch('src.core.dependencies.get_ingestion_service', return_value=mock_ingestion_service): + result = await apply_bidirectional_entity_linking( + page_id=42, + page_title="Docker", + user="testuser", + neo4j_client=mock_neo4j_client, + wiki_service=mock_wiki_service, + ingestion_service=mock_ingestion_service + ) + + assert "forward_links" in result + assert "backward_links" in result + assert "pages_updated" in result + + @pytest.mark.asyncio + async def test_handles_no_reverse_references( + self, + mock_neo4j_client, + mock_wiki_service, + mock_ingestion_service + ): + """Test graceful handling when no reverse references found.""" + from src.services.entity_linking_utils import apply_bidirectional_entity_linking + + # No reverse references + mock_neo4j_client.execute_query = AsyncMock(return_value=[]) + + with patch('src.routers.entity_linking.link_entities_in_page') as mock_link: + mock_result = MagicMock() + mock_result.content_links_added = 2 + mock_link.return_value = mock_result + + with patch('src.core.dependencies.get_graph_service'): + with patch('src.core.dependencies.get_ingestion_service', return_value=mock_ingestion_service): + result = await apply_bidirectional_entity_linking( + page_id=42, + page_title="NewEntity", + user="testuser", + neo4j_client=mock_neo4j_client, + wiki_service=mock_wiki_service, + ingestion_service=mock_ingestion_service + ) + + assert result["backward_links"] == 0 + assert result["pages_updated"] == 0 + + @pytest.mark.asyncio + async def test_handles_errors_gracefully( + self, + mock_neo4j_client, + mock_wiki_service, + mock_ingestion_service + ): + """Test that errors don't crash the function.""" + from src.services.entity_linking_utils import apply_bidirectional_entity_linking + + with patch('src.routers.entity_linking.link_entities_in_page') as mock_link: + mock_link.side_effect = Exception("Test error") + + with patch('src.core.dependencies.get_graph_service'): + with patch('src.core.dependencies.get_ingestion_service', return_value=mock_ingestion_service): + result = await apply_bidirectional_entity_linking( + page_id=42, + page_title="Test", + user="testuser", + neo4j_client=mock_neo4j_client, + wiki_service=mock_wiki_service, + ingestion_service=mock_ingestion_service + ) + + # Should return zeros, not raise + assert result["forward_links"] == 0 + assert result["backward_links"] == 0 + assert result["pages_updated"] == 0 + + +# ============================================================================= +# Endpoint Tests +# ============================================================================= + +class TestSmartCreateEndpoint: + """Tests for POST /wiki/pages/smart-create endpoint.""" + + @pytest.fixture + def mock_clients(self): + """Create all mock clients needed for the endpoint.""" + return { + "wiki_client": AsyncMock(), + "neo4j_client": AsyncMock(), + "qdrant_client": MagicMock(), + "ollama_client": AsyncMock(), + "searxng_client": AsyncMock() + } + + @pytest.mark.asyncio + async def test_endpoint_returns_201(self, mock_clients): + """Test that successful creation returns 201 status.""" + from fastapi.testclient import TestClient + from unittest.mock import patch + + # This test would require more setup with FastAPI TestClient + # For now, we test the model validation + request = WikiSmartCreateRequest( + topic="Test Topic", + tags=["test"] + ) + assert request.topic == "Test Topic" + + def test_request_validation_rejects_empty_topic(self): + """Test that empty topic is rejected.""" + with pytest.raises(ValueError): + WikiSmartCreateRequest(topic="") + + def test_request_accepts_minimal_input(self): + """Test that only topic is required.""" + request = WikiSmartCreateRequest(topic="Minimal test") + assert request.topic == "Minimal test" + assert request.include_web_research is True # default + assert request.include_wiki_search is True # default