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,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)
|