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.
@@ -0,0 +1,1057 @@
1
+ """Context synthesis - LLM-based synthesis of context from multiple sources.
2
+
3
+ This module provides the LLMSynthesisPlugin class that uses an LLM to combine
4
+ raw data from adapters into a structured, concise context block suitable
5
+ for AI coding assistants.
6
+
7
+ Supported providers:
8
+ - anthropic: Claude models via the Anthropic SDK
9
+ - openai: GPT models via the OpenAI SDK
10
+ - ollama: Local models via Ollama HTTP API
11
+
12
+ This module implements the SynthesisPlugin interface for the plugin system.
13
+
14
+ Example:
15
+ config = SynthesisConfig(provider="anthropic", model="claude-haiku-4-5")
16
+ plugin = LLMSynthesisPlugin(config)
17
+ result = await plugin.synthesize(
18
+ task_id="PROJ-123",
19
+ source_contexts={"jira": jira_ctx, "fireflies": meeting_ctx, "local_docs": docs_ctx},
20
+ )
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from abc import ABC, abstractmethod
26
+ from typing import TYPE_CHECKING, Any, ClassVar
27
+
28
+ import httpx
29
+
30
+ from devscontext.constants import DEFAULT_HTTP_TIMEOUT_SECONDS
31
+ from devscontext.logging import get_logger
32
+ from devscontext.models import SynthesisConfig
33
+ from devscontext.plugins.base import SourceContext, SynthesisPlugin
34
+
35
+ if TYPE_CHECKING:
36
+ from devscontext.models import (
37
+ DocsContext,
38
+ GmailContext,
39
+ JiraContext,
40
+ MeetingContext,
41
+ SlackContext,
42
+ )
43
+
44
+ logger = get_logger(__name__)
45
+
46
+ # =============================================================================
47
+ # SYNTHESIS PROMPT
48
+ # =============================================================================
49
+
50
+ SYNTHESIS_PROMPT = """
51
+ You are a senior engineer preparing context for a colleague about to start
52
+ working on a task with an AI coding assistant.
53
+
54
+ Your job: combine the raw data below into a concise, structured context block
55
+ that gives the AI agent everything it needs to write correct, well-integrated code.
56
+
57
+ Rules:
58
+ - Target 2000-3000 tokens. Be concise but don't omit important details.
59
+ - Use these sections (skip any section with no relevant data):
60
+ ## Task: {task_id} — {title}
61
+ ### Requirements
62
+ ### Key Decisions
63
+ ### Team Discussions
64
+ ### External Context
65
+ ### Architecture Context
66
+ ### Coding Standards
67
+ ### Related Work
68
+ - For each fact, note the source in [brackets] at the end of the paragraph.
69
+ - If sources conflict, note the conflict explicitly.
70
+ - Extract acceptance criteria clearly as a checklist if available.
71
+ - For decisions from meetings, include WHO decided and WHEN.
72
+ - Do NOT include generic advice. Only include specific, actionable context.
73
+
74
+ Section-specific guidance:
75
+
76
+ ### Requirements
77
+ - Extract from Jira ticket description and acceptance criteria
78
+ - Present as numbered list or checklist
79
+ - Include any constraints mentioned in comments
80
+
81
+ ### Key Decisions
82
+ - Extract from meeting transcripts and formal decision records
83
+ - Include WHO made the decision, WHEN, and WHY
84
+ - Focus on technical decisions that affect implementation
85
+ - Distinguish from informal team discussions
86
+
87
+ ### Team Discussions
88
+ - Extract from Slack threads and informal communications
89
+ - Focus on clarifications, feedback, and informal agreements
90
+ - Note any concerns or open questions raised
91
+ - Include action items assigned to team members
92
+ - Mark as [Slack] to distinguish from formal decisions
93
+
94
+ ### External Context
95
+ - Extract from email threads with stakeholders or customers
96
+ - Focus on requirements clarifications from product/business
97
+ - Include customer feedback or constraints
98
+ - Note any deadlines or commitments mentioned
99
+ - Mark as [Email] to distinguish from internal discussions
100
+
101
+ ### Architecture Context
102
+ - Focus on ACTIONABLE details from architecture docs:
103
+ - Exact file paths where code should be added/modified
104
+ - Data flow and integration points
105
+ - Database tables and schemas involved
106
+ - Queue names, API endpoints, external services
107
+ - Do NOT include general architectural overview
108
+
109
+ ### Coding Standards
110
+ - Extract SPECIFIC rules that apply to this task:
111
+ - Error handling patterns (e.g., "use Result<T,E>, don't throw")
112
+ - Naming conventions for this codebase
113
+ - Testing requirements (what needs tests, mocking strategy)
114
+ - Async patterns to follow
115
+ - Do NOT include generic advice like "write clean code"
116
+
117
+ ### Related Work
118
+ - Linked tickets and their status
119
+ - Similar past implementations to reference
120
+
121
+ Raw data:
122
+ ---
123
+ {raw_data}
124
+ ---
125
+ """
126
+
127
+
128
+ # =============================================================================
129
+ # LLM PROVIDER INTERFACE
130
+ # =============================================================================
131
+
132
+
133
+ class LLMProvider(ABC):
134
+ """Abstract base class for LLM providers."""
135
+
136
+ @abstractmethod
137
+ async def generate(self, prompt: str, max_tokens: int) -> str:
138
+ """Generate a response from the LLM.
139
+
140
+ Args:
141
+ prompt: The prompt to send to the LLM.
142
+ max_tokens: Maximum tokens in the response.
143
+
144
+ Returns:
145
+ The generated text.
146
+
147
+ Raises:
148
+ Exception: If generation fails.
149
+ """
150
+ ...
151
+
152
+
153
+ class AnthropicProvider(LLMProvider):
154
+ """Anthropic Claude provider."""
155
+
156
+ def __init__(self, api_key: str, model: str) -> None:
157
+ """Initialize the Anthropic provider."""
158
+ self._api_key = api_key
159
+ self._model = model
160
+ self._client: Any = None
161
+
162
+ def _get_client(self) -> Any:
163
+ """Get or create the Anthropic client."""
164
+ if self._client is None:
165
+ try:
166
+ from anthropic import AsyncAnthropic # type: ignore[import-not-found]
167
+ except ImportError as e:
168
+ raise ImportError(
169
+ "anthropic package not installed. "
170
+ "Install with: pip install devscontext[anthropic]"
171
+ ) from e
172
+ self._client = AsyncAnthropic(api_key=self._api_key)
173
+ return self._client
174
+
175
+ async def generate(self, prompt: str, max_tokens: int) -> str:
176
+ """Generate using Claude."""
177
+ client = self._get_client()
178
+ response = await client.messages.create(
179
+ model=self._model,
180
+ max_tokens=max_tokens,
181
+ messages=[{"role": "user", "content": prompt}],
182
+ )
183
+ return str(response.content[0].text)
184
+
185
+
186
+ class OpenAIProvider(LLMProvider):
187
+ """OpenAI GPT provider."""
188
+
189
+ def __init__(self, api_key: str, model: str) -> None:
190
+ """Initialize the OpenAI provider."""
191
+ self._api_key = api_key
192
+ self._model = model
193
+ self._client: Any = None
194
+
195
+ def _get_client(self) -> Any:
196
+ """Get or create the OpenAI client."""
197
+ if self._client is None:
198
+ try:
199
+ from openai import AsyncOpenAI
200
+ except ImportError as e:
201
+ raise ImportError(
202
+ "openai package not installed. Install with: pip install devscontext[openai]"
203
+ ) from e
204
+ self._client = AsyncOpenAI(api_key=self._api_key)
205
+ return self._client
206
+
207
+ async def generate(self, prompt: str, max_tokens: int) -> str:
208
+ """Generate using GPT."""
209
+ client = self._get_client()
210
+ response = await client.chat.completions.create(
211
+ model=self._model,
212
+ max_tokens=max_tokens,
213
+ messages=[{"role": "user", "content": prompt}],
214
+ )
215
+ return response.choices[0].message.content or ""
216
+
217
+
218
+ class OllamaProvider(LLMProvider):
219
+ """Ollama local provider."""
220
+
221
+ def __init__(self, model: str, base_url: str = "http://localhost:11434") -> None:
222
+ """Initialize the Ollama provider."""
223
+ self._model = model
224
+ self._base_url = base_url
225
+ self._client: httpx.AsyncClient | None = None
226
+
227
+ def _get_client(self) -> httpx.AsyncClient:
228
+ """Get or create the HTTP client."""
229
+ if self._client is None:
230
+ self._client = httpx.AsyncClient(
231
+ base_url=self._base_url,
232
+ timeout=DEFAULT_HTTP_TIMEOUT_SECONDS * 2, # Ollama can be slower
233
+ )
234
+ return self._client
235
+
236
+ async def generate(self, prompt: str, max_tokens: int) -> str:
237
+ """Generate using Ollama."""
238
+ client = self._get_client()
239
+ response = await client.post(
240
+ "/api/generate",
241
+ json={
242
+ "model": self._model,
243
+ "prompt": prompt,
244
+ "stream": False,
245
+ "options": {"num_predict": max_tokens},
246
+ },
247
+ )
248
+ response.raise_for_status()
249
+ data = response.json()
250
+ return str(data.get("response", ""))
251
+
252
+
253
+ def create_provider(config: SynthesisConfig) -> LLMProvider:
254
+ """Factory function to create the appropriate LLM provider.
255
+
256
+ Args:
257
+ config: Synthesis configuration with provider and model settings.
258
+
259
+ Returns:
260
+ An LLMProvider instance.
261
+
262
+ Raises:
263
+ ValueError: If provider is not supported or API key is missing.
264
+ """
265
+ if config.provider == "anthropic":
266
+ if not config.api_key:
267
+ raise ValueError("Anthropic API key required for synthesis")
268
+ return AnthropicProvider(api_key=config.api_key, model=config.model)
269
+
270
+ elif config.provider == "openai":
271
+ if not config.api_key:
272
+ raise ValueError("OpenAI API key required for synthesis")
273
+ return OpenAIProvider(api_key=config.api_key, model=config.model)
274
+
275
+ elif config.provider == "ollama":
276
+ return OllamaProvider(model=config.model)
277
+
278
+ else:
279
+ raise ValueError(f"Unsupported synthesis provider: {config.provider}")
280
+
281
+
282
+ # =============================================================================
283
+ # LLM SYNTHESIS PLUGIN
284
+ # =============================================================================
285
+
286
+
287
+ class LLMSynthesisPlugin(SynthesisPlugin):
288
+ """LLM-based synthesis plugin for combining context from multiple sources.
289
+
290
+ Implements the SynthesisPlugin interface for the plugin system.
291
+ Uses an LLM to combine raw data from adapters into a structured,
292
+ concise context block suitable for AI coding assistants.
293
+
294
+ Supports custom prompt templates via config.prompt_template path.
295
+
296
+ Class Attributes:
297
+ name: Plugin identifier ("llm").
298
+ config_schema: Configuration model (SynthesisConfig).
299
+ """
300
+
301
+ # SynthesisPlugin class attributes
302
+ name: ClassVar[str] = "llm"
303
+ config_schema: ClassVar[type[SynthesisConfig]] = SynthesisConfig
304
+
305
+ def __init__(self, config: SynthesisConfig) -> None:
306
+ """Initialize the synthesis plugin.
307
+
308
+ Args:
309
+ config: Synthesis configuration.
310
+ """
311
+ self._config = config
312
+ self._provider: LLMProvider | None = None
313
+ self._custom_prompt: str | None = None
314
+
315
+ def _get_provider(self) -> LLMProvider:
316
+ """Get or create the LLM provider (lazy initialization)."""
317
+ if self._provider is None:
318
+ self._provider = create_provider(self._config)
319
+ return self._provider
320
+
321
+ def _get_prompt_template(self) -> str:
322
+ """Get the prompt template (custom or default).
323
+
324
+ If config.prompt_template is set, loads from that file.
325
+ Otherwise uses the default SYNTHESIS_PROMPT.
326
+
327
+ Returns:
328
+ The prompt template string.
329
+ """
330
+ if self._custom_prompt is not None:
331
+ return self._custom_prompt
332
+
333
+ if self._config.prompt_template:
334
+ from pathlib import Path
335
+
336
+ template_path = Path(self._config.prompt_template)
337
+ if template_path.exists():
338
+ self._custom_prompt = template_path.read_text()
339
+ logger.info(f"Loaded custom prompt template from: {template_path}")
340
+ return self._custom_prompt
341
+ else:
342
+ logger.warning(f"Custom prompt template not found: {template_path}, using default")
343
+
344
+ return SYNTHESIS_PROMPT
345
+
346
+ def _format_jira_context(self, ctx: JiraContext) -> str:
347
+ """Format Jira context as raw data for the prompt."""
348
+ parts = ["## JIRA TICKET"]
349
+ ticket = ctx.ticket
350
+
351
+ parts.append(f"**ID:** {ticket.ticket_id}")
352
+ parts.append(f"**Title:** {ticket.title}")
353
+ parts.append(f"**Status:** {ticket.status}")
354
+
355
+ if ticket.assignee:
356
+ parts.append(f"**Assignee:** {ticket.assignee}")
357
+ if ticket.labels:
358
+ parts.append(f"**Labels:** {', '.join(ticket.labels)}")
359
+ if ticket.components:
360
+ parts.append(f"**Components:** {', '.join(ticket.components)}")
361
+ if ticket.sprint:
362
+ parts.append(f"**Sprint:** {ticket.sprint}")
363
+
364
+ if ticket.description:
365
+ parts.append(f"\n**Description:**\n{ticket.description}")
366
+
367
+ if ticket.acceptance_criteria:
368
+ parts.append(f"\n**Acceptance Criteria:**\n{ticket.acceptance_criteria}")
369
+
370
+ # Comments
371
+ if ctx.comments:
372
+ parts.append(f"\n### Comments ({len(ctx.comments)})")
373
+ for comment in ctx.comments[:10]:
374
+ date_str = comment.created.strftime("%Y-%m-%d")
375
+ parts.append(f"\n**{comment.author}** ({date_str}):\n{comment.body}")
376
+
377
+ # Linked issues
378
+ if ctx.linked_issues:
379
+ parts.append(f"\n### Linked Issues ({len(ctx.linked_issues)})")
380
+ for linked in ctx.linked_issues:
381
+ parts.append(
382
+ f"- [{linked.ticket_id}] {linked.title} ({linked.status}) - {linked.link_type}"
383
+ )
384
+
385
+ return "\n".join(parts)
386
+
387
+ def _format_meeting_context(self, ctx: MeetingContext) -> str:
388
+ """Format meeting context as raw data for the prompt."""
389
+ if not ctx.meetings:
390
+ return ""
391
+
392
+ parts = ["## MEETING TRANSCRIPTS"]
393
+
394
+ for meeting in ctx.meetings:
395
+ date_str = meeting.meeting_date.strftime("%Y-%m-%d")
396
+ parts.append(f"\n### {meeting.meeting_title} ({date_str})")
397
+
398
+ if meeting.participants:
399
+ parts.append(f"**Participants:** {', '.join(meeting.participants)}")
400
+
401
+ parts.append(f"\n**Relevant Excerpt:**\n{meeting.excerpt}")
402
+
403
+ if meeting.action_items:
404
+ parts.append("\n**Action Items:**")
405
+ for item in meeting.action_items:
406
+ parts.append(f"- {item}")
407
+
408
+ if meeting.decisions:
409
+ parts.append("\n**Decisions:**")
410
+ for decision in meeting.decisions:
411
+ parts.append(f"- {decision}")
412
+
413
+ return "\n".join(parts)
414
+
415
+ def _format_architecture_docs(self, ctx: DocsContext) -> str:
416
+ """Format architecture documentation as raw data for the prompt."""
417
+ arch_sections = [s for s in ctx.sections if s.doc_type == "architecture"]
418
+ if not arch_sections:
419
+ return ""
420
+
421
+ parts = ["## ARCHITECTURE DOCS"]
422
+ parts.append("*Focus on file paths, data flow, integration points, and infrastructure.*\n")
423
+
424
+ for section in arch_sections:
425
+ title = section.section_title or section.file_path
426
+ parts.append(f"\n### {title}")
427
+ parts.append(f"**Source:** {section.file_path}")
428
+ parts.append(f"\n{section.content}")
429
+
430
+ return "\n".join(parts)
431
+
432
+ def _format_coding_standards(self, ctx: DocsContext) -> str:
433
+ """Format coding standards as raw data for the prompt."""
434
+ standards_sections = [s for s in ctx.sections if s.doc_type == "standards"]
435
+ if not standards_sections:
436
+ return ""
437
+
438
+ parts = ["## CODING STANDARDS"]
439
+ parts.append("*Specific rules and patterns to follow in this codebase.*\n")
440
+
441
+ for section in standards_sections:
442
+ title = section.section_title or section.file_path
443
+ parts.append(f"\n### {title}")
444
+ parts.append(f"**Source:** {section.file_path}")
445
+ parts.append(f"\n{section.content}")
446
+
447
+ return "\n".join(parts)
448
+
449
+ def _format_other_docs(self, ctx: DocsContext) -> str:
450
+ """Format ADRs and other documentation as raw data for the prompt."""
451
+ other_sections = [s for s in ctx.sections if s.doc_type in ("adr", "other")]
452
+ if not other_sections:
453
+ return ""
454
+
455
+ parts = ["## OTHER DOCUMENTATION"]
456
+
457
+ for section in other_sections:
458
+ doc_type_label = "ADR" if section.doc_type == "adr" else "Doc"
459
+ title = section.section_title or section.file_path
460
+ parts.append(f"\n### [{doc_type_label}] {title}")
461
+ parts.append(f"**Source:** {section.file_path}")
462
+ parts.append(f"\n{section.content}")
463
+
464
+ return "\n".join(parts)
465
+
466
+ def _format_slack_context(self, ctx: SlackContext) -> str:
467
+ """Format Slack context as raw data for the prompt."""
468
+ if not ctx.threads and not ctx.standalone_messages:
469
+ return ""
470
+
471
+ parts = ["## SLACK DISCUSSIONS"]
472
+ parts.append("*Informal team communications and discussions.*\n")
473
+
474
+ for thread in ctx.threads:
475
+ date_str = thread.parent_message.timestamp.strftime("%Y-%m-%d")
476
+ parts.append(f"\n### Thread in #{thread.parent_message.channel_name} ({date_str})")
477
+ parts.append(f"**Participants:** {', '.join(thread.participant_names)}")
478
+
479
+ parts.append(f"\n**{thread.parent_message.user_name}:** {thread.parent_message.text}")
480
+
481
+ for reply in thread.replies[:5]:
482
+ parts.append(f"**{reply.user_name}:** {reply.text}")
483
+
484
+ if thread.decisions:
485
+ parts.append("\n**Informal Decisions:**")
486
+ for d in thread.decisions:
487
+ parts.append(f"- {d}")
488
+
489
+ if thread.action_items:
490
+ parts.append("\n**Action Items:**")
491
+ for a in thread.action_items:
492
+ parts.append(f"- {a}")
493
+
494
+ for msg in ctx.standalone_messages[:5]:
495
+ date_str = msg.timestamp.strftime("%Y-%m-%d")
496
+ parts.append(f"\n**#{msg.channel_name}** ({date_str})")
497
+ parts.append(f"**{msg.user_name}:** {msg.text}")
498
+
499
+ return "\n".join(parts)
500
+
501
+ def _format_gmail_context(self, ctx: GmailContext) -> str:
502
+ """Format Gmail context as raw data for the prompt."""
503
+ if not ctx.threads:
504
+ return ""
505
+
506
+ parts = ["## EMAIL CONTEXT"]
507
+ parts.append("*External communications with stakeholders, customers, etc.*\n")
508
+
509
+ for thread in ctx.threads:
510
+ parts.append(f"\n### Email Thread: {thread.subject}")
511
+ parts.append(f"**Participants:** {', '.join(thread.participants[:5])}")
512
+ parts.append(f"**Latest:** {thread.latest_date.strftime('%Y-%m-%d')}")
513
+
514
+ for msg in thread.messages[:3]:
515
+ sender = msg.sender_name or msg.sender
516
+ date_str = msg.date.strftime("%Y-%m-%d")
517
+ parts.append(f"\n**{sender}** ({date_str}):")
518
+ body = msg.body_text or msg.snippet
519
+ if len(body) > 500:
520
+ body = body[:500] + "..."
521
+ parts.append(body)
522
+
523
+ return "\n".join(parts)
524
+
525
+ def _build_raw_data(
526
+ self,
527
+ jira_context: JiraContext | None,
528
+ meeting_context: MeetingContext | None,
529
+ docs_context: DocsContext | None,
530
+ slack_context: SlackContext | None = None,
531
+ gmail_context: GmailContext | None = None,
532
+ ) -> str:
533
+ """Build the raw data section for the synthesis prompt.
534
+
535
+ Args:
536
+ jira_context: Jira ticket context (if available).
537
+ meeting_context: Meeting transcripts context (if available).
538
+ docs_context: Documentation context (if available).
539
+ slack_context: Slack discussions context (if available).
540
+ gmail_context: Gmail email context (if available).
541
+
542
+ Returns:
543
+ Formatted raw data string.
544
+ """
545
+ sections: list[str] = []
546
+
547
+ # Jira ticket data
548
+ if jira_context and jira_context.ticket:
549
+ sections.append(self._format_jira_context(jira_context))
550
+
551
+ # Meeting transcripts (formal decisions)
552
+ if meeting_context and meeting_context.meetings:
553
+ sections.append(self._format_meeting_context(meeting_context))
554
+
555
+ # Slack discussions (informal team communications)
556
+ if slack_context and (slack_context.threads or slack_context.standalone_messages):
557
+ sections.append(self._format_slack_context(slack_context))
558
+
559
+ # Email context (external communications)
560
+ if gmail_context and gmail_context.threads:
561
+ sections.append(self._format_gmail_context(gmail_context))
562
+
563
+ # Documentation - split by type for better synthesis
564
+ if docs_context and docs_context.sections:
565
+ # Architecture docs (file paths, data flow, infrastructure)
566
+ arch_docs = self._format_architecture_docs(docs_context)
567
+ if arch_docs:
568
+ sections.append(arch_docs)
569
+
570
+ # Coding standards (patterns, rules, conventions)
571
+ standards = self._format_coding_standards(docs_context)
572
+ if standards:
573
+ sections.append(standards)
574
+
575
+ # ADRs and other docs
576
+ other_docs = self._format_other_docs(docs_context)
577
+ if other_docs:
578
+ sections.append(other_docs)
579
+
580
+ if not sections:
581
+ return "No context data available."
582
+
583
+ return "\n\n---\n\n".join(sections)
584
+
585
+ def _format_fallback(
586
+ self,
587
+ task_id: str,
588
+ jira_context: JiraContext | None,
589
+ meeting_context: MeetingContext | None,
590
+ docs_context: DocsContext | None,
591
+ slack_context: SlackContext | None = None,
592
+ gmail_context: GmailContext | None = None,
593
+ ) -> str:
594
+ """Format context as plain markdown when LLM synthesis fails.
595
+
596
+ This provides a fallback that's still useful even without LLM processing.
597
+
598
+ Args:
599
+ task_id: The task identifier.
600
+ jira_context: Jira ticket context (if available).
601
+ meeting_context: Meeting transcripts context (if available).
602
+ docs_context: Documentation context (if available).
603
+ slack_context: Slack discussions context (if available).
604
+ gmail_context: Gmail email context (if available).
605
+
606
+ Returns:
607
+ Plain markdown formatted context.
608
+ """
609
+ parts = [f"## Task: {task_id}"]
610
+ parts.append("\n*Note: LLM synthesis unavailable, showing raw context.*\n")
611
+
612
+ raw_data = self._build_raw_data(
613
+ jira_context, meeting_context, docs_context, slack_context, gmail_context
614
+ )
615
+ parts.append(raw_data)
616
+
617
+ return "\n".join(parts)
618
+
619
+ async def synthesize(
620
+ self,
621
+ task_id: str,
622
+ source_contexts: dict[str, SourceContext],
623
+ ) -> str:
624
+ """Synthesize context from multiple sources using LLM.
625
+
626
+ Implements the SynthesisPlugin interface. Takes context data from
627
+ all enabled adapters and combines it into a structured markdown
628
+ document suitable for AI coding assistants.
629
+
630
+ Args:
631
+ task_id: The task identifier.
632
+ source_contexts: Dict mapping adapter names to their SourceContext.
633
+
634
+ Returns:
635
+ Synthesized markdown context, or fallback raw format on error.
636
+ """
637
+ # Extract typed contexts from source_contexts
638
+ from devscontext.models import (
639
+ DocsContext,
640
+ GmailContext,
641
+ JiraContext,
642
+ MeetingContext,
643
+ SlackContext,
644
+ )
645
+
646
+ jira_context: JiraContext | None = None
647
+ meeting_context: MeetingContext | None = None
648
+ docs_context: DocsContext | None = None
649
+ slack_context: SlackContext | None = None
650
+ gmail_context: GmailContext | None = None
651
+
652
+ for _name, ctx in source_contexts.items():
653
+ if ctx.is_empty():
654
+ continue
655
+
656
+ if isinstance(ctx.data, JiraContext):
657
+ jira_context = ctx.data
658
+ elif isinstance(ctx.data, MeetingContext):
659
+ meeting_context = ctx.data
660
+ elif isinstance(ctx.data, DocsContext):
661
+ docs_context = ctx.data
662
+ elif isinstance(ctx.data, SlackContext):
663
+ slack_context = ctx.data
664
+ elif isinstance(ctx.data, GmailContext):
665
+ gmail_context = ctx.data
666
+
667
+ # Build raw data
668
+ raw_data = self._build_raw_data(
669
+ jira_context, meeting_context, docs_context, slack_context, gmail_context
670
+ )
671
+
672
+ if raw_data == "No context data available.":
673
+ return f"## Task: {task_id}\n\nNo context found for this task."
674
+
675
+ # Get title from Jira if available
676
+ title = ""
677
+ if jira_context and jira_context.ticket:
678
+ title = jira_context.ticket.title
679
+
680
+ # Build the prompt using custom or default template
681
+ prompt_template = self._get_prompt_template()
682
+ prompt = prompt_template.format(
683
+ task_id=task_id,
684
+ title=title,
685
+ raw_data=raw_data,
686
+ )
687
+
688
+ # Try LLM synthesis
689
+ try:
690
+ provider = self._get_provider()
691
+ result = await provider.generate(
692
+ prompt=prompt,
693
+ max_tokens=self._config.max_output_tokens,
694
+ )
695
+ logger.info(
696
+ "Synthesis completed",
697
+ extra={"task_id": task_id, "provider": self._config.provider},
698
+ )
699
+ return result
700
+
701
+ except ImportError as e:
702
+ logger.warning(
703
+ "LLM provider not available, using fallback",
704
+ extra={"error": str(e), "provider": self._config.provider},
705
+ )
706
+ return self._format_fallback(
707
+ task_id, jira_context, meeting_context, docs_context, slack_context, gmail_context
708
+ )
709
+
710
+ except ValueError as e:
711
+ logger.warning(
712
+ "LLM configuration error, using fallback",
713
+ extra={"error": str(e), "provider": self._config.provider},
714
+ )
715
+ return self._format_fallback(
716
+ task_id, jira_context, meeting_context, docs_context, slack_context, gmail_context
717
+ )
718
+
719
+ except Exception as e:
720
+ logger.warning(
721
+ "LLM synthesis failed, using fallback",
722
+ extra={"error": str(e), "provider": self._config.provider},
723
+ )
724
+ return self._format_fallback(
725
+ task_id, jira_context, meeting_context, docs_context, slack_context, gmail_context
726
+ )
727
+
728
+
729
+ # =============================================================================
730
+ # TEMPLATE SYNTHESIS PLUGIN
731
+ # =============================================================================
732
+
733
+
734
+ class TemplateSynthesisPlugin(SynthesisPlugin):
735
+ """Template-based synthesis plugin using Jinja2 templates.
736
+
737
+ Combines context data using a Jinja2 template file, providing
738
+ customizable output without LLM costs. Good for teams that want
739
+ consistent, deterministic output format.
740
+
741
+ Class Attributes:
742
+ name: Plugin identifier ("template").
743
+ config_schema: Configuration model (SynthesisConfig).
744
+ """
745
+
746
+ name: ClassVar[str] = "template"
747
+ config_schema: ClassVar[type[SynthesisConfig]] = SynthesisConfig
748
+
749
+ def __init__(self, config: SynthesisConfig) -> None:
750
+ """Initialize the template synthesis plugin.
751
+
752
+ Args:
753
+ config: Synthesis configuration with template_path.
754
+ """
755
+ self._config = config
756
+ self._template: Any = None
757
+
758
+ def _get_template(self) -> Any:
759
+ """Get or create the Jinja2 template (lazy initialization)."""
760
+ if self._template is None:
761
+ try:
762
+ from jinja2 import Environment, FileSystemLoader
763
+ except ImportError as e:
764
+ raise ImportError(
765
+ "jinja2 package not installed. Install with: pip install jinja2"
766
+ ) from e
767
+
768
+ template_path = self._config.template_path
769
+ if not template_path:
770
+ raise ValueError("template_path is required for template synthesis plugin")
771
+
772
+ from pathlib import Path
773
+
774
+ template_file = Path(template_path)
775
+ if not template_file.exists():
776
+ raise ValueError(f"Template file not found: {template_path}")
777
+
778
+ env = Environment(
779
+ loader=FileSystemLoader(str(template_file.parent)),
780
+ autoescape=False,
781
+ )
782
+ self._template = env.get_template(template_file.name)
783
+
784
+ return self._template
785
+
786
+ async def synthesize(
787
+ self,
788
+ task_id: str,
789
+ source_contexts: dict[str, SourceContext],
790
+ ) -> str:
791
+ """Synthesize context using Jinja2 template.
792
+
793
+ The template receives:
794
+ - task_id: The task identifier
795
+ - contexts: Dict of source contexts
796
+ - jira: JiraContext if available
797
+ - meetings: MeetingContext if available
798
+ - docs: DocsContext if available
799
+ - slack: SlackContext if available
800
+ - gmail: GmailContext if available
801
+
802
+ Args:
803
+ task_id: The task identifier.
804
+ source_contexts: Dict mapping adapter names to their SourceContext.
805
+
806
+ Returns:
807
+ Rendered template output.
808
+ """
809
+ from devscontext.models import (
810
+ DocsContext,
811
+ GmailContext,
812
+ JiraContext,
813
+ MeetingContext,
814
+ SlackContext,
815
+ )
816
+
817
+ # Extract typed contexts
818
+ jira_context: JiraContext | None = None
819
+ meeting_context: MeetingContext | None = None
820
+ docs_context: DocsContext | None = None
821
+ slack_context: SlackContext | None = None
822
+ gmail_context: GmailContext | None = None
823
+
824
+ for _name, ctx in source_contexts.items():
825
+ if ctx.is_empty():
826
+ continue
827
+ if isinstance(ctx.data, JiraContext):
828
+ jira_context = ctx.data
829
+ elif isinstance(ctx.data, MeetingContext):
830
+ meeting_context = ctx.data
831
+ elif isinstance(ctx.data, DocsContext):
832
+ docs_context = ctx.data
833
+ elif isinstance(ctx.data, SlackContext):
834
+ slack_context = ctx.data
835
+ elif isinstance(ctx.data, GmailContext):
836
+ gmail_context = ctx.data
837
+
838
+ try:
839
+ template = self._get_template()
840
+ return str(
841
+ template.render(
842
+ task_id=task_id,
843
+ contexts=source_contexts,
844
+ jira=jira_context,
845
+ meetings=meeting_context,
846
+ docs=docs_context,
847
+ slack=slack_context,
848
+ gmail=gmail_context,
849
+ )
850
+ )
851
+ except Exception as e:
852
+ logger.warning(f"Template synthesis failed: {e}")
853
+ return f"## Task: {task_id}\n\nTemplate synthesis error: {e}"
854
+
855
+
856
+ # =============================================================================
857
+ # PASSTHROUGH SYNTHESIS PLUGIN
858
+ # =============================================================================
859
+
860
+
861
+ class PassthroughSynthesisPlugin(SynthesisPlugin):
862
+ """Passthrough synthesis plugin that returns raw formatted data.
863
+
864
+ No LLM processing - just formats the raw context data as markdown.
865
+ Useful for debugging, testing, or when you want to see exactly
866
+ what data is being collected.
867
+
868
+ Class Attributes:
869
+ name: Plugin identifier ("passthrough").
870
+ config_schema: Configuration model (SynthesisConfig).
871
+ """
872
+
873
+ name: ClassVar[str] = "passthrough"
874
+ config_schema: ClassVar[type[SynthesisConfig]] = SynthesisConfig
875
+
876
+ def __init__(self, config: SynthesisConfig) -> None:
877
+ """Initialize the passthrough synthesis plugin.
878
+
879
+ Args:
880
+ config: Synthesis configuration (mostly unused for passthrough).
881
+ """
882
+ self._config = config
883
+
884
+ async def synthesize(
885
+ self,
886
+ task_id: str,
887
+ source_contexts: dict[str, SourceContext],
888
+ ) -> str:
889
+ """Return raw formatted context without LLM processing.
890
+
891
+ Args:
892
+ task_id: The task identifier.
893
+ source_contexts: Dict mapping adapter names to their SourceContext.
894
+
895
+ Returns:
896
+ Raw markdown formatted context.
897
+ """
898
+ from devscontext.models import (
899
+ DocsContext,
900
+ GmailContext,
901
+ JiraContext,
902
+ MeetingContext,
903
+ SlackContext,
904
+ )
905
+
906
+ parts = [f"## Task: {task_id}", ""]
907
+
908
+ if not source_contexts:
909
+ parts.append("No context data available.")
910
+ return "\n".join(parts)
911
+
912
+ for name, ctx in source_contexts.items():
913
+ if ctx.is_empty():
914
+ continue
915
+
916
+ parts.append(f"### Source: {name} ({ctx.source_type})")
917
+ parts.append(f"*Fetched at: {ctx.fetched_at.isoformat()}*")
918
+ parts.append("")
919
+
920
+ # Format based on data type
921
+ if isinstance(ctx.data, JiraContext):
922
+ parts.append(self._format_jira(ctx.data))
923
+ elif isinstance(ctx.data, MeetingContext):
924
+ parts.append(self._format_meetings(ctx.data))
925
+ elif isinstance(ctx.data, DocsContext):
926
+ parts.append(self._format_docs(ctx.data))
927
+ elif isinstance(ctx.data, SlackContext):
928
+ parts.append(self._format_slack(ctx.data))
929
+ elif isinstance(ctx.data, GmailContext):
930
+ parts.append(self._format_gmail(ctx.data))
931
+ elif ctx.raw_text:
932
+ parts.append(ctx.raw_text)
933
+ else:
934
+ parts.append(f"*Data type: {type(ctx.data).__name__}*")
935
+
936
+ parts.append("")
937
+
938
+ return "\n".join(parts)
939
+
940
+ def _format_jira(self, ctx: JiraContext) -> str:
941
+ """Format Jira context as markdown."""
942
+ lines = []
943
+ ticket = ctx.ticket
944
+
945
+ lines.append(f"**{ticket.ticket_id}**: {ticket.title}")
946
+ lines.append(f"- Status: {ticket.status}")
947
+ if ticket.assignee:
948
+ lines.append(f"- Assignee: {ticket.assignee}")
949
+ if ticket.labels:
950
+ lines.append(f"- Labels: {', '.join(ticket.labels)}")
951
+ if ticket.components:
952
+ lines.append(f"- Components: {', '.join(ticket.components)}")
953
+ if ticket.description:
954
+ lines.append(f"\n**Description:**\n{ticket.description}")
955
+ if ticket.acceptance_criteria:
956
+ lines.append(f"\n**Acceptance Criteria:**\n{ticket.acceptance_criteria}")
957
+
958
+ if ctx.comments:
959
+ lines.append(f"\n**Comments ({len(ctx.comments)}):**")
960
+ for c in ctx.comments[:5]:
961
+ lines.append(f"- {c.author}: {c.body[:200]}...")
962
+
963
+ if ctx.linked_issues:
964
+ lines.append(f"\n**Linked Issues ({len(ctx.linked_issues)}):**")
965
+ for li in ctx.linked_issues:
966
+ lines.append(f"- {li.ticket_id}: {li.title} ({li.link_type})")
967
+
968
+ return "\n".join(lines)
969
+
970
+ def _format_meetings(self, ctx: MeetingContext) -> str:
971
+ """Format meeting context as markdown."""
972
+ if not ctx.meetings:
973
+ return "*No meetings found*"
974
+
975
+ lines = []
976
+ for m in ctx.meetings:
977
+ date_str = m.meeting_date.strftime("%Y-%m-%d")
978
+ lines.append(f"**{m.meeting_title}** ({date_str})")
979
+ if m.participants:
980
+ lines.append(f"Participants: {', '.join(m.participants)}")
981
+ lines.append(f"\n{m.excerpt[:500]}...")
982
+ if m.decisions:
983
+ lines.append("\nDecisions:")
984
+ for d in m.decisions:
985
+ lines.append(f"- {d}")
986
+ if m.action_items:
987
+ lines.append("\nAction Items:")
988
+ for a in m.action_items:
989
+ lines.append(f"- {a}")
990
+ lines.append("")
991
+
992
+ return "\n".join(lines)
993
+
994
+ def _format_docs(self, ctx: DocsContext) -> str:
995
+ """Format docs context as markdown."""
996
+ if not ctx.sections:
997
+ return "*No documentation found*"
998
+
999
+ lines = []
1000
+ for s in ctx.sections:
1001
+ title = s.section_title or s.file_path
1002
+ lines.append(f"**{title}** [{s.doc_type}]")
1003
+ lines.append(f"*Source: {s.file_path}*")
1004
+ lines.append(f"\n{s.content[:500]}...")
1005
+ lines.append("")
1006
+
1007
+ return "\n".join(lines)
1008
+
1009
+ def _format_slack(self, ctx: SlackContext) -> str:
1010
+ """Format Slack context as markdown."""
1011
+ if not ctx.threads and not ctx.standalone_messages:
1012
+ return "*No Slack discussions found*"
1013
+
1014
+ lines = []
1015
+ for thread in ctx.threads:
1016
+ date_str = thread.parent_message.timestamp.strftime("%Y-%m-%d")
1017
+ lines.append(f"**#{thread.parent_message.channel_name}** ({date_str})")
1018
+ lines.append(f"Participants: {', '.join(thread.participant_names)}")
1019
+ user = thread.parent_message.user_name
1020
+ text = thread.parent_message.text[:200]
1021
+ lines.append(f"\n{user}: {text}...")
1022
+ if thread.decisions:
1023
+ lines.append("\nDecisions:")
1024
+ for d in thread.decisions[:3]:
1025
+ lines.append(f"- {d}")
1026
+ if thread.action_items:
1027
+ lines.append("\nAction Items:")
1028
+ for a in thread.action_items[:3]:
1029
+ lines.append(f"- {a}")
1030
+ lines.append("")
1031
+
1032
+ for msg in ctx.standalone_messages[:5]:
1033
+ date_str = msg.timestamp.strftime("%Y-%m-%d")
1034
+ lines.append(f"**#{msg.channel_name}** ({date_str}): {msg.text[:200]}...")
1035
+
1036
+ return "\n".join(lines)
1037
+
1038
+ def _format_gmail(self, ctx: GmailContext) -> str:
1039
+ """Format Gmail context as markdown."""
1040
+ if not ctx.threads:
1041
+ return "*No email threads found*"
1042
+
1043
+ lines = []
1044
+ for thread in ctx.threads:
1045
+ lines.append(f"**{thread.subject}**")
1046
+ lines.append(f"Participants: {', '.join(thread.participants[:5])}")
1047
+ lines.append(f"Latest: {thread.latest_date.strftime('%Y-%m-%d')}")
1048
+ for msg in thread.messages[:2]:
1049
+ sender = msg.sender_name or msg.sender
1050
+ lines.append(f"\n{sender}: {msg.snippet[:200]}...")
1051
+ lines.append("")
1052
+
1053
+ return "\n".join(lines)
1054
+
1055
+
1056
+ # Keep old name as alias for backward compatibility
1057
+ SynthesisEngine = LLMSynthesisPlugin