devscontext 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.
- devscontext/__init__.py +3 -0
- devscontext/adapters/__init__.py +23 -0
- devscontext/adapters/base.py +105 -0
- devscontext/adapters/fireflies.py +585 -0
- devscontext/adapters/gmail.py +580 -0
- devscontext/adapters/jira.py +639 -0
- devscontext/adapters/local_docs.py +984 -0
- devscontext/adapters/slack.py +804 -0
- devscontext/agents/__init__.py +28 -0
- devscontext/agents/preprocessor.py +775 -0
- devscontext/agents/watcher.py +265 -0
- devscontext/cache.py +151 -0
- devscontext/cli.py +727 -0
- devscontext/config.py +264 -0
- devscontext/constants.py +107 -0
- devscontext/core.py +582 -0
- devscontext/exceptions.py +148 -0
- devscontext/logging.py +181 -0
- devscontext/models.py +504 -0
- devscontext/plugins/__init__.py +49 -0
- devscontext/plugins/base.py +321 -0
- devscontext/plugins/registry.py +544 -0
- devscontext/py.typed +0 -0
- devscontext/rag/__init__.py +113 -0
- devscontext/rag/embeddings.py +296 -0
- devscontext/rag/index.py +323 -0
- devscontext/server.py +374 -0
- devscontext/storage.py +321 -0
- devscontext/synthesis.py +1057 -0
- devscontext/utils.py +297 -0
- devscontext-0.1.0.dist-info/METADATA +253 -0
- devscontext-0.1.0.dist-info/RECORD +35 -0
- devscontext-0.1.0.dist-info/WHEEL +4 -0
- devscontext-0.1.0.dist-info/entry_points.txt +2 -0
- devscontext-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""Plugin registry for discovering and managing plugins.
|
|
2
|
+
|
|
3
|
+
This module provides the PluginRegistry class that handles:
|
|
4
|
+
- Registration of adapters and synthesis plugins
|
|
5
|
+
- Discovery of plugins via Python entry points
|
|
6
|
+
- Plugin instantiation with configuration
|
|
7
|
+
- Lifecycle management (initialization, cleanup)
|
|
8
|
+
|
|
9
|
+
Entry Points:
|
|
10
|
+
Third-party packages can register plugins via entry points in pyproject.toml:
|
|
11
|
+
|
|
12
|
+
[project.entry-points."devscontext.adapters"]
|
|
13
|
+
slack = "mypackage.slack:SlackAdapter"
|
|
14
|
+
gmail = "mypackage.gmail:GmailAdapter"
|
|
15
|
+
|
|
16
|
+
[project.entry-points."devscontext.synthesis"]
|
|
17
|
+
custom = "mypackage.synthesis:CustomSynthesis"
|
|
18
|
+
|
|
19
|
+
Example Usage:
|
|
20
|
+
# Create registry and discover plugins
|
|
21
|
+
registry = PluginRegistry()
|
|
22
|
+
registry.discover_plugins()
|
|
23
|
+
|
|
24
|
+
# Register built-in adapters
|
|
25
|
+
registry.register_adapter(JiraAdapter)
|
|
26
|
+
registry.register_synthesis(LLMSynthesisPlugin)
|
|
27
|
+
|
|
28
|
+
# Instantiate plugins with config
|
|
29
|
+
jira = registry.create_adapter("jira", jira_config)
|
|
30
|
+
synthesis = registry.create_synthesis("llm", synthesis_config)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from typing import TYPE_CHECKING, Any
|
|
36
|
+
|
|
37
|
+
from devscontext.logging import get_logger
|
|
38
|
+
from devscontext.plugins.base import Adapter, SynthesisPlugin # noqa: TC001 - used at runtime
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from pydantic import BaseModel
|
|
42
|
+
|
|
43
|
+
from devscontext.models import DevsContextConfig
|
|
44
|
+
|
|
45
|
+
logger = get_logger(__name__)
|
|
46
|
+
|
|
47
|
+
# Entry point group names
|
|
48
|
+
ADAPTER_ENTRY_POINT = "devscontext.adapters"
|
|
49
|
+
SYNTHESIS_ENTRY_POINT = "devscontext.synthesis"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PluginRegistry:
|
|
53
|
+
"""Central registry for discovering and managing plugins.
|
|
54
|
+
|
|
55
|
+
The registry maintains mappings of plugin names to their classes,
|
|
56
|
+
handles discovery via entry points, and manages plugin instantiation.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
adapter_classes: Mapping of adapter names to classes.
|
|
60
|
+
synthesis_classes: Mapping of synthesis plugin names to classes.
|
|
61
|
+
_adapter_instances: Active adapter instances.
|
|
62
|
+
_synthesis_instance: Active synthesis plugin instance.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
"""Initialize an empty plugin registry."""
|
|
67
|
+
# Plugin class registrations
|
|
68
|
+
self._adapter_classes: dict[str, type[Adapter]] = {}
|
|
69
|
+
self._synthesis_classes: dict[str, type[SynthesisPlugin]] = {}
|
|
70
|
+
|
|
71
|
+
# Active plugin instances
|
|
72
|
+
self._adapter_instances: dict[str, Adapter] = {}
|
|
73
|
+
self._synthesis_instance: SynthesisPlugin | None = None
|
|
74
|
+
|
|
75
|
+
# =========================================================================
|
|
76
|
+
# BUILT-IN REGISTRATION
|
|
77
|
+
# =========================================================================
|
|
78
|
+
|
|
79
|
+
def register_builtin_plugins(self) -> None:
|
|
80
|
+
"""Register all built-in adapters and synthesis plugins.
|
|
81
|
+
|
|
82
|
+
This registers:
|
|
83
|
+
- JiraAdapter
|
|
84
|
+
- FirefliesAdapter
|
|
85
|
+
- LocalDocsAdapter
|
|
86
|
+
- SlackAdapter
|
|
87
|
+
- GmailAdapter
|
|
88
|
+
- LLMSynthesisPlugin
|
|
89
|
+
|
|
90
|
+
Call this before load_from_config() to ensure built-in plugins
|
|
91
|
+
are available for instantiation.
|
|
92
|
+
"""
|
|
93
|
+
# Import here to avoid circular imports
|
|
94
|
+
from devscontext.adapters.fireflies import FirefliesAdapter
|
|
95
|
+
from devscontext.adapters.gmail import GmailAdapter
|
|
96
|
+
from devscontext.adapters.jira import JiraAdapter
|
|
97
|
+
from devscontext.adapters.local_docs import LocalDocsAdapter
|
|
98
|
+
from devscontext.adapters.slack import SlackAdapter
|
|
99
|
+
from devscontext.synthesis import (
|
|
100
|
+
LLMSynthesisPlugin,
|
|
101
|
+
PassthroughSynthesisPlugin,
|
|
102
|
+
TemplateSynthesisPlugin,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Register adapters
|
|
106
|
+
self.register_adapter(JiraAdapter)
|
|
107
|
+
self.register_adapter(FirefliesAdapter)
|
|
108
|
+
self.register_adapter(LocalDocsAdapter)
|
|
109
|
+
self.register_adapter(SlackAdapter)
|
|
110
|
+
self.register_adapter(GmailAdapter)
|
|
111
|
+
|
|
112
|
+
# Register synthesis plugins
|
|
113
|
+
self.register_synthesis(LLMSynthesisPlugin)
|
|
114
|
+
self.register_synthesis(TemplateSynthesisPlugin)
|
|
115
|
+
self.register_synthesis(PassthroughSynthesisPlugin)
|
|
116
|
+
|
|
117
|
+
logger.debug("Registered all built-in plugins")
|
|
118
|
+
|
|
119
|
+
def load_from_config(self, config: DevsContextConfig) -> None:
|
|
120
|
+
"""Initialize adapters and synthesis from configuration.
|
|
121
|
+
|
|
122
|
+
Reads the config and creates instances for each enabled source.
|
|
123
|
+
Sources with missing required config or disabled are skipped.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
config: The full DevsContext configuration.
|
|
127
|
+
|
|
128
|
+
Plugin loading flow:
|
|
129
|
+
1. Read sources section from config
|
|
130
|
+
2. For each source, check if enabled
|
|
131
|
+
3. Look up in built-in plugins first, then entry points
|
|
132
|
+
4. Initialize with source-specific config
|
|
133
|
+
5. Skip sources with missing or invalid config
|
|
134
|
+
"""
|
|
135
|
+
sources = config.sources
|
|
136
|
+
|
|
137
|
+
# Load Jira adapter if enabled
|
|
138
|
+
if sources.jira.enabled:
|
|
139
|
+
if sources.jira.base_url and sources.jira.email:
|
|
140
|
+
try:
|
|
141
|
+
self.create_adapter("jira", sources.jira)
|
|
142
|
+
logger.info("Loaded Jira adapter")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.warning(f"Failed to load Jira adapter: {e}")
|
|
145
|
+
else:
|
|
146
|
+
logger.debug("Jira adapter skipped: missing base_url or email")
|
|
147
|
+
|
|
148
|
+
# Load Fireflies adapter if enabled
|
|
149
|
+
if sources.fireflies.enabled:
|
|
150
|
+
if sources.fireflies.api_key:
|
|
151
|
+
try:
|
|
152
|
+
self.create_adapter("fireflies", sources.fireflies)
|
|
153
|
+
logger.info("Loaded Fireflies adapter")
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.warning(f"Failed to load Fireflies adapter: {e}")
|
|
156
|
+
else:
|
|
157
|
+
logger.debug("Fireflies adapter skipped: missing api_key")
|
|
158
|
+
|
|
159
|
+
# Load local docs adapter if enabled
|
|
160
|
+
if sources.docs.enabled:
|
|
161
|
+
try:
|
|
162
|
+
self.create_adapter("local_docs", sources.docs)
|
|
163
|
+
logger.info("Loaded LocalDocs adapter")
|
|
164
|
+
except Exception as e:
|
|
165
|
+
logger.warning(f"Failed to load LocalDocs adapter: {e}")
|
|
166
|
+
|
|
167
|
+
# Load Slack adapter if enabled
|
|
168
|
+
if sources.slack.enabled:
|
|
169
|
+
if sources.slack.bot_token:
|
|
170
|
+
try:
|
|
171
|
+
self.create_adapter("slack", sources.slack)
|
|
172
|
+
logger.info("Loaded Slack adapter")
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.warning(f"Failed to load Slack adapter: {e}")
|
|
175
|
+
else:
|
|
176
|
+
logger.debug("Slack adapter skipped: missing bot_token")
|
|
177
|
+
|
|
178
|
+
# Load Gmail adapter if enabled
|
|
179
|
+
if sources.gmail.enabled:
|
|
180
|
+
if sources.gmail.credentials_path:
|
|
181
|
+
try:
|
|
182
|
+
self.create_adapter("gmail", sources.gmail)
|
|
183
|
+
logger.info("Loaded Gmail adapter")
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.warning(f"Failed to load Gmail adapter: {e}")
|
|
186
|
+
else:
|
|
187
|
+
logger.debug("Gmail adapter skipped: missing credentials_path")
|
|
188
|
+
|
|
189
|
+
# Load synthesis plugin
|
|
190
|
+
synthesis_config = config.synthesis
|
|
191
|
+
plugin_name = synthesis_config.plugin
|
|
192
|
+
|
|
193
|
+
if plugin_name in self._synthesis_classes:
|
|
194
|
+
try:
|
|
195
|
+
self.create_synthesis(plugin_name, synthesis_config)
|
|
196
|
+
logger.info(f"Loaded synthesis plugin: {plugin_name}")
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.warning(f"Failed to load synthesis plugin '{plugin_name}': {e}")
|
|
199
|
+
else:
|
|
200
|
+
logger.warning(
|
|
201
|
+
f"Unknown synthesis plugin '{plugin_name}', "
|
|
202
|
+
f"available: {list(self._synthesis_classes.keys())}"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def get_primary_adapters(self) -> dict[str, Adapter]:
|
|
206
|
+
"""Get all active primary adapters.
|
|
207
|
+
|
|
208
|
+
Primary adapters are fetched first and their context is shared
|
|
209
|
+
with secondary adapters.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Dict mapping adapter names to primary adapter instances.
|
|
213
|
+
"""
|
|
214
|
+
primary: dict[str, Adapter] = {}
|
|
215
|
+
for name, adapter in self._adapter_instances.items():
|
|
216
|
+
# Check if adapter config has primary=True
|
|
217
|
+
config = getattr(adapter, "_config", None)
|
|
218
|
+
if config is not None and getattr(config, "primary", False):
|
|
219
|
+
primary[name] = adapter
|
|
220
|
+
return primary
|
|
221
|
+
|
|
222
|
+
def get_secondary_adapters(self) -> dict[str, Adapter]:
|
|
223
|
+
"""Get all active secondary adapters.
|
|
224
|
+
|
|
225
|
+
Secondary adapters are fetched after primary adapters and
|
|
226
|
+
can use primary context for better matching.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Dict mapping adapter names to secondary adapter instances.
|
|
230
|
+
"""
|
|
231
|
+
secondary: dict[str, Adapter] = {}
|
|
232
|
+
for name, adapter in self._adapter_instances.items():
|
|
233
|
+
# Check if adapter config has primary=False or not set
|
|
234
|
+
config = getattr(adapter, "_config", None)
|
|
235
|
+
if config is None or not getattr(config, "primary", False):
|
|
236
|
+
secondary[name] = adapter
|
|
237
|
+
return secondary
|
|
238
|
+
|
|
239
|
+
# =========================================================================
|
|
240
|
+
# REGISTRATION
|
|
241
|
+
# =========================================================================
|
|
242
|
+
|
|
243
|
+
def register_adapter(self, adapter_class: type[Adapter]) -> None:
|
|
244
|
+
"""Register an adapter class.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
adapter_class: The Adapter subclass to register.
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
ValueError: If adapter name conflicts with existing registration.
|
|
251
|
+
"""
|
|
252
|
+
name = adapter_class.name
|
|
253
|
+
if name in self._adapter_classes:
|
|
254
|
+
existing = self._adapter_classes[name]
|
|
255
|
+
if existing is not adapter_class:
|
|
256
|
+
raise ValueError(f"Adapter '{name}' already registered by {existing.__module__}")
|
|
257
|
+
return # Already registered same class
|
|
258
|
+
|
|
259
|
+
self._adapter_classes[name] = adapter_class
|
|
260
|
+
logger.debug(f"Registered adapter: {name} ({adapter_class.__module__})")
|
|
261
|
+
|
|
262
|
+
def register_synthesis(self, plugin_class: type[SynthesisPlugin]) -> None:
|
|
263
|
+
"""Register a synthesis plugin class.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
plugin_class: The SynthesisPlugin subclass to register.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
ValueError: If plugin name conflicts with existing registration.
|
|
270
|
+
"""
|
|
271
|
+
name = plugin_class.name
|
|
272
|
+
if name in self._synthesis_classes:
|
|
273
|
+
existing = self._synthesis_classes[name]
|
|
274
|
+
if existing is not plugin_class:
|
|
275
|
+
raise ValueError(
|
|
276
|
+
f"Synthesis plugin '{name}' already registered by {existing.__module__}"
|
|
277
|
+
)
|
|
278
|
+
return # Already registered same class
|
|
279
|
+
|
|
280
|
+
self._synthesis_classes[name] = plugin_class
|
|
281
|
+
logger.debug(f"Registered synthesis plugin: {name} ({plugin_class.__module__})")
|
|
282
|
+
|
|
283
|
+
# =========================================================================
|
|
284
|
+
# DISCOVERY
|
|
285
|
+
# =========================================================================
|
|
286
|
+
|
|
287
|
+
def discover_plugins(self) -> None:
|
|
288
|
+
"""Discover and register plugins from entry points.
|
|
289
|
+
|
|
290
|
+
Scans for plugins registered via:
|
|
291
|
+
- devscontext.adapters - adapter plugins
|
|
292
|
+
- devscontext.synthesis - synthesis plugins
|
|
293
|
+
|
|
294
|
+
Errors during discovery are logged but don't stop the process.
|
|
295
|
+
"""
|
|
296
|
+
self._discover_entry_points(ADAPTER_ENTRY_POINT, self.register_adapter)
|
|
297
|
+
self._discover_entry_points(SYNTHESIS_ENTRY_POINT, self.register_synthesis)
|
|
298
|
+
|
|
299
|
+
def _discover_entry_points(
|
|
300
|
+
self,
|
|
301
|
+
group: str,
|
|
302
|
+
register_fn: Any,
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Discover plugins from a specific entry point group.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
group: Entry point group name.
|
|
308
|
+
register_fn: Function to call for each discovered plugin.
|
|
309
|
+
"""
|
|
310
|
+
from importlib.metadata import entry_points
|
|
311
|
+
|
|
312
|
+
eps = entry_points(group=group)
|
|
313
|
+
|
|
314
|
+
for ep in eps:
|
|
315
|
+
try:
|
|
316
|
+
plugin_class = ep.load()
|
|
317
|
+
register_fn(plugin_class)
|
|
318
|
+
logger.info(f"Discovered plugin via entry point: {ep.name}")
|
|
319
|
+
except Exception as e:
|
|
320
|
+
logger.warning(
|
|
321
|
+
f"Failed to load plugin from entry point {ep.name}: {e}",
|
|
322
|
+
extra={"entry_point": ep.name, "group": group},
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# =========================================================================
|
|
326
|
+
# INSTANTIATION
|
|
327
|
+
# =========================================================================
|
|
328
|
+
|
|
329
|
+
def create_adapter(
|
|
330
|
+
self,
|
|
331
|
+
name: str,
|
|
332
|
+
config: BaseModel,
|
|
333
|
+
) -> Adapter:
|
|
334
|
+
"""Create and return an adapter instance.
|
|
335
|
+
|
|
336
|
+
If an instance already exists for this name, returns the existing one.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
name: The adapter name (e.g., "jira").
|
|
340
|
+
config: Configuration for the adapter (must match adapter's config_schema).
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
The adapter instance.
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
KeyError: If no adapter is registered with this name.
|
|
347
|
+
TypeError: If config doesn't match the adapter's config_schema.
|
|
348
|
+
"""
|
|
349
|
+
if name in self._adapter_instances:
|
|
350
|
+
return self._adapter_instances[name]
|
|
351
|
+
|
|
352
|
+
if name not in self._adapter_classes:
|
|
353
|
+
raise KeyError(f"No adapter registered with name '{name}'")
|
|
354
|
+
|
|
355
|
+
adapter_class = self._adapter_classes[name]
|
|
356
|
+
|
|
357
|
+
# Validate config type
|
|
358
|
+
expected_schema = adapter_class.config_schema
|
|
359
|
+
if not isinstance(config, expected_schema):
|
|
360
|
+
raise TypeError(
|
|
361
|
+
f"Adapter '{name}' expects config of type {expected_schema.__name__}, "
|
|
362
|
+
f"got {type(config).__name__}"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
instance = adapter_class(config) # type: ignore[call-arg]
|
|
366
|
+
self._adapter_instances[name] = instance
|
|
367
|
+
logger.debug(f"Created adapter instance: {name}")
|
|
368
|
+
|
|
369
|
+
return instance
|
|
370
|
+
|
|
371
|
+
def create_synthesis(
|
|
372
|
+
self,
|
|
373
|
+
name: str,
|
|
374
|
+
config: BaseModel,
|
|
375
|
+
) -> SynthesisPlugin:
|
|
376
|
+
"""Create and return a synthesis plugin instance.
|
|
377
|
+
|
|
378
|
+
Only one synthesis plugin can be active at a time.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
name: The plugin name (e.g., "llm").
|
|
382
|
+
config: Configuration for the plugin.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
The plugin instance.
|
|
386
|
+
|
|
387
|
+
Raises:
|
|
388
|
+
KeyError: If no plugin is registered with this name.
|
|
389
|
+
TypeError: If config doesn't match the plugin's config_schema.
|
|
390
|
+
"""
|
|
391
|
+
if self._synthesis_instance is not None:
|
|
392
|
+
# Check if it's the same type
|
|
393
|
+
if self._synthesis_instance.name == name:
|
|
394
|
+
return self._synthesis_instance
|
|
395
|
+
# Close existing before creating new
|
|
396
|
+
logger.debug(f"Replacing synthesis plugin: {self._synthesis_instance.name} -> {name}")
|
|
397
|
+
|
|
398
|
+
if name not in self._synthesis_classes:
|
|
399
|
+
raise KeyError(f"No synthesis plugin registered with name '{name}'")
|
|
400
|
+
|
|
401
|
+
plugin_class = self._synthesis_classes[name]
|
|
402
|
+
|
|
403
|
+
# Validate config type
|
|
404
|
+
expected_schema = plugin_class.config_schema
|
|
405
|
+
if not isinstance(config, expected_schema):
|
|
406
|
+
raise TypeError(
|
|
407
|
+
f"Plugin '{name}' expects config of type {expected_schema.__name__}, "
|
|
408
|
+
f"got {type(config).__name__}"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
instance = plugin_class(config) # type: ignore[call-arg]
|
|
412
|
+
self._synthesis_instance = instance
|
|
413
|
+
logger.debug(f"Created synthesis plugin instance: {name}")
|
|
414
|
+
|
|
415
|
+
return instance
|
|
416
|
+
|
|
417
|
+
# =========================================================================
|
|
418
|
+
# INSTANCE ACCESS
|
|
419
|
+
# =========================================================================
|
|
420
|
+
|
|
421
|
+
def get_adapter(self, name: str) -> Adapter | None:
|
|
422
|
+
"""Get an existing adapter instance.
|
|
423
|
+
|
|
424
|
+
Args:
|
|
425
|
+
name: The adapter name.
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
The adapter instance, or None if not instantiated.
|
|
429
|
+
"""
|
|
430
|
+
return self._adapter_instances.get(name)
|
|
431
|
+
|
|
432
|
+
def get_synthesis(self) -> SynthesisPlugin | None:
|
|
433
|
+
"""Get the active synthesis plugin instance.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
The synthesis plugin, or None if not instantiated.
|
|
437
|
+
"""
|
|
438
|
+
return self._synthesis_instance
|
|
439
|
+
|
|
440
|
+
def get_active_adapters(self) -> dict[str, Adapter]:
|
|
441
|
+
"""Get all active adapter instances.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Dict mapping adapter names to instances.
|
|
445
|
+
"""
|
|
446
|
+
return dict(self._adapter_instances)
|
|
447
|
+
|
|
448
|
+
# =========================================================================
|
|
449
|
+
# INTROSPECTION
|
|
450
|
+
# =========================================================================
|
|
451
|
+
|
|
452
|
+
def list_adapters(self) -> list[str]:
|
|
453
|
+
"""List all registered adapter names.
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
List of adapter names.
|
|
457
|
+
"""
|
|
458
|
+
return list(self._adapter_classes.keys())
|
|
459
|
+
|
|
460
|
+
def list_synthesis_plugins(self) -> list[str]:
|
|
461
|
+
"""List all registered synthesis plugin names.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
List of plugin names.
|
|
465
|
+
"""
|
|
466
|
+
return list(self._synthesis_classes.keys())
|
|
467
|
+
|
|
468
|
+
def get_adapter_config_schema(self, name: str) -> type[BaseModel]:
|
|
469
|
+
"""Get the configuration schema for an adapter.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
name: The adapter name.
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
The Pydantic model class for the adapter's config.
|
|
476
|
+
|
|
477
|
+
Raises:
|
|
478
|
+
KeyError: If no adapter is registered with this name.
|
|
479
|
+
"""
|
|
480
|
+
if name not in self._adapter_classes:
|
|
481
|
+
raise KeyError(f"No adapter registered with name '{name}'")
|
|
482
|
+
return self._adapter_classes[name].config_schema
|
|
483
|
+
|
|
484
|
+
def get_synthesis_config_schema(self, name: str) -> type[BaseModel]:
|
|
485
|
+
"""Get the configuration schema for a synthesis plugin.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
name: The plugin name.
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
The Pydantic model class for the plugin's config.
|
|
492
|
+
|
|
493
|
+
Raises:
|
|
494
|
+
KeyError: If no plugin is registered with this name.
|
|
495
|
+
"""
|
|
496
|
+
if name not in self._synthesis_classes:
|
|
497
|
+
raise KeyError(f"No synthesis plugin registered with name '{name}'")
|
|
498
|
+
return self._synthesis_classes[name].config_schema
|
|
499
|
+
|
|
500
|
+
# =========================================================================
|
|
501
|
+
# LIFECYCLE
|
|
502
|
+
# =========================================================================
|
|
503
|
+
|
|
504
|
+
async def close_all(self) -> None:
|
|
505
|
+
"""Close all plugin instances and clean up resources.
|
|
506
|
+
|
|
507
|
+
Should be called during shutdown to properly release resources.
|
|
508
|
+
"""
|
|
509
|
+
# Close adapters
|
|
510
|
+
for name, adapter in list(self._adapter_instances.items()):
|
|
511
|
+
try:
|
|
512
|
+
await adapter.close()
|
|
513
|
+
logger.debug(f"Closed adapter: {name}")
|
|
514
|
+
except Exception as e:
|
|
515
|
+
logger.warning(f"Error closing adapter {name}: {e}")
|
|
516
|
+
|
|
517
|
+
self._adapter_instances.clear()
|
|
518
|
+
|
|
519
|
+
# Close synthesis plugin
|
|
520
|
+
if self._synthesis_instance is not None:
|
|
521
|
+
try:
|
|
522
|
+
await self._synthesis_instance.close()
|
|
523
|
+
logger.debug(f"Closed synthesis plugin: {self._synthesis_instance.name}")
|
|
524
|
+
except Exception as e:
|
|
525
|
+
logger.warning(f"Error closing synthesis plugin: {e}")
|
|
526
|
+
|
|
527
|
+
self._synthesis_instance = None
|
|
528
|
+
|
|
529
|
+
async def health_check_all(self) -> dict[str, bool]:
|
|
530
|
+
"""Run health checks on all active adapters.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Dict mapping adapter names to health status.
|
|
534
|
+
"""
|
|
535
|
+
results: dict[str, bool] = {}
|
|
536
|
+
|
|
537
|
+
for name, adapter in self._adapter_instances.items():
|
|
538
|
+
try:
|
|
539
|
+
results[name] = await adapter.health_check()
|
|
540
|
+
except Exception as e:
|
|
541
|
+
logger.warning(f"Health check failed for {name}: {e}")
|
|
542
|
+
results[name] = False
|
|
543
|
+
|
|
544
|
+
return results
|
devscontext/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""RAG (Retrieval-Augmented Generation) support for local documentation.
|
|
2
|
+
|
|
3
|
+
This module provides optional embedding-based search for the LocalDocsAdapter.
|
|
4
|
+
When enabled, it uses semantic similarity instead of keyword matching for
|
|
5
|
+
finding relevant documentation sections.
|
|
6
|
+
|
|
7
|
+
The RAG feature is optional and requires additional dependencies:
|
|
8
|
+
pip install devscontext[rag]
|
|
9
|
+
|
|
10
|
+
Example usage:
|
|
11
|
+
from devscontext.rag import DocumentIndex, get_embedding_provider
|
|
12
|
+
|
|
13
|
+
# Create embedding provider
|
|
14
|
+
provider = get_embedding_provider(rag_config)
|
|
15
|
+
|
|
16
|
+
# Create and load index
|
|
17
|
+
index = DocumentIndex(rag_config.index_path)
|
|
18
|
+
index.load()
|
|
19
|
+
|
|
20
|
+
# Search for similar documents
|
|
21
|
+
query_embedding = await provider.embed_query("payment webhook retry logic")
|
|
22
|
+
results = index.search(query_embedding, top_k=10, threshold=0.3)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from devscontext.models import RagConfig
|
|
31
|
+
from devscontext.rag.embeddings import EmbeddingProvider
|
|
32
|
+
|
|
33
|
+
# Lazy imports to avoid loading heavy dependencies unless RAG is used
|
|
34
|
+
_RAG_AVAILABLE: bool | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_rag_available() -> bool:
|
|
38
|
+
"""Check if RAG dependencies are installed.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
True if sentence-transformers and numpy are available.
|
|
42
|
+
"""
|
|
43
|
+
global _RAG_AVAILABLE
|
|
44
|
+
if _RAG_AVAILABLE is None:
|
|
45
|
+
try:
|
|
46
|
+
import numpy # noqa: F401
|
|
47
|
+
import sentence_transformers # noqa: F401
|
|
48
|
+
|
|
49
|
+
_RAG_AVAILABLE = True
|
|
50
|
+
except ImportError:
|
|
51
|
+
_RAG_AVAILABLE = False
|
|
52
|
+
return _RAG_AVAILABLE
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_embedding_provider(config: RagConfig) -> EmbeddingProvider:
|
|
56
|
+
"""Factory function to create an embedding provider based on config.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
config: RAG configuration specifying provider and model.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
An EmbeddingProvider instance.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ImportError: If RAG dependencies are not installed.
|
|
66
|
+
ValueError: If the embedding provider is not supported.
|
|
67
|
+
"""
|
|
68
|
+
if not is_rag_available():
|
|
69
|
+
raise ImportError(
|
|
70
|
+
"RAG dependencies not installed. Install with: pip install devscontext[rag]"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
from devscontext.rag.embeddings import (
|
|
74
|
+
LocalEmbeddingProvider,
|
|
75
|
+
OllamaEmbeddingProvider,
|
|
76
|
+
OpenAIEmbeddingProvider,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
providers: dict[str, type[EmbeddingProvider]] = {
|
|
80
|
+
"local": LocalEmbeddingProvider,
|
|
81
|
+
"openai": OpenAIEmbeddingProvider,
|
|
82
|
+
"ollama": OllamaEmbeddingProvider,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
provider_cls = providers.get(config.embedding_provider)
|
|
86
|
+
if provider_cls is None:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Unknown embedding provider: {config.embedding_provider}. "
|
|
89
|
+
f"Supported: {', '.join(providers.keys())}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return provider_cls(config.embedding_model)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
"DocumentIndex",
|
|
97
|
+
"EmbeddingProvider",
|
|
98
|
+
"get_embedding_provider",
|
|
99
|
+
"is_rag_available",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def __getattr__(name: str) -> type:
|
|
104
|
+
"""Lazy import of RAG classes to avoid loading dependencies at import time."""
|
|
105
|
+
if name == "DocumentIndex":
|
|
106
|
+
from devscontext.rag.index import DocumentIndex
|
|
107
|
+
|
|
108
|
+
return DocumentIndex
|
|
109
|
+
if name == "EmbeddingProvider":
|
|
110
|
+
from devscontext.rag.embeddings import EmbeddingProvider
|
|
111
|
+
|
|
112
|
+
return EmbeddingProvider
|
|
113
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|