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,54 @@
1
+ """Retrieval system: hybrid search, reranking, and context building."""
2
+
3
+ from docugraph.retrieval.context_builder import (
4
+ BuiltContext,
5
+ ContextBuilder,
6
+ ContextConfig,
7
+ ContextFormat,
8
+ ContextSource,
9
+ get_context_builder,
10
+ )
11
+ from docugraph.retrieval.hybrid_search import (
12
+ FusionStrategy,
13
+ HybridResult,
14
+ HybridRetriever,
15
+ HybridSearchConfig,
16
+ SearchMode,
17
+ get_hybrid_retriever,
18
+ )
19
+ from docugraph.retrieval.reranker import (
20
+ CohereReranker,
21
+ FastembedReranker,
22
+ LLMReranker,
23
+ PassthroughReranker,
24
+ RerankerBase,
25
+ RerankerType,
26
+ RerankResult,
27
+ get_reranker,
28
+ )
29
+
30
+ __all__ = [
31
+ # Hybrid Search
32
+ "FusionStrategy",
33
+ "SearchMode",
34
+ "HybridSearchConfig",
35
+ "HybridResult",
36
+ "HybridRetriever",
37
+ "get_hybrid_retriever",
38
+ # Reranking
39
+ "RerankerType",
40
+ "RerankResult",
41
+ "RerankerBase",
42
+ "FastembedReranker",
43
+ "CohereReranker",
44
+ "LLMReranker",
45
+ "PassthroughReranker",
46
+ "get_reranker",
47
+ # Context Building
48
+ "ContextFormat",
49
+ "ContextConfig",
50
+ "ContextSource",
51
+ "BuiltContext",
52
+ "ContextBuilder",
53
+ "get_context_builder",
54
+ ]
@@ -0,0 +1,522 @@
1
+ """Context builder for assembling LLM-ready context from search results.
2
+
3
+ Handles:
4
+ - Token budget management
5
+ - Source attribution formatting
6
+ - Memory context injection
7
+ - Deduplication and prioritization
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from enum import StrEnum
14
+ from typing import Any
15
+
16
+ from docugraph.core.models import Chunk, MemoryEntry, SearchResult
17
+ from docugraph.retrieval.hybrid_search import HybridResult
18
+ from docugraph.retrieval.reranker import RerankResult
19
+
20
+
21
+ class ContextFormat(StrEnum):
22
+ """Output format for context."""
23
+
24
+ MARKDOWN = "markdown" # Markdown with headers and code blocks
25
+ PLAIN = "plain" # Plain text with separators
26
+ XML = "xml" # XML tags for structured parsing
27
+ JSON = "json" # JSON for programmatic use
28
+
29
+
30
+ @dataclass
31
+ class ContextConfig:
32
+ """Configuration for context building."""
33
+
34
+ # Token limits
35
+ max_tokens: int = 8000
36
+ reserve_tokens: int = 1000 # Reserve for response
37
+ chunk_token_estimate: float = 1.3 # chars per token estimate
38
+
39
+ # Content settings
40
+ include_sources: bool = True
41
+ include_metadata: bool = False
42
+ include_scores: bool = False
43
+
44
+ # Deduplication
45
+ deduplicate: bool = True
46
+ similarity_threshold: float = 0.9 # For content dedup
47
+
48
+ # Memory injection
49
+ include_memory: bool = True
50
+ memory_position: str = "before" # before, after, or interleaved
51
+
52
+ # Formatting
53
+ format: ContextFormat = ContextFormat.MARKDOWN
54
+ section_separator: str = "\n\n---\n\n"
55
+ max_chunk_chars: int = 2000 # Truncate very long chunks
56
+
57
+
58
+ @dataclass
59
+ class ContextSource:
60
+ """A source included in the context."""
61
+
62
+ chunk_id: str
63
+ content: str
64
+ source_url: str | None = None
65
+ source_path: str | None = None
66
+ title: str | None = None
67
+ score: float | None = None
68
+ metadata: dict[str, Any] = field(default_factory=dict)
69
+
70
+
71
+ @dataclass
72
+ class BuiltContext:
73
+ """Result of context building."""
74
+
75
+ context: str
76
+ sources: list[ContextSource]
77
+ total_tokens: int
78
+ truncated: bool
79
+ memory_included: bool
80
+ source_count: int
81
+
82
+ def to_dict(self) -> dict[str, Any]:
83
+ """Convert to dictionary for serialization."""
84
+ return {
85
+ "context": self.context,
86
+ "sources": [
87
+ {
88
+ "chunk_id": s.chunk_id,
89
+ "source_url": s.source_url,
90
+ "source_path": s.source_path,
91
+ "title": s.title,
92
+ "score": s.score,
93
+ }
94
+ for s in self.sources
95
+ ],
96
+ "total_tokens": self.total_tokens,
97
+ "truncated": self.truncated,
98
+ "memory_included": self.memory_included,
99
+ "source_count": self.source_count,
100
+ }
101
+
102
+
103
+ class ContextBuilder:
104
+ """Builds LLM-ready context from search results.
105
+
106
+ Combines search results with optional memory context,
107
+ manages token budgets, and formats for LLM consumption.
108
+ """
109
+
110
+ def __init__(self, config: ContextConfig | None = None) -> None:
111
+ """Initialize context builder.
112
+
113
+ Args:
114
+ config: Context building configuration
115
+ """
116
+ self._config = config or ContextConfig()
117
+
118
+ def _estimate_tokens(self, text: str) -> int:
119
+ """Estimate token count for text.
120
+
121
+ Uses a simple character-based estimate. For production,
122
+ consider using tiktoken or similar.
123
+
124
+ Args:
125
+ text: Text to estimate
126
+
127
+ Returns:
128
+ Estimated token count
129
+ """
130
+ return int(len(text) / self._config.chunk_token_estimate)
131
+
132
+ def _truncate_chunk(self, content: str) -> str:
133
+ """Truncate chunk content if too long.
134
+
135
+ Args:
136
+ content: Chunk content
137
+
138
+ Returns:
139
+ Truncated content with indicator if needed
140
+ """
141
+ max_chars = self._config.max_chunk_chars
142
+ if len(content) <= max_chars:
143
+ return content
144
+ return content[:max_chars] + "\n... [truncated]"
145
+
146
+ def _normalize_results(
147
+ self,
148
+ results: list[SearchResult | HybridResult | RerankResult | Chunk],
149
+ ) -> list[ContextSource]:
150
+ """Normalize various result types to ContextSource.
151
+
152
+ Args:
153
+ results: Mixed result types from search
154
+
155
+ Returns:
156
+ List of normalized ContextSource objects
157
+ """
158
+ sources: list[ContextSource] = []
159
+
160
+ for result in results:
161
+ if isinstance(result, (SearchResult, HybridResult)):
162
+ chunk = result.chunk
163
+ score = result.score
164
+ elif isinstance(result, RerankResult):
165
+ chunk = result.chunk
166
+ score = result.final_score
167
+ elif isinstance(result, Chunk):
168
+ chunk = result
169
+ score = None
170
+ else:
171
+ continue
172
+
173
+ sources.append(
174
+ ContextSource(
175
+ chunk_id=chunk.id,
176
+ content=self._truncate_chunk(chunk.content),
177
+ source_url=chunk.metadata.get("source_url"),
178
+ source_path=chunk.metadata.get("source_path"),
179
+ title=chunk.metadata.get("title"),
180
+ score=score,
181
+ metadata=chunk.metadata,
182
+ )
183
+ )
184
+
185
+ return sources
186
+
187
+ def _deduplicate_sources(
188
+ self,
189
+ sources: list[ContextSource],
190
+ ) -> list[ContextSource]:
191
+ """Remove duplicate or near-duplicate sources.
192
+
193
+ Uses simple content similarity for deduplication.
194
+
195
+ Args:
196
+ sources: Sources to deduplicate
197
+
198
+ Returns:
199
+ Deduplicated sources
200
+ """
201
+ if not self._config.deduplicate:
202
+ return sources
203
+
204
+ seen_content: set[str] = set()
205
+ deduped: list[ContextSource] = []
206
+
207
+ for source in sources:
208
+ # Create a fingerprint from first N chars
209
+ fingerprint = source.content[:200].lower().strip()
210
+ if fingerprint not in seen_content:
211
+ seen_content.add(fingerprint)
212
+ deduped.append(source)
213
+
214
+ return deduped
215
+
216
+ def _format_source_markdown(self, source: ContextSource, index: int) -> str:
217
+ """Format a source as Markdown.
218
+
219
+ Args:
220
+ source: Source to format
221
+ index: 1-based index for citation
222
+
223
+ Returns:
224
+ Markdown formatted string
225
+ """
226
+ lines: list[str] = []
227
+
228
+ # Header with source info
229
+ title = source.title or f"Source {index}"
230
+ lines.append(f"### [{index}] {title}")
231
+
232
+ if self._config.include_sources:
233
+ if source.source_url:
234
+ lines.append(f"*Source: {source.source_url}*")
235
+ elif source.source_path:
236
+ lines.append(f"*File: {source.source_path}*")
237
+
238
+ if self._config.include_scores and source.score is not None:
239
+ lines.append(f"*Relevance: {source.score:.3f}*")
240
+
241
+ lines.append("") # Blank line before content
242
+ lines.append(source.content)
243
+
244
+ return "\n".join(lines)
245
+
246
+ def _format_source_plain(self, source: ContextSource, index: int) -> str:
247
+ """Format a source as plain text.
248
+
249
+ Args:
250
+ source: Source to format
251
+ index: 1-based index for citation
252
+
253
+ Returns:
254
+ Plain text formatted string
255
+ """
256
+ lines: list[str] = []
257
+
258
+ title = source.title or f"Source {index}"
259
+ lines.append(f"[{index}] {title}")
260
+
261
+ if self._config.include_sources:
262
+ if source.source_url:
263
+ lines.append(f"Source: {source.source_url}")
264
+ elif source.source_path:
265
+ lines.append(f"File: {source.source_path}")
266
+
267
+ lines.append("")
268
+ lines.append(source.content)
269
+
270
+ return "\n".join(lines)
271
+
272
+ def _format_source_xml(self, source: ContextSource, index: int) -> str:
273
+ """Format a source as XML.
274
+
275
+ Args:
276
+ source: Source to format
277
+ index: 1-based index for citation
278
+
279
+ Returns:
280
+ XML formatted string
281
+ """
282
+ import html
283
+
284
+ lines: list[str] = []
285
+ lines.append(f'<source index="{index}">')
286
+
287
+ title = source.title or f"Source {index}"
288
+ lines.append(f" <title>{html.escape(title)}</title>")
289
+
290
+ if source.source_url:
291
+ lines.append(f" <url>{html.escape(source.source_url)}</url>")
292
+ if source.source_path:
293
+ lines.append(f" <path>{html.escape(source.source_path)}</path>")
294
+
295
+ if self._config.include_scores and source.score is not None:
296
+ lines.append(f" <score>{source.score:.3f}</score>")
297
+
298
+ lines.append(f" <content>{html.escape(source.content)}</content>")
299
+ lines.append("</source>")
300
+
301
+ return "\n".join(lines)
302
+
303
+ def _format_source(self, source: ContextSource, index: int) -> str:
304
+ """Format a source according to configured format.
305
+
306
+ Args:
307
+ source: Source to format
308
+ index: 1-based index for citation
309
+
310
+ Returns:
311
+ Formatted string
312
+ """
313
+ if self._config.format == ContextFormat.MARKDOWN:
314
+ return self._format_source_markdown(source, index)
315
+ elif self._config.format == ContextFormat.PLAIN:
316
+ return self._format_source_plain(source, index)
317
+ elif self._config.format == ContextFormat.XML:
318
+ return self._format_source_xml(source, index)
319
+ elif self._config.format == ContextFormat.JSON:
320
+ import json
321
+
322
+ return json.dumps(
323
+ {
324
+ "index": index,
325
+ "title": source.title,
326
+ "url": source.source_url,
327
+ "path": source.source_path,
328
+ "score": source.score,
329
+ "content": source.content,
330
+ }
331
+ )
332
+ else:
333
+ return self._format_source_plain(source, index)
334
+
335
+ def _format_memory(self, memories: list[MemoryEntry]) -> str:
336
+ """Format memory entries for context.
337
+
338
+ Args:
339
+ memories: Memory entries to format
340
+
341
+ Returns:
342
+ Formatted memory section
343
+ """
344
+ if not memories:
345
+ return ""
346
+
347
+ if self._config.format == ContextFormat.MARKDOWN:
348
+ lines = ["## Session Context\n"]
349
+ for mem in memories:
350
+ lines.append(f"- **{mem.key}**: {mem.value}")
351
+ return "\n".join(lines)
352
+
353
+ elif self._config.format == ContextFormat.XML:
354
+ lines = ["<session_context>"]
355
+ for mem in memories:
356
+ lines.append(f' <memory key="{mem.key}">{mem.value}</memory>')
357
+ lines.append("</session_context>")
358
+ return "\n".join(lines)
359
+
360
+ else: # PLAIN or JSON
361
+ lines = ["Session Context:"]
362
+ for mem in memories:
363
+ lines.append(f" {mem.key}: {mem.value}")
364
+ return "\n".join(lines)
365
+
366
+ def build(
367
+ self,
368
+ results: list[SearchResult | HybridResult | RerankResult | Chunk],
369
+ query: str | None = None,
370
+ memories: list[MemoryEntry] | None = None,
371
+ config: ContextConfig | None = None,
372
+ ) -> BuiltContext:
373
+ """Build context from search results.
374
+
375
+ Args:
376
+ results: Search results to include
377
+ query: Optional query for context
378
+ memories: Optional memory entries to include
379
+ config: Override default config for this build
380
+
381
+ Returns:
382
+ Built context with metadata
383
+ """
384
+ cfg = config or self._config
385
+ available_tokens = cfg.max_tokens - cfg.reserve_tokens
386
+
387
+ # Normalize and deduplicate
388
+ sources = self._normalize_results(results)
389
+ sources = self._deduplicate_sources(sources)
390
+
391
+ # Build memory section if configured
392
+ memory_text = ""
393
+ memory_included = False
394
+ if cfg.include_memory and memories:
395
+ memory_text = self._format_memory(memories)
396
+ memory_included = True
397
+ # Account for memory tokens
398
+ available_tokens -= self._estimate_tokens(memory_text)
399
+
400
+ # Build source sections within token budget
401
+ formatted_sources: list[str] = []
402
+ included_sources: list[ContextSource] = []
403
+ current_tokens = 0
404
+ truncated = False
405
+
406
+ for i, source in enumerate(sources, 1):
407
+ formatted = self._format_source(source, i)
408
+ source_tokens = self._estimate_tokens(formatted)
409
+
410
+ if current_tokens + source_tokens > available_tokens:
411
+ truncated = True
412
+ break
413
+
414
+ formatted_sources.append(formatted)
415
+ included_sources.append(source)
416
+ current_tokens += source_tokens
417
+
418
+ # Assemble final context
419
+ separator = cfg.section_separator
420
+ context_parts: list[str] = []
421
+
422
+ # Add query context if provided
423
+ if query:
424
+ if cfg.format == ContextFormat.MARKDOWN:
425
+ context_parts.append(f"## Query\n{query}")
426
+ elif cfg.format == ContextFormat.XML:
427
+ context_parts.append(f"<query>{query}</query>")
428
+ else:
429
+ context_parts.append(f"Query: {query}")
430
+
431
+ # Add memory before or after sources
432
+ if memory_text and cfg.memory_position == "before":
433
+ context_parts.append(memory_text)
434
+
435
+ # Add document header
436
+ if cfg.format == ContextFormat.MARKDOWN:
437
+ context_parts.append("## Relevant Documentation")
438
+ elif cfg.format == ContextFormat.XML:
439
+ context_parts.append("<documentation>")
440
+
441
+ # Add sources
442
+ context_parts.extend(formatted_sources)
443
+
444
+ if cfg.format == ContextFormat.XML:
445
+ context_parts.append("</documentation>")
446
+
447
+ # Add memory after if configured
448
+ if memory_text and cfg.memory_position == "after":
449
+ context_parts.append(memory_text)
450
+
451
+ # Join with appropriate separator
452
+ if cfg.format == ContextFormat.XML:
453
+ context = "\n".join(context_parts)
454
+ else:
455
+ context = separator.join(context_parts)
456
+
457
+ total_tokens = self._estimate_tokens(context)
458
+
459
+ return BuiltContext(
460
+ context=context,
461
+ sources=included_sources,
462
+ total_tokens=total_tokens,
463
+ truncated=truncated,
464
+ memory_included=memory_included,
465
+ source_count=len(included_sources),
466
+ )
467
+
468
+ def build_with_citations(
469
+ self,
470
+ results: list[SearchResult | HybridResult | RerankResult | Chunk],
471
+ query: str | None = None,
472
+ memories: list[MemoryEntry] | None = None,
473
+ ) -> tuple[str, str]:
474
+ """Build context with a separate citations section.
475
+
476
+ Returns both the main context and a citations/references section
477
+ that can be appended to LLM responses.
478
+
479
+ Args:
480
+ results: Search results to include
481
+ query: Optional query
482
+ memories: Optional memory entries
483
+
484
+ Returns:
485
+ Tuple of (context, citations)
486
+ """
487
+ built = self.build(results, query, memories)
488
+
489
+ # Build citations section
490
+ citations_lines: list[str] = []
491
+ if self._config.format == ContextFormat.MARKDOWN:
492
+ citations_lines.append("## Sources")
493
+ for i, source in enumerate(built.sources, 1):
494
+ title = source.title or f"Source {i}"
495
+ if source.source_url:
496
+ citations_lines.append(f"[{i}] [{title}]({source.source_url})")
497
+ elif source.source_path:
498
+ citations_lines.append(f"[{i}] {title} - `{source.source_path}`")
499
+ else:
500
+ citations_lines.append(f"[{i}] {title}")
501
+ else:
502
+ citations_lines.append("Sources:")
503
+ for i, source in enumerate(built.sources, 1):
504
+ title = source.title or f"Source {i}"
505
+ loc = source.source_url or source.source_path or "N/A"
506
+ citations_lines.append(f"[{i}] {title} - {loc}")
507
+
508
+ citations = "\n".join(citations_lines)
509
+
510
+ return built.context, citations
511
+
512
+
513
+ def get_context_builder(config: ContextConfig | None = None) -> ContextBuilder:
514
+ """Factory function to get a ContextBuilder instance.
515
+
516
+ Args:
517
+ config: Optional configuration
518
+
519
+ Returns:
520
+ ContextBuilder instance
521
+ """
522
+ return ContextBuilder(config=config)