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,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
+ ]
@@ -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()