""" Tests for Knowledge Lifecycle Engine. Covers deduplication, archival, analytics, and consolidation. """ import pytest import tempfile import json import uuid from pathlib import Path from datetime import datetime, timedelta from unittest.mock import patch, MagicMock import numpy as np from app.core.sqlite_manager import SQLiteManager from app.core.kb_types import KBType @pytest.fixture def lifecycle_db(): """Create a clean SQLite database with lifecycle support.""" with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as temp_file: temp_db_path = temp_file.name manager = SQLiteManager(temp_db_path) yield manager if Path(temp_db_path).exists(): Path(temp_db_path).unlink() @pytest.fixture def populated_kb(lifecycle_db): """Create a KB with sample documents and embeddings.""" kb_name = "test-lifecycle-kb" lifecycle_db.create_collection(kb_name, KBType.GENERIC, description="Test KB") # Create documents with embeddings # Doc 1 and 2 are near-duplicates (similar embeddings) # Doc 3 is different np.random.seed(42) base_vec = np.random.randn(768).astype(np.float32) base_vec = base_vec / np.linalg.norm(base_vec) # Near-duplicate of base (very small perturbation for high similarity) similar_vec = base_vec + np.random.randn(768).astype(np.float32) * 0.01 similar_vec = similar_vec / np.linalg.norm(similar_vec) # Different vector different_vec = np.random.randn(768).astype(np.float32) different_vec = different_vec / np.linalg.norm(different_vec) docs = ["Authentication using JWT tokens for API security", "JWT token authentication pattern for securing APIs", "Redis caching strategy for database query optimization"] embeddings = [base_vec.tolist(), similar_vec.tolist(), different_vec.tolist()] metadatas = [ {"filename": "auth-jwt.md", "upload_date": datetime.now().isoformat()}, {"filename": "jwt-auth.md", "upload_date": datetime.now().isoformat()}, {"filename": "redis-cache.md", "upload_date": (datetime.now() - timedelta(days=120)).isoformat()} ] ids = ["doc-auth-1", "doc-auth-2", "doc-redis-1"] lifecycle_db.add_documents(kb_name, docs, embeddings, metadatas, ids) return kb_name, lifecycle_db @pytest.fixture def engine(populated_kb): """Create a KnowledgeLifecycleEngine with populated KB.""" kb_name, db = populated_kb # Patch the singleton to use our test DB with patch("app.core.knowledge_lifecycle.get_sqlite_manager", return_value=db): from app.core.knowledge_lifecycle import KnowledgeLifecycleEngine e = KnowledgeLifecycleEngine() e.db = db yield e, kb_name, db @pytest.mark.unit class TestDeduplication: """Test duplicate scanning and merging.""" def test_scan_empty_kb(self, lifecycle_db): """Scan with no documents returns empty results.""" kb_name = "empty-kb" lifecycle_db.create_collection(kb_name, KBType.GENERIC) with patch("app.core.knowledge_lifecycle.get_sqlite_manager", return_value=lifecycle_db): from app.core.knowledge_lifecycle import KnowledgeLifecycleEngine engine = KnowledgeLifecycleEngine() engine.db = lifecycle_db result = engine.scan_duplicates(kb_name) assert result["total_documents"] == 0 assert result["duplicate_pairs"] == [] assert result["clusters"] == [] assert result["duplicate_density"] == 0.0 def test_identical_vectors_detected(self, engine): """Near-duplicate documents are detected above threshold.""" e, kb_name, _ = engine result = e.scan_duplicates(kb_name, threshold=0.85) assert result["total_documents"] == 3 assert len(result["duplicate_pairs"]) >= 1 # The auth docs should be paired pair = result["duplicate_pairs"][0] doc_ids = {pair["doc_a"], pair["doc_b"]} assert "doc-auth-1" in doc_ids or "doc-auth-2" in doc_ids assert pair["similarity"] >= 0.85 def test_below_threshold_ignored(self, engine): """Documents below threshold are not flagged.""" e, kb_name, _ = engine result = e.scan_duplicates(kb_name, threshold=0.99) # At 0.99 threshold, only truly identical docs match # Our similar_vec has noise so shouldn't match at 0.99 for pair in result["duplicate_pairs"]: assert pair["similarity"] >= 0.99 def test_union_find_clustering(self, engine): """Duplicate pairs are clustered correctly.""" e, kb_name, _ = engine result = e.scan_duplicates(kb_name, threshold=0.85) if result["clusters"]: cluster = result["clusters"][0] assert "cluster_id" in cluster assert "doc_ids" in cluster assert cluster["size"] >= 2 def test_merge_dry_run(self, engine): """Dry run merge previews without deleting.""" e, kb_name, db = engine result = e.merge_duplicates( kb_name, keep_doc_id="doc-auth-1", remove_doc_ids=["doc-auth-2"], dry_run=True ) assert result["dry_run"] is True assert result["keep_doc_id"] == "doc-auth-1" assert "doc-auth-2" in result["would_remove"] # Verify doc still exists docs = db.get_all_embeddings(kb_name) doc_ids = [d["doc_id"] for d in docs] assert "doc-auth-2" in doc_ids def test_merge_actual(self, engine): """Actual merge deletes duplicate documents.""" e, kb_name, db = engine result = e.merge_duplicates( kb_name, keep_doc_id="doc-auth-1", remove_doc_ids=["doc-auth-2"], dry_run=False ) assert result["dry_run"] is False assert result["deleted_count"] == 1 # Verify doc was deleted docs = db.get_all_embeddings(kb_name) doc_ids = [d["doc_id"] for d in docs] assert "doc-auth-1" in doc_ids assert "doc-auth-2" not in doc_ids @pytest.mark.unit class TestArchival: """Test document archival and restoration.""" def test_archive_sets_flag(self, engine): """Archive sets archived flag in metadata.""" e, kb_name, db = engine result = e.archive_documents(kb_name, ["doc-auth-1"], reason="test") assert result["archived_count"] == 1 docs = db.get_all_embeddings(kb_name, exclude_archived=False) doc = next(d for d in docs if d["doc_id"] == "doc-auth-1") assert doc["metadata"]["archived"] is True assert doc["metadata"]["archive_reason"] == "test" assert "archived_at" in doc["metadata"] def test_restore_clears_flag(self, engine): """Restore clears archived flag.""" e, kb_name, db = engine # Archive first e.archive_documents(kb_name, ["doc-auth-1"]) # Then restore result = e.restore_documents(kb_name, ["doc-auth-1"]) assert result["restored_count"] == 1 docs = db.get_all_embeddings(kb_name, exclude_archived=False) doc = next(d for d in docs if d["doc_id"] == "doc-auth-1") assert doc["metadata"].get("archived") is False def test_exclude_archived_from_embeddings(self, engine): """Archived docs are excluded when exclude_archived=True.""" e, kb_name, db = engine e.archive_documents(kb_name, ["doc-auth-1"]) # With exclude docs_filtered = db.get_all_embeddings(kb_name, exclude_archived=True) doc_ids_filtered = [d["doc_id"] for d in docs_filtered] assert "doc-auth-1" not in doc_ids_filtered # Without exclude docs_all = db.get_all_embeddings(kb_name, exclude_archived=False) doc_ids_all = [d["doc_id"] for d in docs_all] assert "doc-auth-1" in doc_ids_all def test_find_stale(self, engine): """Find stale documents correctly identifies old docs.""" e, kb_name, _ = engine with patch("app.core.knowledge_lifecycle.get_documents_metadata") as mock_docs: mock_docs.return_value = [ {"filename": "old.md", "upload_date": (datetime.now() - timedelta(days=120)).isoformat()}, {"filename": "new.md", "upload_date": datetime.now().isoformat()} ] result = e.find_stale_documents(kb_name, stale_days=90) assert result["stale_count"] == 1 assert result["stale_documents"][0]["filename"] == "old.md" assert result["stale_documents"][0]["age_days"] >= 119 def test_list_archived(self, engine): """List archived returns only archived docs.""" e, kb_name, _ = engine # Archive one doc e.archive_documents(kb_name, ["doc-redis-1"], reason="stale") result = e.list_archived(kb_name) assert result["archived_count"] == 1 assert result["archived_documents"][0]["doc_id"] == "doc-redis-1" assert result["archived_documents"][0]["archive_reason"] == "stale" @pytest.mark.unit class TestAnalytics: """Test health reports and growth timeline.""" def test_health_report_structure(self, engine): """Health report contains all expected sections.""" e, kb_name, _ = engine with patch("app.core.knowledge_lifecycle.get_collection_stats") as mock_stats, \ patch("app.core.knowledge_lifecycle.get_documents_metadata") as mock_docs: mock_stats.return_value = { "total_documents": 3, "total_chunks": 3, "last_updated": datetime.now().isoformat() } mock_docs.return_value = [ {"filename": "a.md", "upload_date": datetime.now().isoformat()}, {"filename": "b.md", "upload_date": datetime.now().isoformat()}, {"filename": "c.md", "upload_date": (datetime.now() - timedelta(days=100)).isoformat()} ] report = e.get_health_report(kb_name) assert "kb_name" in report assert "stats" in report assert "embedding_coverage" in report assert "top_similar_pairs" in report assert "age_distribution" in report assert "recommendations" in report assert report["embedding_coverage"]["total_docs"] == 3 assert report["embedding_coverage"]["with_embeddings"] == 3 def test_growth_timeline_grouping(self, engine): """Growth timeline groups documents by period.""" e, kb_name, _ = engine with patch("app.core.knowledge_lifecycle.get_documents_metadata") as mock_docs: now = datetime.now() mock_docs.return_value = [ {"filename": "a.md", "upload_date": now.isoformat()}, {"filename": "b.md", "upload_date": now.isoformat()}, {"filename": "c.md", "upload_date": (now - timedelta(days=35)).isoformat()} ] result = e.get_growth_timeline(kb_name, granularity="month") assert result["kb_name"] == kb_name assert result["granularity"] == "month" assert result["total_documents"] == 3 assert len(result["timeline"]) >= 1 # Last entry should have cumulative total = 3 assert result["timeline"][-1]["total"] == 3 def test_density_calculation(self, engine): """Duplicate density is calculated correctly.""" e, kb_name, _ = engine result = e.scan_duplicates(kb_name, threshold=0.85) # density = pairs / (n*(n-1)/2) where n=3 => 3 possible pairs assert 0 <= result["duplicate_density"] <= 1.0 @pytest.mark.unit class TestLifecycleLog: """Test lifecycle audit logging.""" def test_insert_and_query_log(self, lifecycle_db): """Insert and retrieve lifecycle logs.""" kb_name = "test-log-kb" log_id = lifecycle_db.insert_lifecycle_log( kb_name=kb_name, operation_type="dedup_scan", status="completed", input_doc_ids=["doc-1", "doc-2"], details={"pairs_found": 5} ) assert log_id > 0 logs = lifecycle_db.get_lifecycle_logs(kb_name) assert len(logs) == 1 assert logs[0]["operation_type"] == "dedup_scan" assert logs[0]["status"] == "completed" assert logs[0]["input_doc_ids"] == ["doc-1", "doc-2"] assert logs[0]["details"]["pairs_found"] == 5 def test_update_log_status(self, lifecycle_db): """Update lifecycle log status and completion.""" log_id = lifecycle_db.insert_lifecycle_log( kb_name="test-kb", operation_type="consolidate", status="pending" ) success = lifecycle_db.update_lifecycle_log( log_id, "completed", output_doc_ids=["new-doc-1"], details={"merged": 3} ) assert success logs = lifecycle_db.get_lifecycle_logs("test-kb") assert logs[0]["status"] == "completed" assert logs[0]["output_doc_ids"] == ["new-doc-1"] assert logs[0]["completed_at"] is not None def test_filter_by_operation_type(self, lifecycle_db): """Filter logs by operation type.""" lifecycle_db.insert_lifecycle_log("kb1", "dedup_scan", "completed") lifecycle_db.insert_lifecycle_log("kb1", "archive", "completed") lifecycle_db.insert_lifecycle_log("kb1", "dedup_merge", "completed") dedup_logs = lifecycle_db.get_lifecycle_logs("kb1", operation_type="dedup_scan") assert len(dedup_logs) == 1 all_logs = lifecycle_db.get_lifecycle_logs("kb1") assert len(all_logs) == 3 @pytest.mark.unit class TestSQLiteManagerLifecycle: """Test the new SQLiteManager methods for lifecycle operations.""" def test_get_all_embeddings(self, lifecycle_db): """get_all_embeddings returns docs with parsed embeddings.""" kb_name = "test-emb-kb" lifecycle_db.create_collection(kb_name, KBType.GENERIC) vec = np.random.randn(768).astype(np.float32) lifecycle_db.add_documents( kb_name, documents=["Test content"], embeddings=[vec.tolist()], metadatas=[{"filename": "test.md"}], ids=["doc-1"] ) results = lifecycle_db.get_all_embeddings(kb_name) assert len(results) == 1 assert results[0]["doc_id"] == "doc-1" assert results[0]["embedding"] is not None assert len(results[0]["embedding"]) == 768 def test_update_document_metadata(self, lifecycle_db): """update_document_metadata merges new keys into existing metadata.""" kb_name = "test-meta-kb" lifecycle_db.create_collection(kb_name, KBType.GENERIC) vec = np.random.randn(768).astype(np.float32) lifecycle_db.add_documents( kb_name, documents=["Test content"], embeddings=[vec.tolist()], metadatas=[{"filename": "test.md", "tags": ["a"]}], ids=["doc-1"] ) success = lifecycle_db.update_document_metadata( kb_name, "doc-1", {"archived": True, "new_key": "value"} ) assert success docs = lifecycle_db.get_all_embeddings(kb_name, exclude_archived=False) doc = docs[0] assert doc["metadata"]["archived"] is True assert doc["metadata"]["new_key"] == "value" assert doc["metadata"]["filename"] == "test.md" # original preserved def test_delete_documents_by_ids(self, lifecycle_db): """delete_documents_by_ids removes specified documents.""" kb_name = "test-delete-kb" lifecycle_db.create_collection(kb_name, KBType.GENERIC) vec = np.random.randn(768).astype(np.float32) lifecycle_db.add_documents( kb_name, documents=["Doc 1", "Doc 2", "Doc 3"], embeddings=[vec.tolist(), vec.tolist(), vec.tolist()], metadatas=[{"filename": "1.md"}, {"filename": "2.md"}, {"filename": "3.md"}], ids=["doc-1", "doc-2", "doc-3"] ) deleted = lifecycle_db.delete_documents_by_ids(kb_name, ["doc-1", "doc-3"]) assert deleted == 2 remaining = lifecycle_db.get_all_embeddings(kb_name) assert len(remaining) == 1 assert remaining[0]["doc_id"] == "doc-2" @pytest.mark.integration class TestConsolidation: """Integration tests for consolidation (requires Ollama).""" def test_consolidate_dry_run(self, engine): """Dry run shows preview without making changes.""" e, kb_name, db = engine result = e.consolidate_related( kb_name, doc_ids=["doc-auth-1", "doc-auth-2"], new_filename="consolidated-auth.md", dry_run=True ) assert result["dry_run"] is True assert result["source_count"] == 2 assert result["total_chars"] > 0 # Verify originals still exist docs = db.get_all_embeddings(kb_name) doc_ids = [d["doc_id"] for d in docs] assert "doc-auth-1" in doc_ids assert "doc-auth-2" in doc_ids def test_consolidate_insufficient_docs(self, engine): """Consolidation with <2 docs returns error.""" e, kb_name, _ = engine result = e.consolidate_related( kb_name, doc_ids=["doc-auth-1"], new_filename="single.md" ) assert "error" in result