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.
- docugraph/__init__.py +24 -0
- docugraph/_version.py +28 -0
- docugraph/agents/__init__.py +27 -0
- docugraph/agents/coding_agent.py +532 -0
- docugraph/agents/tools/__init__.py +62 -0
- docugraph/agents/tools/graph_query.py +203 -0
- docugraph/agents/tools/memory_ops.py +305 -0
- docugraph/agents/tools/search_code.py +305 -0
- docugraph/agents/tools/search_docs.py +148 -0
- docugraph/core/__init__.py +34 -0
- docugraph/core/config.py +183 -0
- docugraph/core/embeddings.py +394 -0
- docugraph/core/llm.py +892 -0
- docugraph/core/models.py +145 -0
- docugraph/ingestion/__init__.py +64 -0
- docugraph/ingestion/api_docs.py +646 -0
- docugraph/ingestion/chunker.py +382 -0
- docugraph/ingestion/crawler.py +305 -0
- docugraph/ingestion/git_indexer.py +556 -0
- docugraph/ingestion/local_files.py +516 -0
- docugraph/interfaces/__init__.py +1 -0
- docugraph/interfaces/cli.py +983 -0
- docugraph/interfaces/mcp_server.py +918 -0
- docugraph/retrieval/__init__.py +54 -0
- docugraph/retrieval/context_builder.py +522 -0
- docugraph/retrieval/hybrid_search.py +534 -0
- docugraph/retrieval/reranker.py +532 -0
- docugraph/storage/__init__.py +14 -0
- docugraph/storage/graph_store.py +602 -0
- docugraph/storage/memory_store.py +529 -0
- docugraph/storage/vector_store.py +367 -0
- docugraph-0.1.0.dist-info/METADATA +354 -0
- docugraph-0.1.0.dist-info/RECORD +37 -0
- docugraph-0.1.0.dist-info/WHEEL +4 -0
- docugraph-0.1.0.dist-info/entry_points.txt +3 -0
- docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
- docugraph-0.1.0.dist-info/licenses/NOTICE +10 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Search code tool for the coding agent.
|
|
2
|
+
|
|
3
|
+
Provides code-specific search with language and structure awareness.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def search_code(
|
|
13
|
+
query: str,
|
|
14
|
+
language: str | None = None,
|
|
15
|
+
top_k: int = 10,
|
|
16
|
+
include_context: bool = True,
|
|
17
|
+
_context: Any = None,
|
|
18
|
+
**_kwargs: Any,
|
|
19
|
+
) -> list[dict[str, Any]]:
|
|
20
|
+
"""Search for code examples in indexed documentation, from synchronous code.
|
|
21
|
+
|
|
22
|
+
A thin wrapper over ``search_code_async`` for callers that have no event
|
|
23
|
+
loop. Async callers -- the agent loop included -- await
|
|
24
|
+
``search_code_async`` instead: this drives a fresh loop and raises if one
|
|
25
|
+
is already running.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
query: Search query (can be natural language or code snippet)
|
|
29
|
+
language: Filter by programming language (python, javascript, etc.)
|
|
30
|
+
top_k: Number of results to return
|
|
31
|
+
include_context: Include surrounding context
|
|
32
|
+
_context: Agent execution context (injected)
|
|
33
|
+
**kwargs: Additional arguments
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
List of code results with code, path, language, and context
|
|
37
|
+
"""
|
|
38
|
+
return asyncio.run(
|
|
39
|
+
search_code_async(
|
|
40
|
+
query=query,
|
|
41
|
+
language=language,
|
|
42
|
+
top_k=top_k,
|
|
43
|
+
include_context=include_context,
|
|
44
|
+
_context=_context,
|
|
45
|
+
**_kwargs,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def search_code_async(
|
|
51
|
+
query: str,
|
|
52
|
+
language: str | None = None,
|
|
53
|
+
top_k: int = 10,
|
|
54
|
+
include_context: bool = True,
|
|
55
|
+
_context: Any = None,
|
|
56
|
+
**_kwargs: Any,
|
|
57
|
+
) -> list[dict[str, Any]]:
|
|
58
|
+
"""Search for code examples in indexed documentation.
|
|
59
|
+
|
|
60
|
+
This is the implementation; ``search_code`` is the synchronous wrapper.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
query: Search query (can be natural language or code snippet)
|
|
64
|
+
language: Filter by programming language (python, javascript, etc.)
|
|
65
|
+
top_k: Number of results to return
|
|
66
|
+
include_context: Include surrounding context
|
|
67
|
+
_context: Agent execution context (injected)
|
|
68
|
+
**kwargs: Additional arguments
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
List of code results with code, path, language, and context
|
|
72
|
+
"""
|
|
73
|
+
from docugraph.retrieval.hybrid_search import (
|
|
74
|
+
HybridRetriever,
|
|
75
|
+
HybridSearchConfig,
|
|
76
|
+
SearchMode,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Enhance query for code search
|
|
80
|
+
code_query = query
|
|
81
|
+
if language:
|
|
82
|
+
code_query = f"{language} {query}"
|
|
83
|
+
|
|
84
|
+
config = HybridSearchConfig(include_graph=False)
|
|
85
|
+
retriever = HybridRetriever(config=config)
|
|
86
|
+
|
|
87
|
+
results = await retriever.asearch(
|
|
88
|
+
query=code_query,
|
|
89
|
+
top_k=top_k * 2, # Get more results for filtering
|
|
90
|
+
mode=SearchMode.HYBRID,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Filter and format code results
|
|
94
|
+
formatted = []
|
|
95
|
+
for result in results:
|
|
96
|
+
chunk = result.chunk
|
|
97
|
+
content = chunk.content
|
|
98
|
+
|
|
99
|
+
# Skip if doesn't look like code
|
|
100
|
+
if not _contains_code(content):
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# Filter by language if specified
|
|
104
|
+
if language:
|
|
105
|
+
detected_lang = _detect_language(content)
|
|
106
|
+
if detected_lang and language.lower() not in detected_lang.lower():
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# Extract code blocks
|
|
110
|
+
code_blocks = _extract_code_blocks(content)
|
|
111
|
+
if not code_blocks:
|
|
112
|
+
# Use full content if no blocks found
|
|
113
|
+
code_blocks = [{"code": content, "language": _detect_language(content) or ""}]
|
|
114
|
+
|
|
115
|
+
for block in code_blocks:
|
|
116
|
+
formatted.append(
|
|
117
|
+
{
|
|
118
|
+
"code": block["code"],
|
|
119
|
+
"language": block.get("language", ""),
|
|
120
|
+
"path": chunk.metadata.get("source_path")
|
|
121
|
+
or chunk.metadata.get("source_url", "Unknown"),
|
|
122
|
+
"title": chunk.metadata.get("title", ""),
|
|
123
|
+
"score": result.score,
|
|
124
|
+
"context": chunk.content if include_context else "",
|
|
125
|
+
"metadata": chunk.metadata,
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if len(formatted) >= top_k:
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
return formatted[:top_k]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def search_symbols(
|
|
136
|
+
symbol_name: str,
|
|
137
|
+
symbol_type: str | None = None,
|
|
138
|
+
language: str | None = None,
|
|
139
|
+
top_k: int = 10,
|
|
140
|
+
_context: Any = None,
|
|
141
|
+
**_kwargs: Any,
|
|
142
|
+
) -> list[dict[str, Any]]:
|
|
143
|
+
"""Search for code symbols (functions, classes, methods).
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
symbol_name: Name of the symbol to search for
|
|
147
|
+
symbol_type: Type filter (function, class, method, variable)
|
|
148
|
+
language: Programming language filter
|
|
149
|
+
top_k: Number of results to return
|
|
150
|
+
_context: Agent execution context (injected)
|
|
151
|
+
**kwargs: Additional arguments
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
List of symbol results
|
|
155
|
+
"""
|
|
156
|
+
# Build query
|
|
157
|
+
query_parts = [symbol_name]
|
|
158
|
+
if symbol_type:
|
|
159
|
+
query_parts.append(symbol_type)
|
|
160
|
+
if language:
|
|
161
|
+
query_parts.append(language)
|
|
162
|
+
|
|
163
|
+
query = " ".join(query_parts)
|
|
164
|
+
|
|
165
|
+
# Search with code focus
|
|
166
|
+
results = search_code(
|
|
167
|
+
query=query,
|
|
168
|
+
language=language,
|
|
169
|
+
top_k=top_k,
|
|
170
|
+
include_context=True,
|
|
171
|
+
_context=_context,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Filter by symbol name presence
|
|
175
|
+
filtered = []
|
|
176
|
+
for r in results:
|
|
177
|
+
code = r.get("code", "")
|
|
178
|
+
if symbol_name.lower() in code.lower():
|
|
179
|
+
filtered.append(r)
|
|
180
|
+
|
|
181
|
+
return filtered[:top_k]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _contains_code(content: str) -> bool:
|
|
185
|
+
"""Check if content likely contains code.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
content: Text content to check
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if content appears to contain code
|
|
192
|
+
"""
|
|
193
|
+
# Check for code block markers
|
|
194
|
+
if "```" in content or "~~~" in content:
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
# Check for common code patterns
|
|
198
|
+
code_indicators = [
|
|
199
|
+
"def ",
|
|
200
|
+
"class ",
|
|
201
|
+
"function ",
|
|
202
|
+
"import ",
|
|
203
|
+
"from ",
|
|
204
|
+
"const ",
|
|
205
|
+
"let ",
|
|
206
|
+
"var ",
|
|
207
|
+
"async ",
|
|
208
|
+
"await ",
|
|
209
|
+
"return ",
|
|
210
|
+
"=>",
|
|
211
|
+
"() {",
|
|
212
|
+
"(): ",
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
return any(indicator in content for indicator in code_indicators)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _detect_language(content: str) -> str | None:
|
|
219
|
+
"""Detect programming language from content.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
content: Code content
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Detected language or None
|
|
226
|
+
"""
|
|
227
|
+
# Check markdown code block language
|
|
228
|
+
import re
|
|
229
|
+
|
|
230
|
+
match = re.search(r"```(\w+)", content)
|
|
231
|
+
if match:
|
|
232
|
+
return match.group(1)
|
|
233
|
+
|
|
234
|
+
# Heuristic detection
|
|
235
|
+
if "def " in content and "import " in content:
|
|
236
|
+
return "python"
|
|
237
|
+
if "function " in content or "const " in content or "=>" in content:
|
|
238
|
+
return "javascript"
|
|
239
|
+
if "func " in content and "package " in content:
|
|
240
|
+
return "go"
|
|
241
|
+
if "fn " in content and "let " in content and "mut " in content:
|
|
242
|
+
return "rust"
|
|
243
|
+
if "public class " in content or "public static " in content:
|
|
244
|
+
return "java"
|
|
245
|
+
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _extract_code_blocks(content: str) -> list[dict[str, str]]:
|
|
250
|
+
"""Extract code blocks from markdown content.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
content: Markdown content
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
List of code blocks with language and code
|
|
257
|
+
"""
|
|
258
|
+
import re
|
|
259
|
+
|
|
260
|
+
blocks = []
|
|
261
|
+
|
|
262
|
+
# Match fenced code blocks
|
|
263
|
+
pattern = r"```(\w*)\n(.*?)```"
|
|
264
|
+
matches = re.findall(pattern, content, re.DOTALL)
|
|
265
|
+
|
|
266
|
+
for lang, code in matches:
|
|
267
|
+
blocks.append(
|
|
268
|
+
{
|
|
269
|
+
"language": lang or _detect_language(code) or "",
|
|
270
|
+
"code": code.strip(),
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Match indented code blocks (4 spaces)
|
|
275
|
+
if not blocks:
|
|
276
|
+
lines = content.split("\n")
|
|
277
|
+
code_lines = []
|
|
278
|
+
in_code = False
|
|
279
|
+
|
|
280
|
+
for line in lines:
|
|
281
|
+
if line.startswith(" ") or line.startswith("\t"):
|
|
282
|
+
code_lines.append(line[4:] if line.startswith(" ") else line[1:])
|
|
283
|
+
in_code = True
|
|
284
|
+
elif in_code and line.strip() == "":
|
|
285
|
+
code_lines.append("")
|
|
286
|
+
elif in_code:
|
|
287
|
+
if code_lines:
|
|
288
|
+
blocks.append(
|
|
289
|
+
{
|
|
290
|
+
"language": _detect_language("\n".join(code_lines)) or "",
|
|
291
|
+
"code": "\n".join(code_lines).strip(),
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
code_lines = []
|
|
295
|
+
in_code = False
|
|
296
|
+
|
|
297
|
+
if code_lines:
|
|
298
|
+
blocks.append(
|
|
299
|
+
{
|
|
300
|
+
"language": _detect_language("\n".join(code_lines)) or "",
|
|
301
|
+
"code": "\n".join(code_lines).strip(),
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
return blocks
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Search documentation tool for the coding agent.
|
|
2
|
+
|
|
3
|
+
Provides vector and hybrid search capabilities over indexed documentation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def search_docs(
|
|
13
|
+
query: str,
|
|
14
|
+
top_k: int = 5,
|
|
15
|
+
mode: str = "hybrid",
|
|
16
|
+
include_graph: bool = False,
|
|
17
|
+
_context: Any = None,
|
|
18
|
+
**_kwargs: Any,
|
|
19
|
+
) -> list[dict[str, Any]]:
|
|
20
|
+
"""Search indexed documentation from synchronous code.
|
|
21
|
+
|
|
22
|
+
A thin wrapper over ``search_docs_async`` for callers that have no event
|
|
23
|
+
loop. Async callers -- the agent loop included -- await
|
|
24
|
+
``search_docs_async`` instead: this drives a fresh loop and raises if one
|
|
25
|
+
is already running.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
query: Search query text
|
|
29
|
+
top_k: Number of results to return
|
|
30
|
+
mode: Search mode (vector, keyword, hybrid, all)
|
|
31
|
+
include_graph: Include knowledge graph in search
|
|
32
|
+
_context: Agent execution context (injected)
|
|
33
|
+
**kwargs: Additional arguments
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
List of search results with content, source, and score
|
|
37
|
+
"""
|
|
38
|
+
return asyncio.run(
|
|
39
|
+
search_docs_async(
|
|
40
|
+
query=query,
|
|
41
|
+
top_k=top_k,
|
|
42
|
+
mode=mode,
|
|
43
|
+
include_graph=include_graph,
|
|
44
|
+
_context=_context,
|
|
45
|
+
**_kwargs,
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def search_docs_async(
|
|
51
|
+
query: str,
|
|
52
|
+
top_k: int = 5,
|
|
53
|
+
mode: str = "hybrid",
|
|
54
|
+
include_graph: bool = False,
|
|
55
|
+
_context: Any = None,
|
|
56
|
+
**_kwargs: Any,
|
|
57
|
+
) -> list[dict[str, Any]]:
|
|
58
|
+
"""Search indexed documentation.
|
|
59
|
+
|
|
60
|
+
This is the implementation; ``search_docs`` is the synchronous wrapper.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
query: Search query text
|
|
64
|
+
top_k: Number of results to return
|
|
65
|
+
mode: Search mode (vector, keyword, hybrid, all)
|
|
66
|
+
include_graph: Include knowledge graph in search
|
|
67
|
+
_context: Agent execution context (injected)
|
|
68
|
+
**kwargs: Additional arguments
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
List of search results with content, source, and score
|
|
72
|
+
"""
|
|
73
|
+
from docugraph.retrieval.hybrid_search import (
|
|
74
|
+
FusionStrategy,
|
|
75
|
+
HybridRetriever,
|
|
76
|
+
HybridSearchConfig,
|
|
77
|
+
SearchMode,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
mode_map = {
|
|
81
|
+
"vector": SearchMode.VECTOR,
|
|
82
|
+
"keyword": SearchMode.KEYWORD,
|
|
83
|
+
"hybrid": SearchMode.HYBRID,
|
|
84
|
+
"all": SearchMode.ALL,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
config = HybridSearchConfig(include_graph=include_graph)
|
|
88
|
+
retriever = HybridRetriever(config=config)
|
|
89
|
+
|
|
90
|
+
results = await retriever.asearch(
|
|
91
|
+
query=query,
|
|
92
|
+
top_k=top_k,
|
|
93
|
+
mode=mode_map.get(mode, SearchMode.HYBRID),
|
|
94
|
+
fusion=FusionStrategy.RRF,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Format results
|
|
98
|
+
formatted = []
|
|
99
|
+
for result in results:
|
|
100
|
+
chunk = result.chunk
|
|
101
|
+
formatted.append(
|
|
102
|
+
{
|
|
103
|
+
"content": chunk.content,
|
|
104
|
+
"source": chunk.metadata.get("source_url")
|
|
105
|
+
or chunk.metadata.get("source_path", "Unknown"),
|
|
106
|
+
"title": chunk.metadata.get("title", "Untitled"),
|
|
107
|
+
"section": chunk.metadata.get("section_title", ""),
|
|
108
|
+
"score": result.score,
|
|
109
|
+
"sources": result.sources,
|
|
110
|
+
"metadata": chunk.metadata,
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return formatted
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def search_by_source(
|
|
118
|
+
query: str,
|
|
119
|
+
source_pattern: str,
|
|
120
|
+
top_k: int = 10,
|
|
121
|
+
_context: Any = None,
|
|
122
|
+
**_kwargs: Any,
|
|
123
|
+
) -> list[dict[str, Any]]:
|
|
124
|
+
"""Search documentation filtered by source pattern.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
query: Search query text
|
|
128
|
+
source_pattern: Pattern to filter sources (e.g., "react", "fastapi")
|
|
129
|
+
top_k: Number of results to return
|
|
130
|
+
_context: Agent execution context (injected)
|
|
131
|
+
**kwargs: Additional arguments
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of filtered search results
|
|
135
|
+
"""
|
|
136
|
+
# Get more results than needed for filtering
|
|
137
|
+
results = search_docs(query=query, top_k=top_k * 3, _context=_context)
|
|
138
|
+
|
|
139
|
+
# Filter by source pattern
|
|
140
|
+
pattern_lower = source_pattern.lower()
|
|
141
|
+
filtered = [
|
|
142
|
+
r
|
|
143
|
+
for r in results
|
|
144
|
+
if pattern_lower in r.get("source", "").lower()
|
|
145
|
+
or pattern_lower in r.get("title", "").lower()
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
return filtered[:top_k]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Core configuration, models, and provider abstractions."""
|
|
2
|
+
|
|
3
|
+
from docugraph.core.config import Config, get_config
|
|
4
|
+
from docugraph.core.embeddings import EmbeddingProviderBase, get_embedder
|
|
5
|
+
from docugraph.core.llm import (
|
|
6
|
+
LLMProviderBase,
|
|
7
|
+
get_graphiti_llm_client,
|
|
8
|
+
get_llm_client,
|
|
9
|
+
get_llm_provider,
|
|
10
|
+
is_ollama_available,
|
|
11
|
+
)
|
|
12
|
+
from docugraph.core.models import Chunk, Document, Entity, MemoryEntry, Relationship, SearchResult
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
# Config
|
|
16
|
+
"Config",
|
|
17
|
+
"get_config",
|
|
18
|
+
# Models
|
|
19
|
+
"Document",
|
|
20
|
+
"Chunk",
|
|
21
|
+
"Entity",
|
|
22
|
+
"Relationship",
|
|
23
|
+
"MemoryEntry",
|
|
24
|
+
"SearchResult",
|
|
25
|
+
# Embeddings
|
|
26
|
+
"EmbeddingProviderBase",
|
|
27
|
+
"get_embedder",
|
|
28
|
+
# LLM
|
|
29
|
+
"LLMProviderBase",
|
|
30
|
+
"get_llm_provider",
|
|
31
|
+
"get_llm_client",
|
|
32
|
+
"get_graphiti_llm_client",
|
|
33
|
+
"is_ollama_available",
|
|
34
|
+
]
|
docugraph/core/config.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Configuration management for DocuGraph AI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
12
|
+
|
|
13
|
+
from docugraph import _version
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmbeddingProvider(StrEnum):
|
|
17
|
+
"""Supported embedding providers."""
|
|
18
|
+
|
|
19
|
+
AUTO = "auto"
|
|
20
|
+
FASTEMBED = "fastembed"
|
|
21
|
+
SENTENCE_TRANSFORMERS = "sentence-transformers"
|
|
22
|
+
OLLAMA = "ollama"
|
|
23
|
+
OPENAI = "openai"
|
|
24
|
+
COHERE = "cohere"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LLMProvider(StrEnum):
|
|
28
|
+
"""Supported LLM providers."""
|
|
29
|
+
|
|
30
|
+
AUTO = "auto"
|
|
31
|
+
OLLAMA = "ollama"
|
|
32
|
+
OPENAI = "openai"
|
|
33
|
+
ANTHROPIC = "anthropic"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StorageConfig(BaseModel):
|
|
37
|
+
"""Storage configuration."""
|
|
38
|
+
|
|
39
|
+
data_dir: Path = Field(default=Path.home() / ".docugraph" / "data")
|
|
40
|
+
vector_db: str = "lancedb"
|
|
41
|
+
graph_db: str = "kuzu"
|
|
42
|
+
|
|
43
|
+
def model_post_init(self, __context: Any) -> None:
|
|
44
|
+
"""Ensure data directory exists."""
|
|
45
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EmbeddingConfig(BaseModel):
|
|
49
|
+
"""Embedding configuration (local-first, LLM-agnostic).
|
|
50
|
+
|
|
51
|
+
provider=auto resolves: fastembed (local, pure ONNX, no torch) ->
|
|
52
|
+
sentence-transformers (only if already installed, requires torch). It
|
|
53
|
+
reads nothing from the environment, so a cloud provider is used only when
|
|
54
|
+
one is named here.
|
|
55
|
+
|
|
56
|
+
``model`` has no default, because the backends do not share one. Each local
|
|
57
|
+
backend names its own model in its constructor (fastembed's bge-small,
|
|
58
|
+
sentence-transformers' MiniLM, Ollama's nomic-embed-text); a *cloud* backend
|
|
59
|
+
has none, so leaving this unset with ``provider: openai`` or ``cohere`` is an
|
|
60
|
+
error rather than a guess. That distinction matters: a single shared default
|
|
61
|
+
here was the local fastembed name, so naming a cloud provider without naming
|
|
62
|
+
a model would have sent ``BAAI/bge-small-en-v1.5`` to an API that has never
|
|
63
|
+
heard of it.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
provider: EmbeddingProvider = EmbeddingProvider.AUTO
|
|
67
|
+
model: str | None = None # local backends have their own; cloud has none
|
|
68
|
+
dimensions: int | None = None # measured from the model unless set here
|
|
69
|
+
batch_size: int = 32
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LLMConfig(BaseModel):
|
|
73
|
+
"""LLM configuration for extraction and reranking.
|
|
74
|
+
|
|
75
|
+
``model`` defaults to the ``"auto"`` sentinel, which means "whichever model
|
|
76
|
+
this provider offers". Only a local Ollama server can answer that, because
|
|
77
|
+
it is the only provider docugraph can ask what it has installed. A cloud
|
|
78
|
+
provider needs the model named: every name that could be written here has a
|
|
79
|
+
retirement date on the provider's calendar, and a retired name fails every
|
|
80
|
+
call with a 404 the user cannot act on.
|
|
81
|
+
|
|
82
|
+
``temperature`` is unset by default and is sent only when set. That is not
|
|
83
|
+
tidiness: OpenAI's reasoning models reject any temperature other than their
|
|
84
|
+
fixed default, so a default of 0.0 here made every call to one fail with
|
|
85
|
+
``unsupported value``. Set ``temperature: 0.0`` explicitly for deterministic
|
|
86
|
+
local output.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
provider: LLMProvider = LLMProvider.AUTO
|
|
90
|
+
model: str = "auto"
|
|
91
|
+
# Graphiti asks for two models: a large one for extraction and a small one
|
|
92
|
+
# for the cheaper summarization and edge-dedup passes. Unset means "the same
|
|
93
|
+
# as `model`, whatever that turns out to be" -- not a hard-coded model name,
|
|
94
|
+
# which is what it was.
|
|
95
|
+
small_model: str | None = None
|
|
96
|
+
temperature: float | None = None
|
|
97
|
+
max_tokens: int = 4096
|
|
98
|
+
|
|
99
|
+
# API keys (loaded from environment)
|
|
100
|
+
openai_api_key: str | None = None
|
|
101
|
+
anthropic_api_key: str | None = None
|
|
102
|
+
|
|
103
|
+
def model_post_init(self, __context: Any) -> None:
|
|
104
|
+
"""Load API keys from environment."""
|
|
105
|
+
self.openai_api_key = os.getenv("OPENAI_API_KEY")
|
|
106
|
+
self.anthropic_api_key = os.getenv("ANTHROPIC_API_KEY")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CrawlerConfig(BaseModel):
|
|
110
|
+
"""Web crawler configuration."""
|
|
111
|
+
|
|
112
|
+
max_concurrent: int = 5
|
|
113
|
+
rate_limit: float = 2.0 # requests per second
|
|
114
|
+
user_agent: str = f"DocuGraph-AI/{_version.__version__}"
|
|
115
|
+
respect_robots: bool = True
|
|
116
|
+
cache_ttl: int = 86400 # 24 hours
|
|
117
|
+
timeout: int = 30 # seconds
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Config(BaseSettings):
|
|
121
|
+
"""Main configuration for DocuGraph AI."""
|
|
122
|
+
|
|
123
|
+
model_config = SettingsConfigDict(
|
|
124
|
+
env_prefix="DOCUGRAPH_",
|
|
125
|
+
env_nested_delimiter="__",
|
|
126
|
+
extra="ignore",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
storage: StorageConfig = Field(default_factory=StorageConfig)
|
|
130
|
+
embeddings: EmbeddingConfig = Field(default_factory=EmbeddingConfig)
|
|
131
|
+
llm: LLMConfig = Field(default_factory=LLMConfig)
|
|
132
|
+
crawler: CrawlerConfig = Field(default_factory=CrawlerConfig)
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_yaml(cls, path: Path) -> "Config":
|
|
136
|
+
"""Load configuration from a YAML file."""
|
|
137
|
+
if not path.exists():
|
|
138
|
+
return cls()
|
|
139
|
+
|
|
140
|
+
with open(path) as f:
|
|
141
|
+
data = yaml.safe_load(f) or {}
|
|
142
|
+
|
|
143
|
+
return cls(**data)
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def load(cls) -> "Config":
|
|
147
|
+
"""Load configuration from default locations."""
|
|
148
|
+
# Check for config file in order of precedence
|
|
149
|
+
config_paths = [
|
|
150
|
+
Path.cwd() / "docugraph.yaml",
|
|
151
|
+
Path.cwd() / "docugraph.yml",
|
|
152
|
+
Path.cwd() / ".docugraph.yaml",
|
|
153
|
+
Path.home() / ".docugraph" / "config.yaml",
|
|
154
|
+
Path.home() / ".config" / "docugraph" / "config.yaml",
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
for path in config_paths:
|
|
158
|
+
if path.exists():
|
|
159
|
+
return cls.from_yaml(path)
|
|
160
|
+
|
|
161
|
+
return cls()
|
|
162
|
+
|
|
163
|
+
def save(self, path: Path | None = None) -> None:
|
|
164
|
+
"""Save configuration to a YAML file."""
|
|
165
|
+
if path is None:
|
|
166
|
+
path = Path.home() / ".docugraph" / "config.yaml"
|
|
167
|
+
|
|
168
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
|
|
170
|
+
data = self.model_dump(mode="json")
|
|
171
|
+
with open(path, "w") as f:
|
|
172
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@lru_cache
|
|
176
|
+
def get_config() -> Config:
|
|
177
|
+
"""Get the singleton configuration instance."""
|
|
178
|
+
return Config.load()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def reset_config() -> None:
|
|
182
|
+
"""Reset the cached configuration (useful for testing)."""
|
|
183
|
+
get_config.cache_clear()
|