docugraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. docugraph/__init__.py +24 -0
  2. docugraph/_version.py +28 -0
  3. docugraph/agents/__init__.py +27 -0
  4. docugraph/agents/coding_agent.py +532 -0
  5. docugraph/agents/tools/__init__.py +62 -0
  6. docugraph/agents/tools/graph_query.py +203 -0
  7. docugraph/agents/tools/memory_ops.py +305 -0
  8. docugraph/agents/tools/search_code.py +305 -0
  9. docugraph/agents/tools/search_docs.py +148 -0
  10. docugraph/core/__init__.py +34 -0
  11. docugraph/core/config.py +183 -0
  12. docugraph/core/embeddings.py +394 -0
  13. docugraph/core/llm.py +892 -0
  14. docugraph/core/models.py +145 -0
  15. docugraph/ingestion/__init__.py +64 -0
  16. docugraph/ingestion/api_docs.py +646 -0
  17. docugraph/ingestion/chunker.py +382 -0
  18. docugraph/ingestion/crawler.py +305 -0
  19. docugraph/ingestion/git_indexer.py +556 -0
  20. docugraph/ingestion/local_files.py +516 -0
  21. docugraph/interfaces/__init__.py +1 -0
  22. docugraph/interfaces/cli.py +983 -0
  23. docugraph/interfaces/mcp_server.py +918 -0
  24. docugraph/retrieval/__init__.py +54 -0
  25. docugraph/retrieval/context_builder.py +522 -0
  26. docugraph/retrieval/hybrid_search.py +534 -0
  27. docugraph/retrieval/reranker.py +532 -0
  28. docugraph/storage/__init__.py +14 -0
  29. docugraph/storage/graph_store.py +602 -0
  30. docugraph/storage/memory_store.py +529 -0
  31. docugraph/storage/vector_store.py +367 -0
  32. docugraph-0.1.0.dist-info/METADATA +354 -0
  33. docugraph-0.1.0.dist-info/RECORD +37 -0
  34. docugraph-0.1.0.dist-info/WHEEL +4 -0
  35. docugraph-0.1.0.dist-info/entry_points.txt +3 -0
  36. docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
  37. docugraph-0.1.0.dist-info/licenses/NOTICE +10 -0
@@ -0,0 +1,394 @@
1
+ """Embedding provider abstraction (local-first, LLM-agnostic)."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+ from docugraph.core.config import EmbeddingConfig, EmbeddingProvider, get_config
7
+
8
+
9
+ class EmbeddingProviderBase(ABC):
10
+ """Abstract base class for embedding providers."""
11
+
12
+ @abstractmethod
13
+ def embed(self, texts: list[str]) -> list[list[float]]:
14
+ """Embed a list of texts."""
15
+ ...
16
+
17
+ @abstractmethod
18
+ def embed_query(self, query: str) -> list[float]:
19
+ """Embed a single query (may use different model/prefix)."""
20
+ ...
21
+
22
+ @property
23
+ @abstractmethod
24
+ def dimensions(self) -> int:
25
+ """Return the embedding dimensions."""
26
+ ...
27
+
28
+ @property
29
+ @abstractmethod
30
+ def model_name(self) -> str:
31
+ """Return the model name."""
32
+ ...
33
+
34
+
35
+ class FastembedEmbedder(EmbeddingProviderBase):
36
+ """Local embeddings via fastembed (pure ONNX, no torch).
37
+
38
+ Default model BAAI/bge-small-en-v1.5 (384 dims). Weights download on
39
+ first use and are cached under FASTEMBED_CACHE_PATH; unset, fastembed
40
+ falls back to a `fastembed_cache` subdirectory of the system temp
41
+ directory (its `define_cache_dir`), not to `~/.cache/fastembed`.
42
+ """
43
+
44
+ # bge-en-v1.5 models are trained with a query/passage asymmetry; retrieval
45
+ # queries should carry this instruction prefix for best relevance.
46
+ BGE_QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
47
+
48
+ def __init__(self, model_name: str | None = None, model: Any | None = None):
49
+ self._model_name = model_name or "BAAI/bge-small-en-v1.5"
50
+ self._dimensions: int | None = None
51
+ if model is None:
52
+ # Lazy import so environments without fastembed degrade via the chain.
53
+ from fastembed import TextEmbedding
54
+
55
+ model = TextEmbedding(self._model_name)
56
+ # Stored only once non-None so mypy can narrow the attribute.
57
+ self._model: Any = model
58
+
59
+ def embed(self, texts: list[str]) -> list[list[float]]:
60
+ """Embed a list of texts (passages/documents)."""
61
+ if not texts:
62
+ return []
63
+ # fastembed yields numpy arrays; convert to plain float lists.
64
+ vectors: list[list[float]] = [[float(x) for x in vec] for vec in self._model.embed(texts)]
65
+ return vectors
66
+
67
+ def embed_query(self, query: str) -> list[float]:
68
+ """Embed a retrieval query, applying the bge instruction prefix."""
69
+ prefixed = self.BGE_QUERY_PREFIX + query if "bge" in self._model_name.lower() else query
70
+ return self.embed([prefixed])[0]
71
+
72
+ @property
73
+ def dimensions(self) -> int:
74
+ if self._dimensions is None:
75
+ self._dimensions = len(self.embed(["dimension probe"])[0])
76
+ return self._dimensions
77
+
78
+ @property
79
+ def model_name(self) -> str:
80
+ return self._model_name
81
+
82
+
83
+ class SentenceTransformersEmbedder(EmbeddingProviderBase):
84
+ """Local embeddings using sentence-transformers (optional; requires torch).
85
+
86
+ Not a managed dependency anymore — works if the user has
87
+ sentence-transformers installed independently.
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ model_name: str | None = None,
93
+ device: str = "cpu",
94
+ batch_size: int = 32,
95
+ ):
96
+ self._model_name = model_name or "all-MiniLM-L6-v2"
97
+ self._batch_size = batch_size
98
+
99
+ # Lazy load to avoid import overhead
100
+ from sentence_transformers import SentenceTransformer
101
+
102
+ self._model = SentenceTransformer(self._model_name, device=device)
103
+ detected = self._model.get_sentence_embedding_dimension()
104
+ self._dimensions: int = int(detected) if detected is not None else 384
105
+
106
+ def embed(self, texts: list[str]) -> list[list[float]]:
107
+ """Embed a list of texts."""
108
+ embeddings = self._model.encode(
109
+ texts,
110
+ batch_size=self._batch_size,
111
+ show_progress_bar=len(texts) > 100,
112
+ convert_to_numpy=True,
113
+ )
114
+ vectors: list[list[float]] = embeddings.tolist()
115
+ return vectors
116
+
117
+ def embed_query(self, query: str) -> list[float]:
118
+ """Embed a single query."""
119
+ embedding = self._model.encode(query, convert_to_numpy=True)
120
+ vector: list[float] = embedding.tolist()
121
+ return vector
122
+
123
+ @property
124
+ def dimensions(self) -> int:
125
+ return self._dimensions
126
+
127
+ @property
128
+ def model_name(self) -> str:
129
+ return self._model_name
130
+
131
+
132
+ class OllamaEmbedder(EmbeddingProviderBase):
133
+ """Embeddings via Ollama (local LLM server)."""
134
+
135
+ def __init__(
136
+ self,
137
+ model_name: str | None = None,
138
+ base_url: str = "http://localhost:11434",
139
+ ):
140
+ # Local, so it keeps a default: the server is on this machine and
141
+ # `ollama pull nomic-embed-text` is a command the reader can run.
142
+ self._model_name = model_name or "nomic-embed-text"
143
+ self._base_url = base_url
144
+ self._dimensions: int | None = None
145
+
146
+ def _get_client(self) -> Any:
147
+ """Get Ollama client."""
148
+ try:
149
+ import ollama
150
+
151
+ return ollama.Client(host=self._base_url)
152
+ except ImportError as e:
153
+ raise ImportError(
154
+ "Ollama support requires the 'ollama' package. "
155
+ "Install with: pip install 'docugraph[ollama]'"
156
+ ) from e
157
+
158
+ def embed(self, texts: list[str]) -> list[list[float]]:
159
+ """Embed a list of texts."""
160
+ client = self._get_client()
161
+ embeddings = []
162
+ for text in texts:
163
+ response = client.embeddings(model=self._model_name, prompt=text)
164
+ embeddings.append(response["embedding"])
165
+ if self._dimensions is None:
166
+ self._dimensions = len(response["embedding"])
167
+ return embeddings
168
+
169
+ def embed_query(self, query: str) -> list[float]:
170
+ """Embed a single query."""
171
+ return self.embed([query])[0]
172
+
173
+ @property
174
+ def dimensions(self) -> int:
175
+ if self._dimensions is None:
176
+ # Get dimensions by embedding a test string
177
+ self.embed(["test"])
178
+ return self._dimensions or 768
179
+
180
+ @property
181
+ def model_name(self) -> str:
182
+ return self._model_name
183
+
184
+
185
+ class OpenAIEmbedder(EmbeddingProviderBase):
186
+ """Embeddings via OpenAI API.
187
+
188
+ ``model_name`` has no default. An embedding model's vectors live in their
189
+ own space and cannot be mixed with another's, so a name compiled in here is
190
+ a name that silently becomes wrong the day OpenAI retires it -- and the
191
+ store's dimension and identity guards would then be comparing against a
192
+ model nobody chose.
193
+ """
194
+
195
+ def __init__(
196
+ self,
197
+ model_name: str | None = None,
198
+ api_key: str | None = None,
199
+ ):
200
+ if not model_name:
201
+ raise ValueError(
202
+ "embeddings.model must name an OpenAI embedding model when "
203
+ "embeddings.provider is 'openai' (e.g. DOCUGRAPH_EMBEDDINGS__MODEL). "
204
+ "There is no default: an embedding model fixes the vector space "
205
+ "of the whole index, so it is not something to guess at."
206
+ )
207
+ self._model_name = model_name
208
+ self._api_key = api_key
209
+ # Measured from a real response rather than looked up in a table. The
210
+ # table was a second place to keep a fact the API already reports, and
211
+ # it was wrong for every model it did not list -- it defaulted to 1536,
212
+ # so an unlisted model got a schema that could not hold its vectors.
213
+ self._dimensions: int | None = None
214
+
215
+ def _get_client(self) -> Any:
216
+ """Get OpenAI client."""
217
+ try:
218
+ from openai import OpenAI
219
+
220
+ api_key = self._api_key
221
+ if api_key is None:
222
+ config = get_config()
223
+ api_key = config.llm.openai_api_key
224
+
225
+ if not api_key:
226
+ raise ValueError("OpenAI API key not configured")
227
+
228
+ return OpenAI(api_key=api_key)
229
+ except ImportError as e:
230
+ raise ImportError(
231
+ "OpenAI support requires the 'openai' package. "
232
+ "Install with: pip install 'docugraph[cloud]'"
233
+ ) from e
234
+
235
+ def embed(self, texts: list[str]) -> list[list[float]]:
236
+ """Embed a list of texts."""
237
+ client = self._get_client()
238
+ response = client.embeddings.create(
239
+ model=self._model_name,
240
+ input=texts,
241
+ )
242
+ vectors = [item.embedding for item in response.data]
243
+ if self._dimensions is None and vectors:
244
+ self._dimensions = len(vectors[0])
245
+ return vectors
246
+
247
+ def embed_query(self, query: str) -> list[float]:
248
+ """Embed a single query."""
249
+ return self.embed([query])[0]
250
+
251
+ @property
252
+ def dimensions(self) -> int:
253
+ if self._dimensions is None:
254
+ # Nothing has been embedded yet and the caller needs the width before
255
+ # it can build a schema, so ask the model directly.
256
+ self.embed(["dimension probe"])
257
+ return self._dimensions or 0
258
+
259
+ @property
260
+ def model_name(self) -> str:
261
+ return self._model_name
262
+
263
+
264
+ class CohereEmbedder(EmbeddingProviderBase):
265
+ """Embeddings via Cohere API.
266
+
267
+ ``model_name`` has no default, for the same reason as OpenAIEmbedder: the
268
+ vector space is a property of the model. It used to default to
269
+ ``embed-english-v3.0`` with a hard-coded ``1024`` dimensions, which is that
270
+ one model's width -- every other Cohere embedding model got a schema built
271
+ to the wrong size.
272
+ """
273
+
274
+ def __init__(
275
+ self,
276
+ model_name: str | None = None,
277
+ api_key: str | None = None,
278
+ ):
279
+ if not model_name:
280
+ raise ValueError(
281
+ "embeddings.model must name a Cohere embedding model when "
282
+ "embeddings.provider is 'cohere' (e.g. DOCUGRAPH_EMBEDDINGS__MODEL). "
283
+ "There is no default: the model fixes the vector space of the "
284
+ "whole index."
285
+ )
286
+ self._model_name = model_name
287
+ self._api_key = api_key
288
+ self._dimensions: int | None = None
289
+
290
+ def _get_client(self) -> Any:
291
+ """Get Cohere client."""
292
+ try:
293
+ import cohere
294
+
295
+ api_key = self._api_key
296
+ if api_key is None:
297
+ import os
298
+
299
+ api_key = os.getenv("COHERE_API_KEY")
300
+
301
+ if not api_key:
302
+ raise ValueError("Cohere API key not configured")
303
+
304
+ return cohere.Client(api_key)
305
+ except ImportError as e:
306
+ raise ImportError(
307
+ "Cohere support requires the 'cohere' package. "
308
+ "Install with: pip install 'docugraph[cloud]'"
309
+ ) from e
310
+
311
+ def embed(self, texts: list[str]) -> list[list[float]]:
312
+ """Embed a list of texts."""
313
+ client = self._get_client()
314
+ response = client.embed(
315
+ texts=texts,
316
+ model=self._model_name,
317
+ input_type="search_document",
318
+ )
319
+ vectors = [list(emb) for emb in response.embeddings]
320
+ if self._dimensions is None and vectors:
321
+ self._dimensions = len(vectors[0])
322
+ return vectors
323
+
324
+ def embed_query(self, query: str) -> list[float]:
325
+ """Embed a single query."""
326
+ client = self._get_client()
327
+ response = client.embed(
328
+ texts=[query],
329
+ model=self._model_name,
330
+ input_type="search_query",
331
+ )
332
+ return list(response.embeddings[0])
333
+
334
+ @property
335
+ def dimensions(self) -> int:
336
+ if self._dimensions is None:
337
+ self.embed(["dimension probe"])
338
+ return self._dimensions or 0
339
+
340
+ @property
341
+ def model_name(self) -> str:
342
+ return self._model_name
343
+
344
+
345
+ def get_embedder(config: EmbeddingConfig | None = None) -> EmbeddingProviderBase:
346
+ """Factory function to get the configured embedder.
347
+
348
+ With provider=auto: fastembed (local, pure ONNX) -> sentence-transformers
349
+ (only if already installed). Nothing about the environment is consulted,
350
+ so an unrelated OPENAI_API_KEY in your shell cannot start sending document
351
+ text to a third party. A cloud provider is used only when it is named
352
+ explicitly, and then it fails loudly on a missing package or key.
353
+ """
354
+ if config is None:
355
+ config = get_config().embeddings
356
+
357
+ provider = config.provider
358
+
359
+ if provider == EmbeddingProvider.FASTEMBED:
360
+ return FastembedEmbedder(model_name=config.model)
361
+ elif provider == EmbeddingProvider.SENTENCE_TRANSFORMERS:
362
+ return SentenceTransformersEmbedder(
363
+ model_name=config.model,
364
+ batch_size=config.batch_size,
365
+ )
366
+ elif provider == EmbeddingProvider.OLLAMA:
367
+ return OllamaEmbedder(model_name=config.model)
368
+ elif provider == EmbeddingProvider.OPENAI:
369
+ return OpenAIEmbedder(model_name=config.model)
370
+ elif provider == EmbeddingProvider.COHERE:
371
+ return CohereEmbedder(model_name=config.model)
372
+ else:
373
+ # AUTO: local backends only, and `os.environ` is deliberately not
374
+ # consulted here. A key in the environment is not a request to use it:
375
+ # resolving to OpenAI on the mere presence of OPENAI_API_KEY meant that
376
+ # a user who happened to have the variable exported (or the `cloud`
377
+ # extra installed) had the text of every document they embedded sent to
378
+ # a third party, with nothing in the config saying so. Naming the
379
+ # provider is the request; `provider: openai` sends it there.
380
+ try:
381
+ return FastembedEmbedder(model_name=config.model)
382
+ except Exception: # nosec B110 -- provider chain: try the next backend
383
+ pass
384
+ try:
385
+ return SentenceTransformersEmbedder(
386
+ model_name=config.model,
387
+ batch_size=config.batch_size,
388
+ )
389
+ except ImportError as e:
390
+ raise RuntimeError(
391
+ "No embedding provider available. Install fastembed "
392
+ "(pip install fastembed) for local ONNX embeddings, or set "
393
+ "embeddings.provider to 'openai' or 'cohere' for a cloud one."
394
+ ) from e