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/core.py ADDED
@@ -0,0 +1,582 @@
1
+ """Core orchestration logic for DevsContext.
2
+
3
+ This module contains the DevsContextCore class which coordinates
4
+ fetching context from multiple adapters, synthesizing the results
5
+ with an LLM, and caching for performance.
6
+
7
+ Uses the plugin registry for adapter and synthesis plugin management,
8
+ supporting the primary/secondary source fetch strategy.
9
+
10
+ Example:
11
+ config = DevsContextConfig(
12
+ sources=SourcesConfig(
13
+ jira=JiraConfig(enabled=True, base_url="https://...", primary=True),
14
+ docs=DocsConfig(enabled=True, primary=False),
15
+ ),
16
+ synthesis=SynthesisConfig(provider="anthropic"),
17
+ )
18
+ core = DevsContextCore(config)
19
+ result = await core.get_task_context("PROJ-123")
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import time
26
+ from datetime import UTC, datetime
27
+ from typing import TYPE_CHECKING
28
+
29
+ from devscontext.cache import SimpleCache
30
+ from devscontext.logging import get_logger
31
+ from devscontext.models import DocsContext, JiraContext, JiraTicket, MeetingContext, TaskContext
32
+ from devscontext.plugins.base import SourceContext
33
+ from devscontext.plugins.registry import PluginRegistry
34
+ from devscontext.storage import PrebuiltContextStorage
35
+
36
+ if TYPE_CHECKING:
37
+ from devscontext.models import DevsContextConfig, PrebuiltContext
38
+
39
+ logger = get_logger(__name__)
40
+
41
+
42
+ class DevsContextCore:
43
+ """Core orchestration for fetching and synthesizing engineering context.
44
+
45
+ This class coordinates:
46
+ - Fetching context from adapters using the plugin registry
47
+ - Primary adapters are fetched first (e.g., Jira)
48
+ - Secondary adapters are fetched in parallel using primary context
49
+ - Synthesizing raw context into structured markdown via synthesis plugins
50
+ - Caching results to avoid redundant API calls
51
+
52
+ Uses PluginRegistry for managing adapters and synthesis plugins.
53
+ """
54
+
55
+ def __init__(self, config: DevsContextConfig) -> None:
56
+ """Initialize the core with configuration.
57
+
58
+ Args:
59
+ config: DevsContextConfig containing sources, synthesis, and cache settings.
60
+ """
61
+ self._config = config
62
+
63
+ # Initialize cache if enabled
64
+ self._cache: SimpleCache | None = None
65
+ if config.cache.enabled:
66
+ self._cache = SimpleCache(
67
+ ttl=config.cache.ttl_seconds,
68
+ max_size=config.cache.max_size,
69
+ )
70
+
71
+ # Initialize pre-built context storage if configured
72
+ self._storage: PrebuiltContextStorage | None = None
73
+ if config.storage.path:
74
+ self._storage = PrebuiltContextStorage(config.storage.path)
75
+ self._storage_initialized = False
76
+ else:
77
+ self._storage_initialized = True # No storage to initialize
78
+
79
+ # Initialize plugin registry
80
+ self._registry = PluginRegistry()
81
+ self._registry.register_builtin_plugins()
82
+ self._registry.discover_plugins()
83
+ self._registry.load_from_config(config)
84
+
85
+ synthesis = self._registry.get_synthesis()
86
+ logger.info(
87
+ "DevsContextCore initialized",
88
+ extra={
89
+ "cache_enabled": config.cache.enabled,
90
+ "adapters_loaded": list(self._registry.get_active_adapters().keys()),
91
+ "synthesis_plugin": synthesis.name if synthesis else None,
92
+ },
93
+ )
94
+
95
+ async def get_task_context(
96
+ self,
97
+ task_id: str,
98
+ *,
99
+ use_cache: bool = True,
100
+ ) -> TaskContext:
101
+ """Get aggregated and synthesized context for a task.
102
+
103
+ Fetches context from all configured adapters in parallel,
104
+ then uses the LLM to synthesize into structured markdown.
105
+
106
+ Args:
107
+ task_id: The task identifier (e.g., Jira ticket ID).
108
+ use_cache: Whether to use cached results.
109
+
110
+ Returns:
111
+ TaskContext with synthesized markdown and metadata.
112
+ """
113
+ start_time = time.monotonic()
114
+ cache_key = f"context:{task_id}"
115
+
116
+ # Check pre-built context storage first (instant return with rich context)
117
+ if use_cache and self._storage is not None:
118
+ prebuilt = await self._get_prebuilt_context(task_id)
119
+ if prebuilt is not None and not prebuilt.is_expired():
120
+ logger.info(
121
+ "Pre-built context hit",
122
+ extra={
123
+ "task_id": task_id,
124
+ "quality_score": prebuilt.context_quality_score,
125
+ },
126
+ )
127
+ return TaskContext(
128
+ task_id=task_id,
129
+ synthesized=prebuilt.synthesized,
130
+ sources_used=prebuilt.sources_used,
131
+ fetch_duration_ms=0,
132
+ synthesized_at=prebuilt.built_at,
133
+ cached=True,
134
+ prebuilt=True,
135
+ )
136
+
137
+ # Check in-memory cache
138
+ if use_cache and self._cache is not None:
139
+ cached = self._cache.get(cache_key)
140
+ if cached is not None:
141
+ logger.info("Cache hit for task context", extra={"task_id": task_id})
142
+ # Return a copy with cached=True
143
+ if isinstance(cached, TaskContext):
144
+ return TaskContext(
145
+ task_id=cached.task_id,
146
+ synthesized=cached.synthesized,
147
+ sources_used=cached.sources_used,
148
+ fetch_duration_ms=cached.fetch_duration_ms,
149
+ synthesized_at=cached.synthesized_at,
150
+ cached=True,
151
+ )
152
+
153
+ # Fetch context from adapters using new plugin interface
154
+ source_contexts = await self._fetch_all_context(task_id)
155
+
156
+ # Build sources list from source contexts
157
+ sources_used: list[str] = []
158
+ for name, ctx in source_contexts.items():
159
+ if ctx.is_empty():
160
+ continue
161
+
162
+ if name == "jira" and isinstance(ctx.data, JiraContext):
163
+ sources_used.append(f"jira:{ctx.data.ticket.ticket_id}")
164
+ elif name == "fireflies" and isinstance(ctx.data, MeetingContext):
165
+ sources_used.extend(
166
+ f"fireflies:{m.meeting_date.strftime('%Y-%m-%d')}" for m in ctx.data.meetings
167
+ )
168
+ elif name == "local_docs" and isinstance(ctx.data, DocsContext):
169
+ sources_used.extend(f"docs:{s.file_path}" for s in ctx.data.sections)
170
+
171
+ # Synthesize using plugin interface
172
+ synthesis = self._registry.get_synthesis()
173
+ if synthesis is None:
174
+ synthesized = f"## Task: {task_id}\n\nNo synthesis plugin configured."
175
+ else:
176
+ synthesized = await synthesis.synthesize(
177
+ task_id=task_id,
178
+ source_contexts=source_contexts,
179
+ )
180
+
181
+ duration_ms = int((time.monotonic() - start_time) * 1000)
182
+
183
+ logger.info(
184
+ "Task context fetched and synthesized",
185
+ extra={
186
+ "task_id": task_id,
187
+ "source_count": len(sources_used),
188
+ "duration_ms": duration_ms,
189
+ },
190
+ )
191
+
192
+ result = TaskContext(
193
+ task_id=task_id,
194
+ synthesized=synthesized,
195
+ sources_used=sources_used,
196
+ fetch_duration_ms=duration_ms,
197
+ synthesized_at=datetime.now(UTC),
198
+ cached=False,
199
+ )
200
+
201
+ # Store in cache
202
+ if use_cache and self._cache is not None:
203
+ self._cache.set(cache_key, result)
204
+
205
+ return result
206
+
207
+ async def _fetch_all_context(
208
+ self,
209
+ task_id: str,
210
+ ) -> dict[str, SourceContext]:
211
+ """Fetch context from all adapters using the plugin interface.
212
+
213
+ Uses two-phase fetch strategy:
214
+ 1. Fetch from primary adapters first (typically Jira)
215
+ 2. Fetch from secondary adapters in parallel, passing primary context
216
+
217
+ This allows secondary adapters (docs, meetings) to use ticket data
218
+ for better context matching (components, labels, keywords).
219
+
220
+ Args:
221
+ task_id: The task identifier.
222
+
223
+ Returns:
224
+ Dict mapping adapter names to their SourceContext.
225
+ """
226
+ source_contexts: dict[str, SourceContext] = {}
227
+
228
+ # Phase 1: Fetch from primary adapters (typically Jira)
229
+ # Primary adapters are fetched first, their context is shared with secondary
230
+ ticket: JiraTicket | None = None
231
+ primary_adapters = self._registry.get_primary_adapters()
232
+
233
+ for name, adapter in primary_adapters.items():
234
+ try:
235
+ ctx = await adapter.fetch_task_context(task_id)
236
+ source_contexts[name] = ctx
237
+ # Extract Jira ticket if available for secondary adapters
238
+ if isinstance(ctx.data, JiraContext):
239
+ ticket = ctx.data.ticket
240
+ except Exception as e:
241
+ logger.warning(
242
+ f"Primary adapter {name} fetch failed",
243
+ extra={"error": str(e), "task_id": task_id},
244
+ )
245
+
246
+ # Phase 2: Fetch from secondary adapters in parallel
247
+ # Pass ticket data for context-aware matching
248
+ secondary_adapters = self._registry.get_secondary_adapters()
249
+
250
+ if secondary_adapters:
251
+ coros = []
252
+ adapter_names = []
253
+
254
+ for name, adapter in secondary_adapters.items():
255
+ coros.append(adapter.fetch_task_context(task_id, ticket))
256
+ adapter_names.append(name)
257
+
258
+ results = await asyncio.gather(*coros, return_exceptions=True)
259
+
260
+ for name, result in zip(adapter_names, results, strict=True):
261
+ if isinstance(result, BaseException):
262
+ logger.warning(
263
+ f"Secondary adapter {name} fetch failed",
264
+ extra={"error": str(result), "task_id": task_id},
265
+ )
266
+ elif isinstance(result, SourceContext):
267
+ source_contexts[name] = result
268
+
269
+ return source_contexts
270
+
271
+ async def health_check(self) -> dict[str, bool]:
272
+ """Check health of all configured adapters.
273
+
274
+ Returns:
275
+ Dictionary mapping adapter names to health status.
276
+ """
277
+ results = await self._registry.health_check_all()
278
+ logger.info("Health check completed", extra={"adapters": results})
279
+ return results
280
+
281
+ def invalidate_cache(self, task_id: str | None = None) -> None:
282
+ """Invalidate cached context.
283
+
284
+ Args:
285
+ task_id: Specific task to invalidate, or None to clear all.
286
+ """
287
+ if self._cache is None:
288
+ return
289
+
290
+ if task_id:
291
+ self._cache.invalidate(f"context:{task_id}")
292
+ logger.debug("Cache invalidated", extra={"task_id": task_id})
293
+ else:
294
+ self._cache.clear()
295
+ logger.debug("Cache cleared")
296
+
297
+ async def search_context(self, query: str) -> dict[str, str | list[str] | int]:
298
+ """Search across all sources by keyword.
299
+
300
+ Searches Jira, meetings, and local docs in parallel for freeform queries
301
+ like "how do we handle retries?" or "what was decided about payments?".
302
+
303
+ No LLM synthesis - returns formatted search results directly.
304
+ No caching - queries are too varied.
305
+
306
+ Args:
307
+ query: The search query.
308
+
309
+ Returns:
310
+ Dictionary with formatted results and metadata.
311
+ """
312
+ start_time = time.monotonic()
313
+ logger.info("Search context", extra={"query": query})
314
+
315
+ # Search all sources in parallel
316
+ jira_coro = self._search_jira(query)
317
+ meetings_coro = self._search_meetings(query)
318
+ docs_coro = self._search_docs(query)
319
+
320
+ results = await asyncio.gather(jira_coro, meetings_coro, docs_coro, return_exceptions=True)
321
+
322
+ jira_results, meeting_results, docs_results = results
323
+
324
+ # Format results into markdown
325
+ sections: list[str] = [f'## Search Results for "{query}"']
326
+ sources_used: list[str] = []
327
+ total_results = 0
328
+
329
+ # Format Jira results
330
+ if isinstance(jira_results, list) and jira_results:
331
+ sources_used.append("jira")
332
+ total_results += len(jira_results)
333
+ sections.append(self._format_jira_search_results(jira_results))
334
+ elif isinstance(jira_results, BaseException):
335
+ logger.warning("Jira search failed", extra={"error": str(jira_results)})
336
+
337
+ # Format meeting results
338
+ if isinstance(meeting_results, MeetingContext) and meeting_results.meetings:
339
+ sources_used.append("fireflies")
340
+ total_results += len(meeting_results.meetings)
341
+ sections.append(self._format_meeting_search_results(meeting_results))
342
+ elif isinstance(meeting_results, BaseException):
343
+ logger.warning("Meeting search failed", extra={"error": str(meeting_results)})
344
+
345
+ # Format docs results
346
+ if isinstance(docs_results, DocsContext) and docs_results.sections:
347
+ sources_used.append("docs")
348
+ total_results += len(docs_results.sections)
349
+ sections.append(self._format_docs_search_results(docs_results))
350
+ elif isinstance(docs_results, BaseException):
351
+ logger.warning("Docs search failed", extra={"error": str(docs_results)})
352
+
353
+ # Handle no results
354
+ if total_results == 0:
355
+ sections.append("\nNo results found.")
356
+
357
+ duration_ms = int((time.monotonic() - start_time) * 1000)
358
+
359
+ logger.info(
360
+ "Search completed",
361
+ extra={
362
+ "query": query,
363
+ "result_count": total_results,
364
+ "sources": sources_used,
365
+ "duration_ms": duration_ms,
366
+ },
367
+ )
368
+
369
+ return {
370
+ "query": query,
371
+ "results": "\n\n".join(sections),
372
+ "sources": sources_used if sources_used else ["none"],
373
+ "result_count": total_results,
374
+ "duration_ms": duration_ms,
375
+ }
376
+
377
+ async def _search_jira(self, query: str) -> list[JiraTicket]:
378
+ """Search Jira for matching issues."""
379
+ jira = self._registry.get_adapter("jira")
380
+ if jira is None:
381
+ return []
382
+ return await jira.search_issues(query, max_results=5) # type: ignore[attr-defined,no-any-return]
383
+
384
+ async def _search_meetings(self, query: str) -> MeetingContext:
385
+ """Search meeting transcripts for query."""
386
+ fireflies = self._registry.get_adapter("fireflies")
387
+ if fireflies is None:
388
+ return MeetingContext(meetings=[])
389
+ return await fireflies.get_meeting_context(query) # type: ignore[attr-defined,no-any-return]
390
+
391
+ async def _search_docs(self, query: str) -> DocsContext:
392
+ """Search local documentation for query."""
393
+ docs = self._registry.get_adapter("local_docs")
394
+ if docs is None:
395
+ return DocsContext(sections=[])
396
+ return await docs.search_docs(query, max_results=5) # type: ignore[attr-defined,no-any-return]
397
+
398
+ def _format_jira_search_results(self, tickets: list[JiraTicket]) -> str:
399
+ """Format Jira search results as markdown."""
400
+ parts = ["### Jira Tickets"]
401
+ for ticket in tickets:
402
+ status_badge = f"[{ticket.status}]"
403
+ assignee = f" — {ticket.assignee}" if ticket.assignee else ""
404
+ parts.append(f"- **{ticket.ticket_id}**: {ticket.title} {status_badge}{assignee}")
405
+ return "\n".join(parts)
406
+
407
+ def _format_meeting_search_results(self, context: MeetingContext) -> str:
408
+ """Format meeting search results as markdown."""
409
+ parts = ["### Meeting Discussions"]
410
+ for meeting in context.meetings:
411
+ date_str = meeting.meeting_date.strftime("%Y-%m-%d")
412
+ parts.append(f"\n**{meeting.meeting_title}** ({date_str})")
413
+ # Truncate excerpt for search results
414
+ excerpt = meeting.excerpt
415
+ if len(excerpt) > 300:
416
+ excerpt = excerpt[:300] + "..."
417
+ parts.append(excerpt)
418
+ return "\n".join(parts)
419
+
420
+ def _format_docs_search_results(self, context: DocsContext) -> str:
421
+ """Format docs search results as markdown."""
422
+ parts = ["### Documentation"]
423
+ for section in context.sections:
424
+ title = section.section_title or section.file_path
425
+ doc_type = f"[{section.doc_type}]"
426
+ parts.append(f"\n**{title}** {doc_type}")
427
+ parts.append(f"*Source: {section.file_path}*")
428
+ # Truncate content for search results
429
+ content = section.content
430
+ if len(content) > 200:
431
+ content = content[:200] + "..."
432
+ parts.append(content)
433
+ return "\n".join(parts)
434
+
435
+ async def get_standards(
436
+ self, area: str | None = None
437
+ ) -> dict[str, str | None | int | list[str]]:
438
+ """Get coding standards from local documentation.
439
+
440
+ Args:
441
+ area: Optional area to filter (e.g., 'typescript', 'testing').
442
+
443
+ Returns:
444
+ Dictionary containing standards content and metadata.
445
+ """
446
+ start_time = time.monotonic()
447
+ logger.info("Get standards", extra={"area": area})
448
+
449
+ docs = self._registry.get_adapter("local_docs")
450
+ available_areas: list[str] = []
451
+
452
+ if docs is None:
453
+ content = self._get_standards_not_configured_message()
454
+ section_count = 0
455
+ else:
456
+ docs_context = await docs.get_standards(area) # type: ignore[attr-defined]
457
+ available_areas = await docs.list_standards_areas() # type: ignore[attr-defined]
458
+
459
+ if docs_context.sections:
460
+ # Format sections into markdown with header
461
+ title = f"# Coding Standards: {area}" if area else "# Coding Standards"
462
+ parts: list[str] = [title, ""]
463
+
464
+ for section in docs_context.sections:
465
+ # Add source file info
466
+ source_info = f"*Source: {section.file_path}*"
467
+ if section.section_title:
468
+ parts.append(f"## {section.section_title}")
469
+ parts.append(source_info)
470
+ parts.append("")
471
+ parts.append(section.content)
472
+ else:
473
+ parts.append(source_info)
474
+ parts.append("")
475
+ parts.append(section.content)
476
+ parts.append("") # Blank line between sections
477
+
478
+ content = "\n".join(parts)
479
+ section_count = len(docs_context.sections)
480
+ elif area and available_areas:
481
+ # Area specified but no matches - show available areas
482
+ content = self._get_no_matching_standards_message(area, available_areas)
483
+ section_count = 0
484
+ else:
485
+ content = self._get_standards_not_configured_message()
486
+ section_count = 0
487
+
488
+ duration_ms = int((time.monotonic() - start_time) * 1000)
489
+ logger.info(
490
+ "Get standards completed",
491
+ extra={"area": area, "duration_ms": duration_ms, "section_count": section_count},
492
+ )
493
+
494
+ return {
495
+ "area": area,
496
+ "content": content,
497
+ "section_count": section_count,
498
+ "available_areas": available_areas,
499
+ "duration_ms": duration_ms,
500
+ }
501
+
502
+ def _get_standards_not_configured_message(self) -> str:
503
+ """Generate message when no standards are configured."""
504
+ return """# Coding Standards
505
+
506
+ No standards documents found.
507
+
508
+ ## How to Configure
509
+
510
+ 1. Create markdown files in your docs directory
511
+ 2. Configure `sources.docs.paths` in `.devscontext.yaml`
512
+ 3. Place files in a `standards/` subdirectory
513
+
514
+ ### Example Structure
515
+
516
+ ```
517
+ docs/
518
+ standards/
519
+ typescript.md
520
+ testing.md
521
+ api-design.md
522
+ ```
523
+
524
+ ### Example .devscontext.yaml
525
+
526
+ ```yaml
527
+ sources:
528
+ docs:
529
+ enabled: true
530
+ paths:
531
+ - ./docs
532
+ ```
533
+
534
+ Files in the `standards/` directory will be automatically recognized as coding standards.
535
+ """
536
+
537
+ def _get_no_matching_standards_message(self, area: str, available_areas: list[str]) -> str:
538
+ """Generate message when no standards match the requested area."""
539
+ areas_list = "\n".join(f"- `{a}`" for a in available_areas)
540
+ return f"""# Coding Standards: {area}
541
+
542
+ No standards found for "{area}".
543
+
544
+ ## Available Areas
545
+
546
+ {areas_list}
547
+
548
+ Try one of the areas above, or omit the area parameter to see all standards.
549
+ """
550
+
551
+ async def _get_prebuilt_context(self, task_id: str) -> PrebuiltContext | None:
552
+ """Get pre-built context from storage, initializing if needed.
553
+
554
+ Args:
555
+ task_id: Task identifier.
556
+
557
+ Returns:
558
+ PrebuiltContext if found, None otherwise.
559
+ """
560
+ if self._storage is None:
561
+ return None
562
+
563
+ try:
564
+ # Initialize storage on first access
565
+ if not self._storage_initialized:
566
+ await self._storage.initialize()
567
+ self._storage_initialized = True
568
+
569
+ return await self._storage.get(task_id)
570
+
571
+ except Exception as e:
572
+ logger.warning(
573
+ "Failed to get pre-built context",
574
+ extra={"task_id": task_id, "error": str(e)},
575
+ )
576
+ return None
577
+
578
+ async def close(self) -> None:
579
+ """Close all adapter connections and clean up resources."""
580
+ await self._registry.close_all()
581
+ if self._storage is not None:
582
+ await self._storage.close()