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
devscontext/synthesis.py
ADDED
|
@@ -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
|