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,516 @@
1
+ """Local file indexing for Markdown, RST, and text documentation.
2
+
3
+ Supports:
4
+ - Markdown (.md, .mdx)
5
+ - reStructuredText (.rst)
6
+ - Plain text (.txt)
7
+ - Code files with docstrings (optional)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import fnmatch
13
+ import os
14
+ import re
15
+ from collections.abc import Iterator
16
+ from dataclasses import dataclass, field
17
+ from datetime import UTC, datetime
18
+ from enum import StrEnum
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from docugraph.core.models import Document
23
+ from docugraph.ingestion.chunker import Chunker, ChunkerConfig
24
+
25
+
26
+ class FileType(StrEnum):
27
+ """Supported file types."""
28
+
29
+ MARKDOWN = "markdown"
30
+ RST = "rst"
31
+ TEXT = "text"
32
+ CODE = "code"
33
+ UNKNOWN = "unknown"
34
+
35
+
36
+ @dataclass
37
+ class LocalFileConfig:
38
+ """Configuration for local file indexing."""
39
+
40
+ # File patterns to include
41
+ include_patterns: list[str] = field(default_factory=lambda: ["*.md", "*.mdx", "*.rst", "*.txt"])
42
+
43
+ # File patterns to exclude
44
+ exclude_patterns: list[str] = field(
45
+ default_factory=lambda: [
46
+ "*.min.js",
47
+ "*.min.css",
48
+ "node_modules/*",
49
+ ".git/*",
50
+ "__pycache__/*",
51
+ "*.pyc",
52
+ ".venv/*",
53
+ "venv/*",
54
+ "dist/*",
55
+ "build/*",
56
+ ".tox/*",
57
+ ".pytest_cache/*",
58
+ ]
59
+ )
60
+
61
+ # Directories to skip entirely
62
+ skip_dirs: list[str] = field(
63
+ default_factory=lambda: [
64
+ ".git",
65
+ "node_modules",
66
+ "__pycache__",
67
+ ".venv",
68
+ "venv",
69
+ ".tox",
70
+ ".pytest_cache",
71
+ "dist",
72
+ "build",
73
+ ".eggs",
74
+ ]
75
+ )
76
+
77
+ # Maximum file size in bytes (default 1MB)
78
+ max_file_size: int = 1024 * 1024
79
+
80
+ # Whether to follow symlinks
81
+ follow_symlinks: bool = False
82
+
83
+ # Extract title from content
84
+ extract_title: bool = True
85
+
86
+ # Include hidden files (starting with .)
87
+ include_hidden: bool = False
88
+
89
+ # Encoding to use for reading files
90
+ encoding: str = "utf-8"
91
+
92
+
93
+ @dataclass
94
+ class IndexedFile:
95
+ """Represents an indexed file with metadata."""
96
+
97
+ path: Path
98
+ relative_path: str
99
+ file_type: FileType
100
+ content: str
101
+ title: str | None
102
+ size: int
103
+ modified_at: datetime
104
+ metadata: dict[str, Any] = field(default_factory=dict)
105
+
106
+ def to_document(self) -> Document:
107
+ """Convert to Document model."""
108
+ return Document(
109
+ source_path=str(self.relative_path),
110
+ title=self.title,
111
+ content=self.content,
112
+ content_type=self.file_type.value,
113
+ metadata={
114
+ "file_type": self.file_type.value,
115
+ "size": self.size,
116
+ "modified_at": self.modified_at.isoformat(),
117
+ **self.metadata,
118
+ },
119
+ )
120
+
121
+
122
+ class LocalFileIndexer:
123
+ """Indexes local files for documentation retrieval.
124
+
125
+ Walks directory trees, reads supported file types,
126
+ extracts content and metadata, and optionally chunks documents.
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ config: LocalFileConfig | None = None,
132
+ chunker: Chunker | None = None,
133
+ ) -> None:
134
+ """Initialize the local file indexer.
135
+
136
+ Args:
137
+ config: Indexer configuration
138
+ chunker: Optional chunker for document splitting
139
+ """
140
+ self._config = config or LocalFileConfig()
141
+ self._chunker = chunker
142
+
143
+ def _detect_file_type(self, path: Path) -> FileType:
144
+ """Detect file type from extension.
145
+
146
+ Args:
147
+ path: File path
148
+
149
+ Returns:
150
+ Detected file type
151
+ """
152
+ suffix = path.suffix.lower()
153
+
154
+ if suffix in (".md", ".mdx", ".markdown"):
155
+ return FileType.MARKDOWN
156
+ elif suffix == ".rst":
157
+ return FileType.RST
158
+ elif suffix == ".txt":
159
+ return FileType.TEXT
160
+ elif suffix in (".py", ".js", ".ts", ".java", ".go", ".rs", ".c", ".cpp", ".h"):
161
+ return FileType.CODE
162
+ else:
163
+ return FileType.UNKNOWN
164
+
165
+ def _matches_pattern(self, path: Path, patterns: list[str]) -> bool:
166
+ """Check if path matches any pattern.
167
+
168
+ Args:
169
+ path: File path
170
+ patterns: Glob patterns
171
+
172
+ Returns:
173
+ True if matches any pattern
174
+ """
175
+ path_str = str(path)
176
+ name = path.name
177
+
178
+ for pattern in patterns:
179
+ if fnmatch.fnmatch(name, pattern) or fnmatch.fnmatch(path_str, pattern):
180
+ return True
181
+ return False
182
+
183
+ def _should_skip_dir(self, dir_name: str) -> bool:
184
+ """Check if directory should be skipped.
185
+
186
+ Args:
187
+ dir_name: Directory name
188
+
189
+ Returns:
190
+ True if should skip
191
+ """
192
+ if dir_name in self._config.skip_dirs:
193
+ return True
194
+ return bool(not self._config.include_hidden and dir_name.startswith("."))
195
+
196
+ def _should_index_file(self, path: Path) -> bool:
197
+ """Check if file should be indexed.
198
+
199
+ Args:
200
+ path: File path
201
+
202
+ Returns:
203
+ True if should index
204
+ """
205
+ # Check hidden
206
+ if not self._config.include_hidden and path.name.startswith("."):
207
+ return False
208
+
209
+ # Check include patterns
210
+ if not self._matches_pattern(path, self._config.include_patterns):
211
+ return False
212
+
213
+ # Check exclude patterns
214
+ if self._matches_pattern(path, self._config.exclude_patterns):
215
+ return False
216
+
217
+ # Check size
218
+ try:
219
+ if path.stat().st_size > self._config.max_file_size:
220
+ return False
221
+ except OSError:
222
+ return False
223
+
224
+ return True
225
+
226
+ def _extract_title_from_markdown(self, content: str) -> str | None:
227
+ """Extract title from Markdown content.
228
+
229
+ Looks for:
230
+ 1. YAML frontmatter title
231
+ 2. First H1 heading
232
+
233
+ Args:
234
+ content: Markdown content
235
+
236
+ Returns:
237
+ Extracted title or None
238
+ """
239
+ # Check for YAML frontmatter
240
+ if content.startswith("---"):
241
+ end = content.find("---", 3)
242
+ if end > 0:
243
+ frontmatter = content[3:end]
244
+ title_match = re.search(
245
+ r"^title:\s*[\"']?(.+?)[\"']?\s*$", frontmatter, re.MULTILINE
246
+ )
247
+ if title_match:
248
+ return title_match.group(1).strip()
249
+
250
+ # Look for first H1 heading
251
+ h1_match = re.search(r"^#\s+(.+?)$", content, re.MULTILINE)
252
+ if h1_match:
253
+ return h1_match.group(1).strip()
254
+
255
+ return None
256
+
257
+ def _extract_title_from_rst(self, content: str) -> str | None:
258
+ """Extract title from RST content.
259
+
260
+ Looks for the first title (underlined with = or -).
261
+
262
+ Args:
263
+ content: RST content
264
+
265
+ Returns:
266
+ Extracted title or None
267
+ """
268
+ lines = content.split("\n")
269
+ for i, line in enumerate(lines):
270
+ if i > 0 and line and len(set(line)) == 1 and line[0] in "=-~^":
271
+ # Previous line is the title
272
+ title = lines[i - 1].strip()
273
+ if title:
274
+ return title
275
+ return None
276
+
277
+ def _extract_title(self, content: str, file_type: FileType, path: Path) -> str | None:
278
+ """Extract title from content based on file type.
279
+
280
+ Args:
281
+ content: File content
282
+ file_type: File type
283
+ path: File path
284
+
285
+ Returns:
286
+ Extracted title or filename-based title
287
+ """
288
+ if not self._config.extract_title:
289
+ return path.stem
290
+
291
+ title = None
292
+
293
+ if file_type == FileType.MARKDOWN:
294
+ title = self._extract_title_from_markdown(content)
295
+ elif file_type == FileType.RST:
296
+ title = self._extract_title_from_rst(content)
297
+
298
+ # Fallback to filename
299
+ if not title:
300
+ title = path.stem.replace("-", " ").replace("_", " ").title()
301
+
302
+ return title
303
+
304
+ def _read_file(self, path: Path) -> str | None:
305
+ """Read file content with encoding handling.
306
+
307
+ Args:
308
+ path: File path
309
+
310
+ Returns:
311
+ File content or None if unreadable
312
+ """
313
+ encodings = [self._config.encoding, "utf-8", "latin-1", "cp1252"]
314
+
315
+ for encoding in encodings:
316
+ try:
317
+ return path.read_text(encoding=encoding)
318
+ except (UnicodeDecodeError, LookupError):
319
+ continue
320
+ except OSError:
321
+ return None
322
+
323
+ return None
324
+
325
+ def index_file(self, path: Path, base_path: Path | None = None) -> IndexedFile | None:
326
+ """Index a single file.
327
+
328
+ Args:
329
+ path: File path to index
330
+ base_path: Base path for computing relative paths
331
+
332
+ Returns:
333
+ IndexedFile or None if not indexable
334
+ """
335
+ path = path.resolve()
336
+
337
+ if not path.is_file():
338
+ return None
339
+
340
+ if not self._should_index_file(path):
341
+ return None
342
+
343
+ content = self._read_file(path)
344
+ if content is None:
345
+ return None
346
+
347
+ file_type = self._detect_file_type(path)
348
+ title = self._extract_title(content, file_type, path)
349
+
350
+ # Compute relative path
351
+ if base_path:
352
+ try:
353
+ relative_path = str(path.relative_to(base_path))
354
+ except ValueError:
355
+ relative_path = str(path)
356
+ else:
357
+ relative_path = str(path)
358
+
359
+ # Get file stats
360
+ stat = path.stat()
361
+
362
+ return IndexedFile(
363
+ path=path,
364
+ relative_path=relative_path,
365
+ file_type=file_type,
366
+ content=content,
367
+ title=title,
368
+ size=stat.st_size,
369
+ modified_at=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
370
+ metadata={
371
+ "encoding": self._config.encoding,
372
+ },
373
+ )
374
+
375
+ def index_directory(
376
+ self,
377
+ directory: Path | str,
378
+ recursive: bool = True,
379
+ ) -> Iterator[IndexedFile]:
380
+ """Index all supported files in a directory.
381
+
382
+ Args:
383
+ directory: Directory to index
384
+ recursive: Whether to recurse into subdirectories
385
+
386
+ Yields:
387
+ IndexedFile for each indexed file
388
+ """
389
+ directory = Path(directory).resolve()
390
+
391
+ if not directory.is_dir():
392
+ raise ValueError(f"Not a directory: {directory}")
393
+
394
+ if recursive:
395
+ for root, dirs, files in os.walk(directory, followlinks=self._config.follow_symlinks):
396
+ # Filter directories in place
397
+ dirs[:] = [d for d in dirs if not self._should_skip_dir(d)]
398
+
399
+ for filename in files:
400
+ path = Path(root) / filename
401
+ indexed = self.index_file(path, base_path=directory)
402
+ if indexed:
403
+ yield indexed
404
+ else:
405
+ for path in directory.iterdir():
406
+ if path.is_file():
407
+ indexed = self.index_file(path, base_path=directory)
408
+ if indexed:
409
+ yield indexed
410
+
411
+ def index_to_documents(
412
+ self,
413
+ directory: Path | str,
414
+ recursive: bool = True,
415
+ ) -> list[Document]:
416
+ """Index directory and return Document objects.
417
+
418
+ Args:
419
+ directory: Directory to index
420
+ recursive: Whether to recurse into subdirectories
421
+
422
+ Returns:
423
+ List of Document objects
424
+ """
425
+ documents = []
426
+ for indexed_file in self.index_directory(directory, recursive):
427
+ documents.append(indexed_file.to_document())
428
+ return documents
429
+
430
+ def index_and_chunk(
431
+ self,
432
+ directory: Path | str,
433
+ recursive: bool = True,
434
+ ) -> tuple[list[Document], list[Any]]:
435
+ """Index directory and chunk documents.
436
+
437
+ Args:
438
+ directory: Directory to index
439
+ recursive: Whether to recurse into subdirectories
440
+
441
+ Returns:
442
+ Tuple of (documents, chunks)
443
+ """
444
+ if self._chunker is None:
445
+ self._chunker = Chunker(ChunkerConfig())
446
+
447
+ documents = []
448
+ all_chunks = []
449
+
450
+ for indexed_file in self.index_directory(directory, recursive):
451
+ doc = indexed_file.to_document()
452
+ documents.append(doc)
453
+
454
+ chunks = self._chunker.chunk_document(doc)
455
+ all_chunks.extend(chunks)
456
+
457
+ return documents, all_chunks
458
+
459
+ def get_stats(self, directory: Path | str, recursive: bool = True) -> dict[str, Any]:
460
+ """Get statistics about indexable files in a directory.
461
+
462
+ Args:
463
+ directory: Directory to analyze
464
+ recursive: Whether to recurse into subdirectories
465
+
466
+ Returns:
467
+ Statistics dict
468
+ """
469
+ stats: dict[str, Any] = {
470
+ "total_files": 0,
471
+ "total_size": 0,
472
+ "by_type": {},
473
+ "skipped": 0,
474
+ }
475
+
476
+ directory = Path(directory).resolve()
477
+
478
+ for root, dirs, files in os.walk(directory, followlinks=self._config.follow_symlinks):
479
+ dirs[:] = [d for d in dirs if not self._should_skip_dir(d)]
480
+
481
+ for filename in files:
482
+ path = Path(root) / filename
483
+
484
+ if self._should_index_file(path):
485
+ stats["total_files"] += 1
486
+ try:
487
+ size = path.stat().st_size
488
+ stats["total_size"] += size
489
+ except OSError:
490
+ pass
491
+
492
+ file_type = self._detect_file_type(path).value
493
+ stats["by_type"][file_type] = stats["by_type"].get(file_type, 0) + 1
494
+ else:
495
+ stats["skipped"] += 1
496
+
497
+ if not recursive:
498
+ break
499
+
500
+ return stats
501
+
502
+
503
+ def get_local_file_indexer(
504
+ config: LocalFileConfig | None = None,
505
+ chunker: Chunker | None = None,
506
+ ) -> LocalFileIndexer:
507
+ """Factory function to get a LocalFileIndexer instance.
508
+
509
+ Args:
510
+ config: Optional configuration
511
+ chunker: Optional chunker instance
512
+
513
+ Returns:
514
+ LocalFileIndexer instance
515
+ """
516
+ return LocalFileIndexer(config=config, chunker=chunker)
@@ -0,0 +1 @@
1
+ """User interfaces: CLI and MCP server."""