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
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Source clustering abstraction for wiki synthesis pages.
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"""
10from __future__ import annotations
12import logging
13from dataclasses import dataclass
14from typing import TYPE_CHECKING, Protocol, runtime_checkable
16from lilbee.core.config import ClustererBackend
18if TYPE_CHECKING:
19 from lilbee.core.config import Config
20 from lilbee.data.store import Store
22log = logging.getLogger(__name__)
25@dataclass(frozen=True)
26class SourceCluster:
27 """A group of related documents identified by a clustering strategy."""
29 cluster_id: str
30 """Opaque stable identifier, used for filesystem slugs."""
32 label: str
33 """Human-readable topic label for the cluster."""
35 sources: frozenset[str]
36 """Set of source document filenames in the cluster."""
39@runtime_checkable
40class SourceClusterer(Protocol):
41 """Finds clusters of related source documents for cross-source synthesis."""
43 def available(self) -> bool:
44 """Return True if this clusterer can produce clusters in the current env."""
45 ...
47 def get_clusters(self, min_sources: int = 3) -> list[SourceCluster]:
48 """Return clusters spanning at least ``min_sources`` distinct documents."""
49 ...
52def _select_backend(config: Config, store: Store) -> SourceClusterer:
53 """Pick a backend based on ``config.wiki_clusterer`` with safe fallback.
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
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)
75class Clusterer:
76 """Wiki synthesis clusterer facade with live backend selection.
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 """
86 def __init__(self, config: Config, store: Store) -> None:
87 self._config = config
88 self._store = store
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)
95 def available(self) -> bool:
96 return self.backend.available()
98 def get_clusters(self, min_sources: int = 3) -> list[SourceCluster]:
99 return self.backend.get_clusters(min_sources=min_sources)