Coverage for src/lilbee/api.py: 100%

93 statements  

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

1"""Programmatic access to lilbee's retrieval pipeline. 

2 

3Retrieval only -- no LLM chat. Search your indexed documents from Python. 

4Optional features (concept graph, reranker) activate automatically when 

5their dependencies are installed. 

6 

7Usage:: 

8 

9 from lilbee import Lilbee 

10 

11 bee = Lilbee("./docs") 

12 bee.sync() 

13 results = bee.search("authentication") 

14 bee.close() 

15 

16Each instance binds its own Config and Services for the duration of every call 

17via contextvar scopes, so it runs against its own data root without mutating the 

18process-global cfg or the shared services singleton. The scope is per task, so 

19two instances may be driven from different threads or asyncio tasks without 

20clobbering one another, and the global HTTP daemon's fleet is untouched. An 

21instance holds a live engine between calls; call :meth:`Lilbee.close` to release 

22it. 

23""" 

24 

25from __future__ import annotations 

26 

27import asyncio 

28from pathlib import Path 

29from typing import TYPE_CHECKING 

30 

31# app.ingest stays at module top: registering source roots is a locked 

32# config.toml read-modify-write over the config singleton (no copy, no symlink), 

33# cheap to import. data.ingest is deferred at each callsite below because it 

34# transitively imports spaCy via the wiki package and adds ~3s on first touch. 

35from lilbee.app.ingest import register_sources, remove_documents_durably 

36from lilbee.app.services import build_services, services_scope 

37from lilbee.app.status import STATUS_RECENT_LIMIT 

38from lilbee.core.config import Config, cfg, config_scope, refuse_environment 

39from lilbee.core.system import canonical_data_root 

40from lilbee.data.store import ( 

41 DOCUMENTS_PAGE_DEFAULT, 

42 DOCUMENTS_PAGE_MAX, 

43 LOCAL_OWNER, 

44 MemoryKind, 

45 MemoryRow, 

46 SearchScope, 

47 scope_to_chunk_type, 

48) 

49 

50if TYPE_CHECKING: 

51 from lilbee.app.services import Services 

52 from lilbee.data.ingest import SyncResult 

53 from lilbee.data.store import SearchChunk, SourceRecord, Store 

54 from lilbee.providers.base import LLMProvider 

55 from lilbee.retrieval.embedder import Embedder 

56 from lilbee.retrieval.query import Searcher 

57 

58# What a sync of this API is called in the notice of a sync that waits for it. 

59_SYNC_STARTER = "the lilbee Python API" 

60 

61 

62class Lilbee: 

63 """Programmatic access to lilbee's retrieval pipeline. 

64 

65 Usage:: 

66 

67 from lilbee import Lilbee 

68 

69 bee = Lilbee("./docs") 

70 bee.sync() 

71 results = bee.search("authentication") 

72 

73 Retrieval only. Wiki building and browsing stay off this surface by design: 

74 they are interactive, long-running operations that belong to the CLI, TUI, 

75 HTTP API, and MCP tools. ``search(scope=...)`` is the library's whole wiki 

76 story, since scoping a query is retrieval. 

77 """ 

78 

79 def __init__( 

80 self, 

81 documents_dir: str | Path | None = None, 

82 *, 

83 config: Config | None = None, 

84 provider: LLMProvider | None = None, 

85 ) -> None: 

86 """Create a lilbee instance. 

87 Args: 

88 documents_dir: Path to documents folder. Creates a default Config 

89 with derived data and lancedb directories. 

90 config: Full Config instance for complete control. 

91 provider: LLM provider instance. If not given, creates one from config. 

92 

93 Pass documents_dir or config, not both. If neither is given, uses 

94 ``Config()`` (same defaults as the CLI). Raises RefusedVariableError when the 

95 environment sets a value its setting refuses. 

96 """ 

97 refuse_environment() 

98 if documents_dir is not None and config is not None: 

99 raise ValueError("Pass documents_dir or config, not both") 

100 

101 if config is not None: 

102 self._config = config 

103 elif documents_dir is not None: 

104 root = canonical_data_root(documents_dir) 

105 self._config = cfg.model_copy( 

106 update={ 

107 "data_root": root, 

108 "documents_dir": root / "documents", 

109 "data_dir": root / "data", 

110 "lancedb_dir": root / "data" / "lancedb", 

111 }, 

112 ) 

113 else: 

114 self._config = Config() 

115 

116 self._config.documents_dir.mkdir(parents=True, exist_ok=True) 

117 self._config.data_dir.mkdir(parents=True, exist_ok=True) 

118 

119 self._services: Services = build_services(self._config, provider=provider) 

120 self._closed = False 

121 

122 @property 

123 def config(self) -> Config: 

124 """The Config instance backing this Lilbee.""" 

125 return self._config 

126 

127 @property 

128 def store(self) -> Store: 

129 """The Store component.""" 

130 return self._services.store 

131 

132 @property 

133 def embedder(self) -> Embedder: 

134 """The Embedder component.""" 

135 return self._services.embedder 

136 

137 @property 

138 def searcher(self) -> Searcher: 

139 """The Searcher component.""" 

140 return self._services.searcher 

141 

142 def sync(self, *, quiet: bool = True) -> SyncResult: 

143 """Sync documents to the vector store. Returns what changed.""" 

144 # heavy: data.ingest transitively imports spaCy via wiki 

145 from lilbee.data.ingest import sync as _sync 

146 

147 with config_scope(self._config), services_scope(self._services): 

148 return asyncio.run(_sync(quiet=quiet, started_by=_SYNC_STARTER)) 

149 

150 def search( 

151 self, query: str, *, top_k: int = 0, scope: SearchScope = SearchScope.BOTH 

152 ) -> list[SearchChunk]: 

153 """Search indexed documents. Returns ranked chunks. 

154 

155 ``scope`` selects raw document chunks, generated wiki chunks, or both. 

156 """ 

157 with config_scope(self._config), services_scope(self._services): 

158 return self._services.searcher.search( 

159 query, top_k=top_k, chunk_type=scope_to_chunk_type(scope) 

160 ) 

161 

162 def add(self, paths: list[str | Path]) -> SyncResult: 

163 """Add files to the knowledge base and sync. 

164 Registers each path as a source root (indexed in place), then syncs. A 

165 folder that contains registered sources takes them in. The result is 

166 the sync's: it names no source that was registered or taken in. 

167 """ 

168 # heavy: data.ingest transitively imports spaCy via wiki 

169 from lilbee.data.ingest import SyncResult 

170 from lilbee.data.ingest import sync as _sync 

171 

172 resolved = [Path(p).resolve() for p in paths] 

173 with config_scope(self._config), services_scope(self._services): 

174 if not register_sources(resolved, force=True).reached_corpus: 

175 return SyncResult() 

176 return asyncio.run(_sync(quiet=True, started_by=_SYNC_STARTER)) 

177 

178 def remove(self, name: str) -> None: 

179 """Remove a document from the index by source name (source bytes are kept). 

180 

181 Raises ``LockTimeoutError`` with nothing removed when another lilbee 

182 process holds the index write lock past its timeout. 

183 """ 

184 with config_scope(self._config), services_scope(self._services): 

185 remove_documents_durably([name]) 

186 

187 def status(self) -> dict[str, object]: 

188 """Return index stats: the real document count and the newest source names. 

189 

190 ``sources`` holds at most ``STATUS_RECENT_LIMIT`` names; ``documents`` pages the rest. 

191 """ 

192 with config_scope(self._config), services_scope(self._services): 

193 summary = self._services.store.source_summary(STATUS_RECENT_LIMIT) 

194 return { 

195 "documents_dir": str(self._config.documents_dir), 

196 "data_dir": str(self._config.data_dir), 

197 "document_count": summary.document_count, 

198 "sources": [head["filename"] for head in summary.newest], 

199 } 

200 

201 def documents( 

202 self, 

203 *, 

204 search: str | None = None, 

205 limit: int = DOCUMENTS_PAGE_DEFAULT, 

206 offset: int = 0, 

207 ) -> list[SourceRecord]: 

208 """Return one page of indexed documents, filtered by a filename *search*. 

209 

210 Reads the same store listing as ``GET /api/documents``. Raises ``ValueError`` 

211 when *limit* is outside 1 to ``DOCUMENTS_PAGE_MAX`` or *offset* is negative. 

212 """ 

213 if not 1 <= limit <= DOCUMENTS_PAGE_MAX: 

214 raise ValueError(f"limit must be from 1 to {DOCUMENTS_PAGE_MAX}, got {limit}") 

215 if offset < 0: 

216 raise ValueError(f"offset must be 0 or more, got {offset}") 

217 with config_scope(self._config), services_scope(self._services): 

218 return self._services.store.get_sources(search=search, limit=limit, offset=offset) 

219 

220 def rebuild(self) -> SyncResult: 

221 """Rebuild the entire index from scratch.""" 

222 # heavy: data.ingest transitively imports spaCy via wiki 

223 from lilbee.data.ingest import sync as _sync 

224 

225 with config_scope(self._config), services_scope(self._services): 

226 return asyncio.run(_sync(force_rebuild=True, quiet=True, started_by=_SYNC_STARTER)) 

227 

228 def remember( 

229 self, 

230 text: str, 

231 *, 

232 kind: MemoryKind = MemoryKind.FACT, 

233 shared: bool = False, 

234 ) -> str: 

235 """Store a fact or preference in long-term memory; returns its id. 

236 

237 This library primitive does not consult ``memory_enabled``: that flag 

238 gates the interactive surfaces (TUI/CLI/MCP/REST) and the chat-prompt 

239 injection, not direct programmatic access. ``remember`` and ``recall`` 

240 operate as a pair regardless of the flag. 

241 """ 

242 from lilbee.app.memory import make_memory_row 

243 

244 with config_scope(self._config), services_scope(self._services): 

245 record = make_memory_row(text, self._services.embedder.embed, kind=kind, shared=shared) 

246 return self._services.store.add_memory(record) 

247 

248 def recall(self, query: str, *, top_k: int | None = None) -> list[MemoryRow]: 

249 """Recall facts relevant to *query* (own memories plus agent-shared).""" 

250 from lilbee.data.store import human_recall_predicate 

251 

252 with config_scope(self._config), services_scope(self._services): 

253 return self._services.store.search_memories( 

254 self._services.embedder.embed_query(query), 

255 owner_predicate=human_recall_predicate(), 

256 top_k=self._config.memory_top_k if top_k is None else top_k, 

257 max_distance=self._config.memory_max_distance, 

258 ) 

259 

260 def memories(self) -> list[MemoryRow]: 

261 """List all stored memories, newest first.""" 

262 from lilbee.data.store import local_owner_predicate 

263 

264 with config_scope(self._config), services_scope(self._services): 

265 return self._services.store.get_memories(owner_predicate=local_owner_predicate()) 

266 

267 def forget(self, memory_id: str) -> bool: 

268 """Delete a local memory by id; True when it existed and was removed.""" 

269 with config_scope(self._config), services_scope(self._services): 

270 return self._services.store.delete_memory(memory_id, owner=LOCAL_OWNER) 

271 

272 def close(self) -> None: 

273 """Release the engine and store this instance holds. Idempotent.""" 

274 if self._closed: 

275 return 

276 self._closed = True 

277 self._services.provider.shutdown() 

278 self._services.store.close()