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
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Programmatic access to lilbee's retrieval pipeline.
3Retrieval only -- no LLM chat. Search your indexed documents from Python.
4Optional features (concept graph, reranker) activate automatically when
5their dependencies are installed.
7Usage::
9 from lilbee import Lilbee
11 bee = Lilbee("./docs")
12 bee.sync()
13 results = bee.search("authentication")
14 bee.close()
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"""
25from __future__ import annotations
27import asyncio
28from pathlib import Path
29from typing import TYPE_CHECKING
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)
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
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"
62class Lilbee:
63 """Programmatic access to lilbee's retrieval pipeline.
65 Usage::
67 from lilbee import Lilbee
69 bee = Lilbee("./docs")
70 bee.sync()
71 results = bee.search("authentication")
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 """
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.
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")
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()
116 self._config.documents_dir.mkdir(parents=True, exist_ok=True)
117 self._config.data_dir.mkdir(parents=True, exist_ok=True)
119 self._services: Services = build_services(self._config, provider=provider)
120 self._closed = False
122 @property
123 def config(self) -> Config:
124 """The Config instance backing this Lilbee."""
125 return self._config
127 @property
128 def store(self) -> Store:
129 """The Store component."""
130 return self._services.store
132 @property
133 def embedder(self) -> Embedder:
134 """The Embedder component."""
135 return self._services.embedder
137 @property
138 def searcher(self) -> Searcher:
139 """The Searcher component."""
140 return self._services.searcher
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
147 with config_scope(self._config), services_scope(self._services):
148 return asyncio.run(_sync(quiet=quiet, started_by=_SYNC_STARTER))
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.
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 )
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
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))
178 def remove(self, name: str) -> None:
179 """Remove a document from the index by source name (source bytes are kept).
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])
187 def status(self) -> dict[str, object]:
188 """Return index stats: the real document count and the newest source names.
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 }
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*.
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)
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
225 with config_scope(self._config), services_scope(self._services):
226 return asyncio.run(_sync(force_rebuild=True, quiet=True, started_by=_SYNC_STARTER))
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.
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
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)
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
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 )
260 def memories(self) -> list[MemoryRow]:
261 """List all stored memories, newest first."""
262 from lilbee.data.store import local_owner_predicate
264 with config_scope(self._config), services_scope(self._services):
265 return self._services.store.get_memories(owner_predicate=local_owner_predicate())
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)
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()