from __future__ import annotations import asyncio import json from types import SimpleNamespace import httpx import pytest from app.gateway import llmwiki_index_scheduler from deerflow.config.llmwiki_config import LocalWikiEmbeddingConfig, LocalWikiIndexConfig from deerflow.integrations.weknora.local_index import sync as wiki_sync from deerflow.integrations.weknora.local_index.embedding import ( StrictWikiEmbeddingClient, WikiEmbeddingError, ) from deerflow.integrations.weknora.local_index.normalize import normalize_wiki_page_links from deerflow.integrations.weknora.local_index.sync import WikiSyncLeaseLost, WikiSyncService from deerflow.integrations.weknora.local_index.vector_cache import WikiVectorCache from deerflow.persistence.llmwiki_index.memory import MemoryLlmWikiIndexStore class FakeEmbedding: dimensions = 2 fingerprint = "fp" def __init__(self) -> None: self.calls = 0 async def embed_texts(self, texts: list[str]) -> list[list[float]]: self.calls += 1 return [[1.0, 0.0] for _ in texts] class FailingEmbedding(FakeEmbedding): async def embed_texts(self, texts: list[str]) -> list[list[float]]: raise RuntimeError("embedding unavailable") class UnavailableProviderEmbedding(FakeEmbedding): async def embed_texts(self, texts: list[str]) -> list[list[float]]: self.calls += 1 raise WikiEmbeddingError( "connection refused", category="request", retryable=True, ) class RecoverableProviderEmbedding(FakeEmbedding): def __init__(self) -> None: super().__init__() self.available = False async def embed_texts(self, texts: list[str]) -> list[list[float]]: self.calls += 1 if not self.available: raise WikiEmbeddingError( "connection refused", category="request", retryable=True, ) return [[1.0, 0.0] for _ in texts] class LeaseStealingEmbedding(FakeEmbedding): def __init__(self, store: MemoryLlmWikiIndexStore) -> None: super().__init__() self.store = store async def embed_texts(self, texts: list[str]) -> list[list[float]]: state = self.store.states["kb"] state["lease_owner"] = "replacement-worker" state["active_sync_id"] = "replacement-sync" return await super().embed_texts(texts) class LeaseStealingFailingEmbedding(LeaseStealingEmbedding): async def embed_texts(self, texts: list[str]) -> list[list[float]]: await super().embed_texts(texts) raise RuntimeError("embedding failed after lease takeover") class NewFingerprintEmbedding(FakeEmbedding): fingerprint = "fp-new" class FakeWeKnora: def __init__(self) -> None: self.pages = { "one": { "id": "p1", "slug": "one", "title": "One", "summary": "A long enough summary for indexing and retrieval.", "content": "# Intro\nA long enough Wiki paragraph for indexing and semantic retrieval.", "status": "published", "version": 1, }, } async def get_knowledge_base(self, knowledge_base_id: str): return {"id": knowledge_base_id, "indexing_strategy": {"wiki_enabled": True}} async def list_wiki_pages(self, knowledge_base_id: str, *, page: int, page_size: int): return { "pages": [{"slug": item["slug"], "title": item["title"]} for item in self.pages.values()], "total": len(self.pages), "total_pages": 1, } async def get_wiki_page(self, knowledge_base_id: str, slug: str): return dict(self.pages[slug]) class ManyPageWeKnora(FakeWeKnora): def __init__(self, page_count: int = 50) -> None: super().__init__() self.pages = { f"page-{index}": { "id": f"p{index}", "slug": f"page-{index}", "title": f"Page {index}", "summary": "A long enough summary for indexing and retrieval.", "content": "# Intro\nA long enough Wiki paragraph for indexing and semantic retrieval.", "status": "published", "version": 1, } for index in range(page_count) } self.detail_calls = 0 self.knowledge_base_calls = 0 async def get_knowledge_base(self, knowledge_base_id: str): self.knowledge_base_calls += 1 return await super().get_knowledge_base(knowledge_base_id) async def get_wiki_page(self, knowledge_base_id: str, slug: str): self.detail_calls += 1 return await super().get_wiki_page(knowledge_base_id, slug) class LinkedFakeWeKnora(FakeWeKnora): def __init__(self) -> None: super().__init__() self.pages["one"].update( { "out_links": [{"slug": "two"}], "content": "# Intro\nOne links to [[Two Page|the second page]].", } ) self.pages["two"] = { "id": "p2", "slug": "two", "title": "Two Page", "summary": "A second page with enough text for the local index.", "content": "# Details\nThe second page contains connected evidence.", "inLinks": ["one"], "status": "published", "version": 1, } async def get_wiki_graph(self, knowledge_base_id: str, *, limit: int): return { "nodes": [ {"id": "node-one", "slug": "one", "title": "One"}, {"id": "node-two", "slug": "two", "title": "Two Page"}, ], "edges": [{"source": "node-one", "target": "node-two"}], } class LeaseStealingGraphWeKnora(FakeWeKnora): def __init__(self, store: MemoryLlmWikiIndexStore) -> None: super().__init__() self.store = store async def get_wiki_graph(self, knowledge_base_id: str, *, limit: int): del knowledge_base_id, limit state = self.store.states["kb"] state["lease_owner"] = "replacement-worker" state["active_sync_id"] = "replacement-sync" return {"nodes": [], "edges": []} def test_normalize_wiki_page_links_handles_provider_shapes_and_markdown() -> None: links = normalize_wiki_page_links( { "inLinks": [{"page_slug": "source/a#overview"}], "out_links": ["target/b", {"path": "target/c"}], "content": "See [[Target D#details|details]] and [[target/b]].", } ) assert links == { "in_links": ["source/a"], "out_links": ["Target D", "target/b", "target/c"], } @pytest.mark.asyncio async def test_configured_dimensions_are_validated_but_not_sent_to_bge() -> None: requests: list[dict] = [] def handler(request: httpx.Request) -> httpx.Response: payload = json.loads(request.content) requests.append(payload) if "dimensions" in payload: return httpx.Response(400, json={"detail": "dimensions is not supported"}) return httpx.Response( 200, json={"data": [{"index": 0, "embedding": [1.0, 0.0, 0.0]}]}, ) config = LocalWikiEmbeddingConfig( model="bge-embedding-m3", base_url="http://embedding.test/v1", dimensions=3, max_retries=0, ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: client = StrictWikiEmbeddingClient(config, http_client=http_client) vector = await client.embed_query("Wiki page") assert vector == [1.0, 0.0, 0.0] assert requests == [{"model": "bge-embedding-m3", "input": ["Wiki page"]}] @pytest.mark.asyncio async def test_embedding_error_includes_upstream_reason(caplog) -> None: def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(400, json={"detail": "unsupported request parameter: dimensions"}) config = LocalWikiEmbeddingConfig( model="bge-embedding-m3", base_url="http://embedding.test/v1", max_retries=0, ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: client = StrictWikiEmbeddingClient(config, http_client=http_client) with pytest.raises(RuntimeError, match="unsupported request parameter: dimensions"): await client.embed_query("Wiki page") assert "unsupported request parameter: dimensions" in caplog.text @pytest.mark.asyncio async def test_omitted_dimensions_are_discovered_from_first_response() -> None: def handler(request: httpx.Request) -> httpx.Response: return httpx.Response( 200, json={"data": [{"index": 0, "embedding": [0.0, 1.0]}]}, ) config = LocalWikiEmbeddingConfig( model="bge-embedding-m3", base_url="http://embedding.test/v1", dimensions=None, max_retries=0, ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http_client: client = StrictWikiEmbeddingClient(config, http_client=http_client) await client.embed_query("dimension probe") assert client.dimensions == 2 @pytest.mark.asyncio async def test_provider_failure_aborts_remaining_pages_and_opens_backoff( monkeypatch: pytest.MonkeyPatch, ) -> None: now = 0.0 monkeypatch.setattr(wiki_sync, "monotonic", lambda: now) config = LocalWikiIndexConfig(chunk_min_chars=10, min_similarity=0) store = MemoryLlmWikiIndexStore() embedding = UnavailableProviderEmbedding() client = ManyPageWeKnora() service = WikiSyncService( config, store, embedding, WikiVectorCache(store), client, ) mapping = {"id": "kb", "weknora_id": "remote", "name": "Wiki"} with pytest.raises(WikiEmbeddingError, match="connection refused"): await service.sync_mapping(mapping) # The provider client's own retry policy is paid for one page only. The # remaining 49 pages are not fetched or embedded after a shared provider # failure. assert embedding.calls == 1 assert client.detail_calls == 1 assert store.states["kb"]["state"] == "failed" assert store.states["kb"]["last_sync_result"]["failure_scope"] == "embedding_provider" assert store.states["kb"]["last_sync_result"]["retry_after_seconds"] == 60 backoff = await service.sync_mapping(mapping) assert backoff == { "mapping_id": "kb", "status": "provider_backoff", "failure_scope": "embedding_provider", "failure_count": 1, "retry_after_seconds": 60, "error": "WikiEmbeddingError: connection refused", } assert embedding.calls == 1 assert client.knowledge_base_calls == 1 manual = await service.sync_mapping(mapping, sync_id="manual-sync") assert manual["status"] == "failed" assert manual["sync_id"] == "manual-sync" assert manual["failure_scope"] == "embedding_provider" assert manual["failure_count"] == 1 assert manual["retry_after_seconds"] == 60 assert store.states["kb"]["state"] == "failed" assert store.states["kb"]["remote_page_count"] == 50 assert store.states["kb"]["last_sync_result"] == manual assert embedding.calls == 1 assert client.knowledge_base_calls == 1 # Once due, a single tiny provider probe fails before any second Wiki # directory scan and doubles the next breaker window. now = 60.0 with pytest.raises(WikiEmbeddingError, match="connection refused"): await service.sync_mapping(mapping) assert embedding.calls == 2 assert client.knowledge_base_calls == 1 next_backoff = await service.sync_mapping(mapping) assert next_backoff["failure_count"] == 2 assert next_backoff["retry_after_seconds"] == 120 @pytest.mark.asyncio async def test_successful_recovery_probe_closes_provider_breaker( monkeypatch: pytest.MonkeyPatch, ) -> None: now = 0.0 monkeypatch.setattr(wiki_sync, "monotonic", lambda: now) config = LocalWikiIndexConfig(chunk_min_chars=10, min_similarity=0) store = MemoryLlmWikiIndexStore() embedding = RecoverableProviderEmbedding() client = FakeWeKnora() service = WikiSyncService( config, store, embedding, WikiVectorCache(store), client, ) mapping = {"id": "kb", "weknora_id": "remote", "name": "Wiki"} with pytest.raises(WikiEmbeddingError): await service.sync_mapping(mapping) now = 60.0 embedding.available = True result = await service.sync_mapping(mapping) assert result["status"] == "completed" assert embedding.calls == 3 # failed page, recovery probe, indexed page assert service._embedding_backoff_result("kb") is None @pytest.mark.asyncio async def test_sync_is_incremental_and_full_scan_deletes_missing_pages() -> None: config = LocalWikiIndexConfig(chunk_min_chars=10, min_similarity=0) store = MemoryLlmWikiIndexStore() embedding = FakeEmbedding() client = FakeWeKnora() service = WikiSyncService(config, store, embedding, WikiVectorCache(store), client) mapping = {"id": "kb", "weknora_id": "remote", "name": "Wiki"} first = await service.sync_mapping(mapping) assert first["changed_pages"] == 1 assert embedding.calls == 1 statuses = await store.list_page_index_status("kb") assert statuses[0]["title"] == "One" assert statuses[0]["vector_count"] > 0 assert store.states["kb"]["remote_page_count"] == 1 second = await service.sync_mapping(mapping) assert second["changed_pages"] == 0 assert embedding.calls == 1 client.pages.clear() deleted = await service.sync_mapping(mapping) assert deleted["deleted_pages"] == 1 assert next(iter(store.pages.values()))["is_remote_deleted"] is True @pytest.mark.asyncio async def test_sync_persists_mapping_scoped_wiki_edges_for_adjacency() -> None: config = LocalWikiIndexConfig(chunk_min_chars=10, min_similarity=0) store = MemoryLlmWikiIndexStore() client = LinkedFakeWeKnora() mapping = {"id": "kb", "weknora_id": "remote", "name": "Wiki"} result = await WikiSyncService(config, store, FakeEmbedding(), WikiVectorCache(store), client).sync_mapping(mapping) assert result["graph_edges"] == 1 assert result["graph_changed"] is True neighbors = await store.get_adjacent_pages(["kb"], {"kb": ["one"]}, direction="outgoing") assert len(neighbors) == 1 assert neighbors[0] == { **neighbors[0], "knowledge_base_mapping_id": "kb", "seed_slug": "one", "neighbor_slug": "two", "direction": "outgoing", "source_slug": "one", "target_slug": "two", } assert neighbors[0]["page"]["title"] == "Two Page" # A seed belonging to another mapping cannot traverse this mapping's edge. assert await store.get_adjacent_pages(["other"], {"other": ["one"]}, direction="both") == [] @pytest.mark.asyncio async def test_sync_losing_lease_during_graph_fetch_cannot_delete_or_replace_snapshot() -> None: config = LocalWikiIndexConfig(chunk_min_chars=10, min_similarity=0) store = MemoryLlmWikiIndexStore() mapping = {"id": "kb", "weknora_id": "remote", "name": "Wiki"} initial_client = LinkedFakeWeKnora() await WikiSyncService( config, store, FakeEmbedding(), WikiVectorCache(store), initial_client, ).sync_mapping(mapping) page_snapshot = { page_id: dict(page) for page_id, page in store.pages.items() } edge_snapshot = { mapping_id: [dict(edge) for edge in edges] for mapping_id, edges in store.edges.items() } stealing_client = LeaseStealingGraphWeKnora(store) stealing_client.pages.clear() with pytest.raises(WikiSyncLeaseLost, match="lease was lost"): await WikiSyncService( config, store, FakeEmbedding(), WikiVectorCache(store), stealing_client, ).sync_mapping(mapping) assert store.pages == page_snapshot assert store.edges == edge_snapshot assert store.states["kb"]["lease_owner"] == "replacement-worker" assert store.states["kb"]["active_sync_id"] == "replacement-sync" @pytest.mark.asyncio async def test_sync_losing_lease_during_embedding_cannot_replace_page_snapshot() -> None: config = LocalWikiIndexConfig(chunk_min_chars=10, min_similarity=0) store = MemoryLlmWikiIndexStore() client = FakeWeKnora() mapping = {"id": "kb", "weknora_id": "remote", "name": "Wiki"} await WikiSyncService( config, store, FakeEmbedding(), WikiVectorCache(store), client, ).sync_mapping(mapping) page_snapshot = { page_id: dict(page) for page_id, page in store.pages.items() } vector_snapshot = { page_id: [dict(vector) for vector in vectors] for page_id, vectors in store.vectors.items() } client.pages["one"]["content"] += " Changed by a stale scan." with pytest.raises(WikiSyncLeaseLost, match="lease was lost"): await WikiSyncService( config, store, LeaseStealingEmbedding(store), WikiVectorCache(store), client, ).sync_mapping(mapping) assert store.pages == page_snapshot assert store.vectors == vector_snapshot assert store.states["kb"]["lease_owner"] == "replacement-worker" assert store.states["kb"]["active_sync_id"] == "replacement-sync" @pytest.mark.asyncio async def test_page_failure_fencing_propagates_lease_loss_without_wrapping() -> None: config = LocalWikiIndexConfig(chunk_min_chars=10, min_similarity=0) store = MemoryLlmWikiIndexStore() client = FakeWeKnora() mapping = {"id": "kb", "weknora_id": "remote", "name": "Wiki"} with pytest.raises(WikiSyncLeaseLost, match="recording a page failure"): await WikiSyncService( config, store, LeaseStealingFailingEmbedding(store), WikiVectorCache(store), client, ).sync_mapping(mapping) assert store.pages == {} assert store.states["kb"]["lease_owner"] == "replacement-worker" assert store.states["kb"]["active_sync_id"] == "replacement-sync" @pytest.mark.asyncio async def test_page_failure_keeps_previous_generation_and_marks_partial() -> None: config = LocalWikiIndexConfig(chunk_min_chars=10, min_similarity=0) store = MemoryLlmWikiIndexStore() client = FakeWeKnora() mapping = {"id": "kb", "weknora_id": "remote", "name": "Wiki"} await WikiSyncService(config, store, FakeEmbedding(), WikiVectorCache(store), client).sync_mapping(mapping) page = next(iter(store.pages.values())) old_hash = page["content_hash"] old_vectors = list(store.vectors[page["id"]]) client.pages["one"]["content"] += " Changed." result = await WikiSyncService(config, store, FailingEmbedding(), WikiVectorCache(store), client).sync_mapping(mapping) assert result["status"] == "partial" assert store.states["kb"]["state"] == "partial" assert store.states["kb"]["failed_page_count"] == 1 assert store.pages[page["id"]]["content_hash"] == old_hash assert store.pages[page["id"]]["index_status"] == "ready" assert store.vectors[page["id"]] == old_vectors @pytest.mark.asyncio async def test_fingerprint_change_automatically_rebuilds_entire_mapping() -> None: config = LocalWikiIndexConfig(chunk_min_chars=10, min_similarity=0) store = MemoryLlmWikiIndexStore() client = FakeWeKnora() mapping = {"id": "kb", "weknora_id": "remote", "name": "Wiki"} await WikiSyncService(config, store, FakeEmbedding(), WikiVectorCache(store), client).sync_mapping(mapping) embedding = NewFingerprintEmbedding() result = await WikiSyncService(config, store, embedding, WikiVectorCache(store), client).sync_mapping(mapping) assert result["status"] == "completed" assert result["changed_pages"] == 1 assert embedding.calls == 1 assert store.states["kb"]["state"] == "idle" assert store.states["kb"]["embedding_fingerprint"] == "fp-new" assert {vector["embedding_fingerprint"] for vectors in store.vectors.values() for vector in vectors} == {"fp-new"} @pytest.mark.asyncio async def test_auto_sync_scheduler_scans_immediately_then_only_when_due(monkeypatch) -> None: now = 0.0 refreshes: list[float] = [] syncs: list[float] = [] sleeps: list[float] = [] config = SimpleNamespace( enabled=True, auto_sync=True, sync_interval_seconds=25, ) async def fake_refresh(_app): refreshes.append(now) return config async def fake_sleep(seconds: float) -> None: nonlocal now sleeps.append(seconds) if len(syncs) >= 2: raise asyncio.CancelledError now += seconds async def fake_sync_once(_app, *, config=None) -> None: syncs.append(now) monkeypatch.setattr(llmwiki_index_scheduler, "monotonic", lambda: now) monkeypatch.setattr(llmwiki_index_scheduler.asyncio, "sleep", fake_sleep) monkeypatch.setattr(llmwiki_index_scheduler, "_sync_once", fake_sync_once) monkeypatch.setattr( llmwiki_index_scheduler, "_refresh_local_wiki_config", fake_refresh, ) app = SimpleNamespace( state=SimpleNamespace( llmwiki_sync_service=object(), llmwiki_store=object(), ) ) with pytest.raises(asyncio.CancelledError): await llmwiki_index_scheduler.local_wiki_index_scheduler_loop(app) assert refreshes == [0.0, 10.0, 20.0, 30.0] assert syncs == [0.0, 30.0] assert sleeps == [10.0, 10.0, 10.0, 10.0] @pytest.mark.asyncio async def test_auto_sync_scheduler_refreshes_provider_between_syncs(monkeypatch) -> None: now = 0.0 seen_providers: list[str] = [] syncs: list[str] = [] providers = iter(("openai", "local", "local")) async def fake_refresh(_app): provider = next(providers) seen_providers.append(provider) _app.state.llmwiki_runtime_signature = provider return SimpleNamespace( enabled=True, auto_sync=True, sync_interval_seconds=120, embedding=SimpleNamespace(provider=provider), ) async def fake_sync_once(_app, *, config=None) -> None: syncs.append(config.embedding.provider) async def fake_sleep(seconds: float) -> None: nonlocal now now += seconds if len(seen_providers) >= 3: raise asyncio.CancelledError monkeypatch.setattr(llmwiki_index_scheduler, "monotonic", lambda: now) monkeypatch.setattr(llmwiki_index_scheduler.asyncio, "sleep", fake_sleep) monkeypatch.setattr(llmwiki_index_scheduler, "_sync_once", fake_sync_once) monkeypatch.setattr( llmwiki_index_scheduler, "_refresh_local_wiki_config", fake_refresh, ) app = SimpleNamespace( state=SimpleNamespace( llmwiki_sync_service=object(), llmwiki_store=object(), ) ) with pytest.raises(asyncio.CancelledError): await llmwiki_index_scheduler.local_wiki_index_scheduler_loop(app) assert seen_providers == ["openai", "local", "local"] assert syncs == ["openai", "local"] @pytest.mark.asyncio async def test_disabled_auto_sync_keeps_scheduler_local_only(monkeypatch) -> None: now = 0.0 refreshes: list[float] = [] syncs: list[float] = [] remote_maintenance_calls: list[str] = [] config = SimpleNamespace( enabled=True, auto_sync=False, sync_interval_seconds=25, ) async def fake_refresh(_app): refreshes.append(now) return config async def fake_sleep(seconds: float) -> None: nonlocal now now += seconds if len(refreshes) >= 3: raise asyncio.CancelledError async def fake_sync_once(_app, *, config=None) -> None: syncs.append(now) async def fake_remote_maintenance(_app) -> None: remote_maintenance_calls.append("called") monkeypatch.setattr(llmwiki_index_scheduler, "monotonic", lambda: now) monkeypatch.setattr(llmwiki_index_scheduler.asyncio, "sleep", fake_sleep) monkeypatch.setattr(llmwiki_index_scheduler, "_sync_once", fake_sync_once) monkeypatch.setattr( llmwiki_index_scheduler, "_upgrade_wiki_generation_configs_once", fake_remote_maintenance, ) monkeypatch.setattr( llmwiki_index_scheduler, "_refresh_local_wiki_config", fake_refresh, ) app = SimpleNamespace( state=SimpleNamespace( llmwiki_sync_service=object(), llmwiki_store=object(), ) ) with pytest.raises(asyncio.CancelledError): await llmwiki_index_scheduler.local_wiki_index_scheduler_loop(app) assert refreshes == [0.0, 10.0, 20.0] assert syncs == [] assert remote_maintenance_calls == [] def test_local_wiki_index_defaults_to_manual_sync() -> None: config = LocalWikiIndexConfig() assert config.auto_sync is False assert config.sync_interval_seconds == 30 def test_local_wiki_index_accepts_legacy_sync_flag() -> None: config = LocalWikiIndexConfig.model_validate({"sync_enabled": True}) assert config.auto_sync is True assert config.sync_enabled is True