Coverage for src/lilbee/retrieval/clustering.py: 100%

34 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Source clustering abstraction for wiki synthesis pages. 

2 

3Defines the :class:`SourceClusterer` protocol, the :class:`ClustererBackend` 

4enum of known backend identifiers, and the :class:`Clusterer` facade. The 

5facade is the single class the services container constructs and it picks 

6the right backend from ``config.wiki_clusterer`` so callers never need to 

7know which implementation they got. 

8""" 

9 

10from __future__ import annotations 

11 

12import logging 

13from dataclasses import dataclass 

14from typing import TYPE_CHECKING, Protocol, runtime_checkable 

15 

16from lilbee.core.config import ClustererBackend 

17 

18if TYPE_CHECKING: 

19 from lilbee.core.config import Config 

20 from lilbee.data.store import Store 

21 

22log = logging.getLogger(__name__) 

23 

24 

25@dataclass(frozen=True) 

26class SourceCluster: 

27 """A group of related documents identified by a clustering strategy.""" 

28 

29 cluster_id: str 

30 """Opaque stable identifier, used for filesystem slugs.""" 

31 

32 label: str 

33 """Human-readable topic label for the cluster.""" 

34 

35 sources: frozenset[str] 

36 """Set of source document filenames in the cluster.""" 

37 

38 

39@runtime_checkable 

40class SourceClusterer(Protocol): 

41 """Finds clusters of related source documents for cross-source synthesis.""" 

42 

43 def available(self) -> bool: 

44 """Return True if this clusterer can produce clusters in the current env.""" 

45 ... 

46 

47 def get_clusters(self, min_sources: int = 3) -> list[SourceCluster]: 

48 """Return clusters spanning at least ``min_sources`` distinct documents.""" 

49 ... 

50 

51 

52def _select_backend(config: Config, store: Store) -> SourceClusterer: 

53 """Pick a backend based on ``config.wiki_clusterer`` with safe fallback. 

54 

55 Concrete backends are imported inside the function to break a hard 

56 circular dependency: ``clustering_embedding`` re-exports 

57 :class:`SourceCluster` from this module, so importing it at module 

58 level here would fail during package initialization. 

59 """ 

60 from lilbee.retrieval.clustering_embedding import EmbeddingClusterer 

61 from lilbee.retrieval.concepts import ConceptGraphClusterer 

62 

63 if config.wiki_clusterer == ClustererBackend.CONCEPTS: 

64 graph_clusterer = ConceptGraphClusterer(config, store) 

65 if graph_clusterer.available(): 

66 return graph_clusterer 

67 log.warning( 

68 "wiki_clusterer=concepts but the [graph] extra is not installed or " 

69 "the concept graph has not been built. Falling back to the " 

70 "embedding clusterer." 

71 ) 

72 return EmbeddingClusterer(config, store) 

73 

74 

75class Clusterer: 

76 """Wiki synthesis clusterer facade with live backend selection. 

77 

78 The backend is selected on each access rather than pinned at 

79 construction: the services container caches this facade for the process 

80 lifetime, and a concept graph built later in the same process must be 

81 picked up without a restart. Selection is cheap -- backend construction 

82 just captures config and store, and availability is a config flag plus 

83 a table-existence check. 

84 """ 

85 

86 def __init__(self, config: Config, store: Store) -> None: 

87 self._config = config 

88 self._store = store 

89 

90 @property 

91 def backend(self) -> SourceClusterer: 

92 """Select and return the backend for the store's current state.""" 

93 return _select_backend(self._config, self._store) 

94 

95 def available(self) -> bool: 

96 return self.backend.available() 

97 

98 def get_clusters(self, min_sources: int = 3) -> list[SourceCluster]: 

99 return self.backend.get_clusters(min_sources=min_sources)