devscontext 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devscontext/__init__.py +3 -0
- devscontext/adapters/__init__.py +23 -0
- devscontext/adapters/base.py +105 -0
- devscontext/adapters/fireflies.py +585 -0
- devscontext/adapters/gmail.py +580 -0
- devscontext/adapters/jira.py +639 -0
- devscontext/adapters/local_docs.py +984 -0
- devscontext/adapters/slack.py +804 -0
- devscontext/agents/__init__.py +28 -0
- devscontext/agents/preprocessor.py +775 -0
- devscontext/agents/watcher.py +265 -0
- devscontext/cache.py +151 -0
- devscontext/cli.py +727 -0
- devscontext/config.py +264 -0
- devscontext/constants.py +107 -0
- devscontext/core.py +582 -0
- devscontext/exceptions.py +148 -0
- devscontext/logging.py +181 -0
- devscontext/models.py +504 -0
- devscontext/plugins/__init__.py +49 -0
- devscontext/plugins/base.py +321 -0
- devscontext/plugins/registry.py +544 -0
- devscontext/py.typed +0 -0
- devscontext/rag/__init__.py +113 -0
- devscontext/rag/embeddings.py +296 -0
- devscontext/rag/index.py +323 -0
- devscontext/server.py +374 -0
- devscontext/storage.py +321 -0
- devscontext/synthesis.py +1057 -0
- devscontext/utils.py +297 -0
- devscontext-0.1.0.dist-info/METADATA +253 -0
- devscontext-0.1.0.dist-info/RECORD +35 -0
- devscontext-0.1.0.dist-info/WHEEL +4 -0
- devscontext-0.1.0.dist-info/entry_points.txt +2 -0
- devscontext-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
"""Pre-processing pipeline for building rich context.
|
|
2
|
+
|
|
3
|
+
This module provides the PreprocessingPipeline class that builds rich context
|
|
4
|
+
for Jira tickets without latency pressure. Unlike on-demand fetching, the
|
|
5
|
+
pipeline can make more API calls, cross-reference sources, and run multiple
|
|
6
|
+
synthesis passes.
|
|
7
|
+
|
|
8
|
+
The pipeline includes:
|
|
9
|
+
1. Deep Jira fetch (ticket + comments + linked issues + epic/parent)
|
|
10
|
+
2. Broad meeting search (by ticket ID + title keywords + epic name)
|
|
11
|
+
3. Thorough doc matching (by components, labels, keywords, parent context)
|
|
12
|
+
4. Multi-pass synthesis (extraction, combination, gap detection)
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
pipeline = PreprocessingPipeline(config, storage)
|
|
16
|
+
context = await pipeline.process("PROJ-123")
|
|
17
|
+
print(f"Quality: {context.context_quality_score}")
|
|
18
|
+
print(f"Gaps: {context.gaps}")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
from datetime import UTC, datetime, timedelta
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from devscontext.logging import get_logger
|
|
28
|
+
from devscontext.models import (
|
|
29
|
+
DocsContext,
|
|
30
|
+
JiraContext,
|
|
31
|
+
MeetingContext,
|
|
32
|
+
PrebuiltContext,
|
|
33
|
+
)
|
|
34
|
+
from devscontext.plugins.registry import PluginRegistry
|
|
35
|
+
from devscontext.synthesis import create_provider
|
|
36
|
+
from devscontext.utils import extract_keywords
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from devscontext.models import DevsContextConfig, JiraTicket
|
|
40
|
+
from devscontext.storage import PrebuiltContextStorage
|
|
41
|
+
from devscontext.synthesis import LLMProvider
|
|
42
|
+
|
|
43
|
+
logger = get_logger(__name__)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# =============================================================================
|
|
47
|
+
# MULTI-PASS SYNTHESIS PROMPTS
|
|
48
|
+
# =============================================================================
|
|
49
|
+
|
|
50
|
+
EXTRACTION_PROMPT_JIRA = """
|
|
51
|
+
Extract the key facts from this Jira ticket for a developer about to implement it.
|
|
52
|
+
|
|
53
|
+
Focus on:
|
|
54
|
+
- What needs to be done (requirements)
|
|
55
|
+
- Any acceptance criteria
|
|
56
|
+
- Technical constraints or dependencies
|
|
57
|
+
- Key decisions made in comments
|
|
58
|
+
|
|
59
|
+
Return a structured summary in markdown format. Be concise but don't omit important details.
|
|
60
|
+
|
|
61
|
+
Jira Ticket Data:
|
|
62
|
+
---
|
|
63
|
+
{jira_data}
|
|
64
|
+
---
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
EXTRACTION_PROMPT_MEETINGS = """
|
|
68
|
+
Extract relevant decisions, action items, and discussions from these meeting excerpts.
|
|
69
|
+
|
|
70
|
+
Focus on:
|
|
71
|
+
- Technical decisions that affect implementation
|
|
72
|
+
- WHO made each decision and WHEN
|
|
73
|
+
- Any unresolved questions or debates
|
|
74
|
+
- Action items assigned to the team
|
|
75
|
+
|
|
76
|
+
Return a structured summary in markdown format. Include speaker names where available.
|
|
77
|
+
|
|
78
|
+
Meeting Excerpts:
|
|
79
|
+
---
|
|
80
|
+
{meeting_data}
|
|
81
|
+
---
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
EXTRACTION_PROMPT_DOCS = """
|
|
85
|
+
Extract relevant technical context from these documentation sections.
|
|
86
|
+
|
|
87
|
+
Focus on:
|
|
88
|
+
- Architecture patterns to follow
|
|
89
|
+
- Coding standards that apply
|
|
90
|
+
- File paths and integration points
|
|
91
|
+
- Any ADRs (Architecture Decision Records) that apply
|
|
92
|
+
|
|
93
|
+
Return a structured summary in markdown format. Be specific and actionable.
|
|
94
|
+
|
|
95
|
+
Documentation:
|
|
96
|
+
---
|
|
97
|
+
{docs_data}
|
|
98
|
+
---
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
COMBINATION_PROMPT = """
|
|
102
|
+
Combine these extracted facts into a unified context block for an AI coding assistant.
|
|
103
|
+
|
|
104
|
+
Use this structure:
|
|
105
|
+
## Task: {task_id} — {title}
|
|
106
|
+
### Requirements
|
|
107
|
+
### Key Decisions
|
|
108
|
+
### Architecture Context
|
|
109
|
+
### Coding Standards
|
|
110
|
+
### Related Work
|
|
111
|
+
|
|
112
|
+
Rules:
|
|
113
|
+
- Target 2000-3000 tokens. Be concise but complete.
|
|
114
|
+
- For each fact, note the source in [brackets] at the end.
|
|
115
|
+
- If sources conflict, note the conflict explicitly.
|
|
116
|
+
- Do NOT include generic advice. Only include specific, actionable context.
|
|
117
|
+
|
|
118
|
+
Extracted from Jira:
|
|
119
|
+
---
|
|
120
|
+
{jira_summary}
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
Extracted from Meetings:
|
|
124
|
+
---
|
|
125
|
+
{meeting_summary}
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
Extracted from Documentation:
|
|
129
|
+
---
|
|
130
|
+
{docs_summary}
|
|
131
|
+
---
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
GAP_DETECTION_PROMPT = """
|
|
135
|
+
Review this context for a Jira ticket and identify what's MISSING that a developer might need.
|
|
136
|
+
|
|
137
|
+
Check for these common gaps:
|
|
138
|
+
1. Missing acceptance criteria (how to know when done?)
|
|
139
|
+
2. Missing architecture documentation (where does this code go?)
|
|
140
|
+
3. Missing coding standards (what patterns to follow?)
|
|
141
|
+
4. No meeting discussions (was this design reviewed?)
|
|
142
|
+
5. No related ADRs (should decisions be documented?)
|
|
143
|
+
6. Unclear dependencies (what needs to be done first?)
|
|
144
|
+
7. Missing test requirements (what needs to be tested?)
|
|
145
|
+
|
|
146
|
+
Return a JSON array of strings, each describing a gap. If no gaps, return [].
|
|
147
|
+
|
|
148
|
+
Example output:
|
|
149
|
+
["No acceptance criteria defined in ticket", "No architecture docs found"]
|
|
150
|
+
|
|
151
|
+
Context to review:
|
|
152
|
+
---
|
|
153
|
+
{context}
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
Return ONLY a JSON array, no other text.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class PreprocessingPipeline:
|
|
161
|
+
"""Builds rich context for tickets not under latency pressure.
|
|
162
|
+
|
|
163
|
+
Unlike on-demand fetching, this pipeline can:
|
|
164
|
+
- Make more API calls for deeper context
|
|
165
|
+
- Cross-reference multiple sources
|
|
166
|
+
- Run multiple LLM synthesis passes
|
|
167
|
+
- Detect and report gaps in context
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
config: DevsContextConfig,
|
|
173
|
+
storage: PrebuiltContextStorage,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Initialize the pipeline.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
config: DevsContext configuration.
|
|
179
|
+
storage: Storage for pre-built context.
|
|
180
|
+
"""
|
|
181
|
+
self._config = config
|
|
182
|
+
self._storage = storage
|
|
183
|
+
|
|
184
|
+
# Initialize plugin registry for adapters
|
|
185
|
+
self._registry = PluginRegistry()
|
|
186
|
+
self._registry.register_builtin_plugins()
|
|
187
|
+
self._registry.load_from_config(config)
|
|
188
|
+
|
|
189
|
+
# LLM provider for synthesis
|
|
190
|
+
self._provider: LLMProvider | None = None
|
|
191
|
+
|
|
192
|
+
def _get_provider(self) -> LLMProvider:
|
|
193
|
+
"""Get or create LLM provider."""
|
|
194
|
+
if self._provider is None:
|
|
195
|
+
self._provider = create_provider(self._config.synthesis)
|
|
196
|
+
return self._provider
|
|
197
|
+
|
|
198
|
+
async def process(self, task_id: str) -> PrebuiltContext:
|
|
199
|
+
"""Run full preprocessing pipeline for a task.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
task_id: Jira ticket ID to process.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
PrebuiltContext with synthesized content and quality metrics.
|
|
206
|
+
"""
|
|
207
|
+
logger.info("Starting preprocessing pipeline", extra={"task_id": task_id})
|
|
208
|
+
|
|
209
|
+
# 1. Deep fetch from all sources
|
|
210
|
+
jira_ctx = await self._deep_jira_fetch(task_id)
|
|
211
|
+
if jira_ctx is None:
|
|
212
|
+
raise ValueError(f"Could not fetch Jira ticket: {task_id}")
|
|
213
|
+
|
|
214
|
+
meeting_ctx = await self._broad_meeting_search(jira_ctx.ticket)
|
|
215
|
+
docs_ctx = await self._thorough_doc_match(jira_ctx.ticket)
|
|
216
|
+
|
|
217
|
+
# 2. Multi-pass synthesis
|
|
218
|
+
synthesized, quality_score, gaps = await self._multi_pass_synthesis(
|
|
219
|
+
task_id=task_id,
|
|
220
|
+
jira_ctx=jira_ctx,
|
|
221
|
+
meeting_ctx=meeting_ctx,
|
|
222
|
+
docs_ctx=docs_ctx,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# 3. Build sources list
|
|
226
|
+
sources_used = [f"jira:{task_id}"]
|
|
227
|
+
for meeting in meeting_ctx.meetings:
|
|
228
|
+
sources_used.append(f"fireflies:{meeting.meeting_date.strftime('%Y-%m-%d')}")
|
|
229
|
+
for section in docs_ctx.sections:
|
|
230
|
+
sources_used.append(f"docs:{section.file_path}")
|
|
231
|
+
|
|
232
|
+
# 4. Calculate expiration and hash
|
|
233
|
+
ttl_hours = self._config.agents.preprocessor.context_ttl_hours
|
|
234
|
+
now = datetime.now(UTC)
|
|
235
|
+
expires_at = now + timedelta(hours=ttl_hours)
|
|
236
|
+
source_data_hash = self._compute_source_hash(jira_ctx.ticket)
|
|
237
|
+
|
|
238
|
+
# 5. Build and store context
|
|
239
|
+
context = PrebuiltContext(
|
|
240
|
+
task_id=task_id,
|
|
241
|
+
synthesized=synthesized,
|
|
242
|
+
sources_used=sources_used,
|
|
243
|
+
context_quality_score=quality_score,
|
|
244
|
+
gaps=gaps,
|
|
245
|
+
built_at=now,
|
|
246
|
+
expires_at=expires_at,
|
|
247
|
+
source_data_hash=source_data_hash,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
await self._storage.store(context)
|
|
251
|
+
|
|
252
|
+
logger.info(
|
|
253
|
+
"Preprocessing complete",
|
|
254
|
+
extra={
|
|
255
|
+
"task_id": task_id,
|
|
256
|
+
"quality_score": quality_score,
|
|
257
|
+
"gaps_count": len(gaps),
|
|
258
|
+
"sources_count": len(sources_used),
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return context
|
|
263
|
+
|
|
264
|
+
def _compute_source_hash(self, ticket: JiraTicket) -> str:
|
|
265
|
+
"""Compute hash of source data for staleness detection.
|
|
266
|
+
|
|
267
|
+
Uses the Jira ticket's updated timestamp as the primary indicator
|
|
268
|
+
of freshness.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
ticket: Jira ticket to hash.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Hash string.
|
|
275
|
+
"""
|
|
276
|
+
# Simple approach: hash the updated timestamp
|
|
277
|
+
data = ticket.updated.isoformat()
|
|
278
|
+
return hashlib.sha256(data.encode()).hexdigest()[:16]
|
|
279
|
+
|
|
280
|
+
async def _deep_jira_fetch(self, task_id: str) -> JiraContext | None:
|
|
281
|
+
"""Fetch ticket with all related context.
|
|
282
|
+
|
|
283
|
+
Fetches:
|
|
284
|
+
- Main ticket with full description
|
|
285
|
+
- All comments (not just recent)
|
|
286
|
+
- All linked issues with their summaries
|
|
287
|
+
- Epic/parent ticket if available (TODO: implement)
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
task_id: Jira ticket ID.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
JiraContext with full data, or None if not found.
|
|
294
|
+
"""
|
|
295
|
+
jira = self._registry.get_adapter("jira")
|
|
296
|
+
if jira is None:
|
|
297
|
+
logger.warning("Jira adapter not available")
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
# Use the adapter's fetch method which already does deep fetching
|
|
302
|
+
ctx = await jira.fetch_task_context(task_id)
|
|
303
|
+
if ctx.is_empty():
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
if isinstance(ctx.data, JiraContext):
|
|
307
|
+
return ctx.data
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
except Exception as e:
|
|
311
|
+
logger.error(
|
|
312
|
+
"Failed to fetch Jira context",
|
|
313
|
+
extra={"task_id": task_id, "error": str(e)},
|
|
314
|
+
)
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
async def _broad_meeting_search(self, ticket: JiraTicket) -> MeetingContext:
|
|
318
|
+
"""Search for meetings with multiple strategies.
|
|
319
|
+
|
|
320
|
+
Searches by:
|
|
321
|
+
- Ticket ID (e.g., "PROJ-123")
|
|
322
|
+
- Keywords from ticket title
|
|
323
|
+
- Epic/feature name if available (TODO: implement)
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
ticket: Jira ticket to search for.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
MeetingContext with all found excerpts.
|
|
330
|
+
"""
|
|
331
|
+
fireflies = self._registry.get_adapter("fireflies")
|
|
332
|
+
if fireflies is None:
|
|
333
|
+
return MeetingContext(meetings=[])
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
# Strategy 1: Search by ticket ID
|
|
337
|
+
ctx_by_id = await fireflies.fetch_task_context(ticket.ticket_id, ticket)
|
|
338
|
+
|
|
339
|
+
# Strategy 2: Search by title keywords
|
|
340
|
+
keywords = extract_keywords(ticket.title)
|
|
341
|
+
keyword_query = " ".join(keywords[:3]) # Use top 3 keywords
|
|
342
|
+
|
|
343
|
+
all_meetings = []
|
|
344
|
+
if isinstance(ctx_by_id.data, MeetingContext):
|
|
345
|
+
all_meetings.extend(ctx_by_id.data.meetings)
|
|
346
|
+
|
|
347
|
+
# Search by keywords if we have them
|
|
348
|
+
if keyword_query:
|
|
349
|
+
ctx_by_keywords = await fireflies.fetch_task_context(keyword_query, ticket)
|
|
350
|
+
if isinstance(ctx_by_keywords.data, MeetingContext):
|
|
351
|
+
# Deduplicate by meeting title + date
|
|
352
|
+
existing = {(m.meeting_title, m.meeting_date) for m in all_meetings}
|
|
353
|
+
for meeting in ctx_by_keywords.data.meetings:
|
|
354
|
+
key = (meeting.meeting_title, meeting.meeting_date)
|
|
355
|
+
if key not in existing:
|
|
356
|
+
all_meetings.append(meeting)
|
|
357
|
+
existing.add(key)
|
|
358
|
+
|
|
359
|
+
return MeetingContext(meetings=all_meetings)
|
|
360
|
+
|
|
361
|
+
except Exception as e:
|
|
362
|
+
logger.warning(
|
|
363
|
+
"Meeting search failed",
|
|
364
|
+
extra={"task_id": ticket.ticket_id, "error": str(e)},
|
|
365
|
+
)
|
|
366
|
+
return MeetingContext(meetings=[])
|
|
367
|
+
|
|
368
|
+
async def _thorough_doc_match(self, ticket: JiraTicket) -> DocsContext:
|
|
369
|
+
"""Match documentation with multiple strategies.
|
|
370
|
+
|
|
371
|
+
Matches by:
|
|
372
|
+
- Components (e.g., "payments" component -> payments.md)
|
|
373
|
+
- Labels (e.g., "api" label -> api-design.md)
|
|
374
|
+
- Keywords from ticket title
|
|
375
|
+
- Always includes standards (CLAUDE.md, .cursorrules)
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
ticket: Jira ticket to match docs for.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
DocsContext with all matched sections.
|
|
382
|
+
"""
|
|
383
|
+
docs = self._registry.get_adapter("local_docs")
|
|
384
|
+
if docs is None:
|
|
385
|
+
return DocsContext(sections=[])
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
# Use the adapter's fetch which already does multi-strategy matching
|
|
389
|
+
ctx = await docs.fetch_task_context(ticket.ticket_id, ticket)
|
|
390
|
+
|
|
391
|
+
if isinstance(ctx.data, DocsContext):
|
|
392
|
+
return ctx.data
|
|
393
|
+
return DocsContext(sections=[])
|
|
394
|
+
|
|
395
|
+
except Exception as e:
|
|
396
|
+
logger.warning(
|
|
397
|
+
"Doc matching failed",
|
|
398
|
+
extra={"task_id": ticket.ticket_id, "error": str(e)},
|
|
399
|
+
)
|
|
400
|
+
return DocsContext(sections=[])
|
|
401
|
+
|
|
402
|
+
async def _multi_pass_synthesis(
|
|
403
|
+
self,
|
|
404
|
+
task_id: str,
|
|
405
|
+
jira_ctx: JiraContext,
|
|
406
|
+
meeting_ctx: MeetingContext,
|
|
407
|
+
docs_ctx: DocsContext,
|
|
408
|
+
) -> tuple[str, float, list[str]]:
|
|
409
|
+
"""Run multi-pass synthesis with dedicated prompts.
|
|
410
|
+
|
|
411
|
+
Pass 1 - Clean Extraction:
|
|
412
|
+
Extract key facts from each source independently.
|
|
413
|
+
|
|
414
|
+
Pass 2 - Combination:
|
|
415
|
+
Combine extracted facts into unified context.
|
|
416
|
+
|
|
417
|
+
Pass 3 - Gap Detection:
|
|
418
|
+
Identify missing context that developers might need.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
task_id: Jira ticket ID.
|
|
422
|
+
jira_ctx: Jira context with ticket, comments, links.
|
|
423
|
+
meeting_ctx: Meeting excerpts.
|
|
424
|
+
docs_ctx: Documentation sections.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
Tuple of (synthesized_markdown, quality_score, gaps_list).
|
|
428
|
+
"""
|
|
429
|
+
provider = self._get_provider()
|
|
430
|
+
max_tokens = self._config.synthesis.max_output_tokens
|
|
431
|
+
|
|
432
|
+
# === Pass 1: Extraction ===
|
|
433
|
+
logger.debug("Pass 1: Extracting from sources")
|
|
434
|
+
|
|
435
|
+
# Extract from Jira
|
|
436
|
+
jira_data = self._format_jira_for_extraction(jira_ctx)
|
|
437
|
+
jira_prompt = EXTRACTION_PROMPT_JIRA.format(jira_data=jira_data)
|
|
438
|
+
jira_summary = await provider.generate(jira_prompt, max_tokens=1500)
|
|
439
|
+
|
|
440
|
+
# Extract from meetings (if any)
|
|
441
|
+
meeting_summary = "No meeting discussions found."
|
|
442
|
+
if meeting_ctx.meetings:
|
|
443
|
+
meeting_data = self._format_meetings_for_extraction(meeting_ctx)
|
|
444
|
+
meeting_prompt = EXTRACTION_PROMPT_MEETINGS.format(meeting_data=meeting_data)
|
|
445
|
+
meeting_summary = await provider.generate(meeting_prompt, max_tokens=1500)
|
|
446
|
+
|
|
447
|
+
# Extract from docs (if any)
|
|
448
|
+
docs_summary = "No relevant documentation found."
|
|
449
|
+
if docs_ctx.sections:
|
|
450
|
+
docs_data = self._format_docs_for_extraction(docs_ctx)
|
|
451
|
+
docs_prompt = EXTRACTION_PROMPT_DOCS.format(docs_data=docs_data)
|
|
452
|
+
docs_summary = await provider.generate(docs_prompt, max_tokens=1500)
|
|
453
|
+
|
|
454
|
+
# === Pass 2: Combination ===
|
|
455
|
+
logger.debug("Pass 2: Combining extracted facts")
|
|
456
|
+
|
|
457
|
+
combination_prompt = COMBINATION_PROMPT.format(
|
|
458
|
+
task_id=task_id,
|
|
459
|
+
title=jira_ctx.ticket.title,
|
|
460
|
+
jira_summary=jira_summary,
|
|
461
|
+
meeting_summary=meeting_summary,
|
|
462
|
+
docs_summary=docs_summary,
|
|
463
|
+
)
|
|
464
|
+
synthesized = await provider.generate(combination_prompt, max_tokens=max_tokens)
|
|
465
|
+
|
|
466
|
+
# === Pass 3: Gap Detection ===
|
|
467
|
+
logger.debug("Pass 3: Detecting gaps")
|
|
468
|
+
|
|
469
|
+
# Rule-based gap detection (reliable, consistent)
|
|
470
|
+
rule_gaps = self._detect_gaps(jira_ctx, meeting_ctx, docs_ctx)
|
|
471
|
+
|
|
472
|
+
# LLM-based gap detection (additional insights)
|
|
473
|
+
gap_prompt = GAP_DETECTION_PROMPT.format(context=synthesized)
|
|
474
|
+
gap_response = await provider.generate(gap_prompt, max_tokens=500)
|
|
475
|
+
llm_gaps = self._parse_gaps(gap_response)
|
|
476
|
+
|
|
477
|
+
# Merge gaps (rule-based first, then unique LLM gaps)
|
|
478
|
+
all_gaps = list(rule_gaps)
|
|
479
|
+
existing_lower = {g.lower() for g in all_gaps}
|
|
480
|
+
for gap in llm_gaps:
|
|
481
|
+
if gap.lower() not in existing_lower:
|
|
482
|
+
all_gaps.append(gap)
|
|
483
|
+
existing_lower.add(gap.lower())
|
|
484
|
+
|
|
485
|
+
# === Calculate Quality Score ===
|
|
486
|
+
quality_score = self._calculate_quality_score(jira_ctx, meeting_ctx, docs_ctx)
|
|
487
|
+
|
|
488
|
+
# === Append Gaps to Synthesized Context ===
|
|
489
|
+
if all_gaps:
|
|
490
|
+
synthesized = self._append_gaps_to_context(synthesized, all_gaps, quality_score)
|
|
491
|
+
|
|
492
|
+
return synthesized, quality_score, all_gaps
|
|
493
|
+
|
|
494
|
+
def _format_jira_for_extraction(self, ctx: JiraContext) -> str:
|
|
495
|
+
"""Format Jira context for extraction prompt."""
|
|
496
|
+
parts = [
|
|
497
|
+
f"## Ticket: {ctx.ticket.ticket_id}",
|
|
498
|
+
f"**Title:** {ctx.ticket.title}",
|
|
499
|
+
f"**Status:** {ctx.ticket.status}",
|
|
500
|
+
]
|
|
501
|
+
|
|
502
|
+
if ctx.ticket.description:
|
|
503
|
+
parts.append(f"\n**Description:**\n{ctx.ticket.description}")
|
|
504
|
+
|
|
505
|
+
if ctx.ticket.acceptance_criteria:
|
|
506
|
+
parts.append(f"\n**Acceptance Criteria:**\n{ctx.ticket.acceptance_criteria}")
|
|
507
|
+
|
|
508
|
+
if ctx.ticket.labels:
|
|
509
|
+
parts.append(f"\n**Labels:** {', '.join(ctx.ticket.labels)}")
|
|
510
|
+
|
|
511
|
+
if ctx.ticket.components:
|
|
512
|
+
parts.append(f"\n**Components:** {', '.join(ctx.ticket.components)}")
|
|
513
|
+
|
|
514
|
+
if ctx.comments:
|
|
515
|
+
parts.append("\n**Comments:**")
|
|
516
|
+
for comment in ctx.comments:
|
|
517
|
+
date_str = comment.created.strftime("%Y-%m-%d")
|
|
518
|
+
parts.append(f"\n*{comment.author} ({date_str}):*\n{comment.body}")
|
|
519
|
+
|
|
520
|
+
if ctx.linked_issues:
|
|
521
|
+
parts.append("\n**Linked Issues:**")
|
|
522
|
+
for link in ctx.linked_issues:
|
|
523
|
+
parts.append(f"- {link.link_type}: {link.ticket_id} ({link.status}) — {link.title}")
|
|
524
|
+
|
|
525
|
+
return "\n".join(parts)
|
|
526
|
+
|
|
527
|
+
def _format_meetings_for_extraction(self, ctx: MeetingContext) -> str:
|
|
528
|
+
"""Format meeting context for extraction prompt."""
|
|
529
|
+
parts = []
|
|
530
|
+
for meeting in ctx.meetings:
|
|
531
|
+
date_str = meeting.meeting_date.strftime("%Y-%m-%d")
|
|
532
|
+
parts.append(f"## {meeting.meeting_title} ({date_str})")
|
|
533
|
+
if meeting.participants:
|
|
534
|
+
parts.append(f"**Participants:** {', '.join(meeting.participants)}")
|
|
535
|
+
parts.append(f"\n{meeting.excerpt}")
|
|
536
|
+
|
|
537
|
+
if meeting.action_items:
|
|
538
|
+
parts.append("\n**Action Items:**")
|
|
539
|
+
for item in meeting.action_items:
|
|
540
|
+
parts.append(f"- {item}")
|
|
541
|
+
|
|
542
|
+
if meeting.decisions:
|
|
543
|
+
parts.append("\n**Decisions:**")
|
|
544
|
+
for decision in meeting.decisions:
|
|
545
|
+
parts.append(f"- {decision}")
|
|
546
|
+
|
|
547
|
+
parts.append("") # Blank line between meetings
|
|
548
|
+
|
|
549
|
+
return "\n".join(parts)
|
|
550
|
+
|
|
551
|
+
def _format_docs_for_extraction(self, ctx: DocsContext) -> str:
|
|
552
|
+
"""Format documentation context for extraction prompt."""
|
|
553
|
+
parts = []
|
|
554
|
+
for section in ctx.sections:
|
|
555
|
+
title = section.section_title or section.file_path
|
|
556
|
+
parts.append(f"## {title}")
|
|
557
|
+
parts.append(f"*Source: {section.file_path}* [{section.doc_type}]")
|
|
558
|
+
parts.append(f"\n{section.content}")
|
|
559
|
+
parts.append("") # Blank line between sections
|
|
560
|
+
|
|
561
|
+
return "\n".join(parts)
|
|
562
|
+
|
|
563
|
+
def _parse_gaps(self, response: str) -> list[str]:
|
|
564
|
+
"""Parse gap detection response into list of gaps."""
|
|
565
|
+
import json
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
# Try to parse as JSON array
|
|
569
|
+
response = response.strip()
|
|
570
|
+
# Handle markdown code blocks
|
|
571
|
+
if response.startswith("```"):
|
|
572
|
+
lines = response.split("\n")
|
|
573
|
+
response = "\n".join(lines[1:-1])
|
|
574
|
+
|
|
575
|
+
gaps = json.loads(response)
|
|
576
|
+
if isinstance(gaps, list):
|
|
577
|
+
return [str(g) for g in gaps if g]
|
|
578
|
+
return []
|
|
579
|
+
except json.JSONDecodeError:
|
|
580
|
+
# If not valid JSON, try to extract bullet points
|
|
581
|
+
gaps = []
|
|
582
|
+
for line in response.split("\n"):
|
|
583
|
+
line = line.strip()
|
|
584
|
+
if line.startswith("- ") or line.startswith("* "):
|
|
585
|
+
gaps.append(line[2:])
|
|
586
|
+
elif line.startswith('"') and line.endswith('"'):
|
|
587
|
+
gaps.append(line[1:-1])
|
|
588
|
+
return gaps
|
|
589
|
+
|
|
590
|
+
def _calculate_quality_score(
|
|
591
|
+
self,
|
|
592
|
+
jira_ctx: JiraContext,
|
|
593
|
+
meeting_ctx: MeetingContext,
|
|
594
|
+
docs_ctx: DocsContext,
|
|
595
|
+
) -> float:
|
|
596
|
+
"""Calculate context quality score based on completeness.
|
|
597
|
+
|
|
598
|
+
Scoring rubric (each dimension 0-1, averaged):
|
|
599
|
+
1. Has clear requirements/acceptance criteria
|
|
600
|
+
2. Has implementation decisions (meeting context)
|
|
601
|
+
3. Has architecture context (relevant docs)
|
|
602
|
+
4. Has coding standards (standards docs)
|
|
603
|
+
5. Has related work context (linked issues)
|
|
604
|
+
|
|
605
|
+
Returns:
|
|
606
|
+
Quality score between 0 and 1.
|
|
607
|
+
"""
|
|
608
|
+
dimensions: list[float] = []
|
|
609
|
+
|
|
610
|
+
# 1. Has clear requirements or acceptance criteria
|
|
611
|
+
has_requirements = bool(
|
|
612
|
+
jira_ctx.ticket.acceptance_criteria
|
|
613
|
+
or (jira_ctx.ticket.description and len(jira_ctx.ticket.description) > 100)
|
|
614
|
+
)
|
|
615
|
+
dimensions.append(1.0 if has_requirements else 0.0)
|
|
616
|
+
|
|
617
|
+
# 2. Has implementation decisions (meeting context)
|
|
618
|
+
has_meetings = bool(meeting_ctx.meetings)
|
|
619
|
+
dimensions.append(1.0 if has_meetings else 0.0)
|
|
620
|
+
|
|
621
|
+
# 3. Has architecture context (relevant docs matched)
|
|
622
|
+
has_arch = any(s.doc_type == "architecture" for s in docs_ctx.sections)
|
|
623
|
+
dimensions.append(1.0 if has_arch else 0.0)
|
|
624
|
+
|
|
625
|
+
# 4. Has coding standards (standards docs included)
|
|
626
|
+
has_standards = any(s.doc_type == "standards" for s in docs_ctx.sections)
|
|
627
|
+
dimensions.append(1.0 if has_standards else 0.0)
|
|
628
|
+
|
|
629
|
+
# 5. Has related work context (linked issues exist)
|
|
630
|
+
has_related = bool(jira_ctx.linked_issues)
|
|
631
|
+
dimensions.append(1.0 if has_related else 0.0)
|
|
632
|
+
|
|
633
|
+
# Return average of all dimensions
|
|
634
|
+
return sum(dimensions) / len(dimensions) if dimensions else 0.0
|
|
635
|
+
|
|
636
|
+
def _detect_gaps(
|
|
637
|
+
self,
|
|
638
|
+
jira_ctx: JiraContext,
|
|
639
|
+
meeting_ctx: MeetingContext,
|
|
640
|
+
docs_ctx: DocsContext,
|
|
641
|
+
) -> list[str]:
|
|
642
|
+
"""Detect specific gaps in context and return actionable messages.
|
|
643
|
+
|
|
644
|
+
Checks for:
|
|
645
|
+
- Missing acceptance criteria
|
|
646
|
+
- Missing meeting discussions
|
|
647
|
+
- Missing architecture documentation
|
|
648
|
+
- Missing coding standards
|
|
649
|
+
- Missing linked/related tickets
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
List of actionable gap messages.
|
|
653
|
+
"""
|
|
654
|
+
gaps: list[str] = []
|
|
655
|
+
|
|
656
|
+
# Check for acceptance criteria
|
|
657
|
+
if not jira_ctx.ticket.acceptance_criteria:
|
|
658
|
+
gaps.append(
|
|
659
|
+
"No acceptance criteria found in Jira ticket — "
|
|
660
|
+
"consider adding clear done criteria before implementation"
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
# Check for meeting discussions
|
|
664
|
+
if not meeting_ctx.meetings:
|
|
665
|
+
gaps.append(
|
|
666
|
+
"No meeting discussions found for this task — "
|
|
667
|
+
"consider a planning discussion with the team"
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
# Check for architecture documentation
|
|
671
|
+
has_arch = any(s.doc_type == "architecture" for s in docs_ctx.sections)
|
|
672
|
+
if not has_arch:
|
|
673
|
+
# Try to identify the service area from components or labels
|
|
674
|
+
service_area = None
|
|
675
|
+
if jira_ctx.ticket.components:
|
|
676
|
+
service_area = jira_ctx.ticket.components[0]
|
|
677
|
+
elif jira_ctx.ticket.labels:
|
|
678
|
+
service_area = jira_ctx.ticket.labels[0]
|
|
679
|
+
|
|
680
|
+
if service_area:
|
|
681
|
+
gaps.append(
|
|
682
|
+
f"No architecture documentation found for service area '{service_area}' — "
|
|
683
|
+
"consider documenting the architectural approach"
|
|
684
|
+
)
|
|
685
|
+
else:
|
|
686
|
+
gaps.append(
|
|
687
|
+
"No architecture documentation found — "
|
|
688
|
+
"consider documenting the architectural approach for this feature"
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
# Check for coding standards
|
|
692
|
+
has_standards = any(s.doc_type == "standards" for s in docs_ctx.sections)
|
|
693
|
+
if not has_standards:
|
|
694
|
+
gaps.append(
|
|
695
|
+
"No coding standards documentation found — "
|
|
696
|
+
"check if CLAUDE.md or standards/ docs exist in the repository"
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
# Check for linked/related tickets
|
|
700
|
+
if not jira_ctx.linked_issues:
|
|
701
|
+
gaps.append(
|
|
702
|
+
"No linked or related tickets — "
|
|
703
|
+
"this task may lack broader context; consider linking related work"
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
# Additional checks from comments and description
|
|
707
|
+
ticket = jira_ctx.ticket
|
|
708
|
+
description = (ticket.description or "").lower()
|
|
709
|
+
|
|
710
|
+
# Check for unclear dependencies
|
|
711
|
+
no_ac = not ticket.acceptance_criteria
|
|
712
|
+
no_depends_mention = "depends" not in description and "blocked" not in description
|
|
713
|
+
if no_ac and no_depends_mention and not jira_ctx.linked_issues:
|
|
714
|
+
gaps.append(
|
|
715
|
+
"No dependencies identified — "
|
|
716
|
+
"verify this task has no blocking dependencies before starting"
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
return gaps
|
|
720
|
+
|
|
721
|
+
def _append_gaps_to_context(
|
|
722
|
+
self,
|
|
723
|
+
synthesized: str,
|
|
724
|
+
gaps: list[str],
|
|
725
|
+
quality_score: float,
|
|
726
|
+
) -> str:
|
|
727
|
+
"""Append identified gaps to the synthesized context.
|
|
728
|
+
|
|
729
|
+
Adds a section at the end of the context to inform the AI agent
|
|
730
|
+
about missing information that may affect implementation.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
synthesized: The synthesized context markdown.
|
|
734
|
+
gaps: List of identified gaps.
|
|
735
|
+
quality_score: The calculated quality score.
|
|
736
|
+
|
|
737
|
+
Returns:
|
|
738
|
+
Synthesized context with gaps section appended.
|
|
739
|
+
"""
|
|
740
|
+
if not gaps:
|
|
741
|
+
return synthesized
|
|
742
|
+
|
|
743
|
+
# Format quality indicator
|
|
744
|
+
if quality_score >= 0.8:
|
|
745
|
+
quality_label = "Good"
|
|
746
|
+
elif quality_score >= 0.6:
|
|
747
|
+
quality_label = "Moderate"
|
|
748
|
+
elif quality_score >= 0.4:
|
|
749
|
+
quality_label = "Limited"
|
|
750
|
+
else:
|
|
751
|
+
quality_label = "Incomplete"
|
|
752
|
+
|
|
753
|
+
gaps_section = f"""
|
|
754
|
+
|
|
755
|
+
---
|
|
756
|
+
|
|
757
|
+
## ⚠️ Context Quality: {quality_label} ({quality_score:.0%})
|
|
758
|
+
|
|
759
|
+
The following gaps were identified in the available context.
|
|
760
|
+
Consider addressing these before or during implementation:
|
|
761
|
+
|
|
762
|
+
"""
|
|
763
|
+
for gap in gaps:
|
|
764
|
+
gaps_section += f"- {gap}\n"
|
|
765
|
+
|
|
766
|
+
gaps_section += """
|
|
767
|
+
*These gaps were automatically detected during context preprocessing.*
|
|
768
|
+
*Some may not be relevant to your specific task.*
|
|
769
|
+
"""
|
|
770
|
+
|
|
771
|
+
return synthesized + gaps_section
|
|
772
|
+
|
|
773
|
+
async def close(self) -> None:
|
|
774
|
+
"""Close resources."""
|
|
775
|
+
await self._registry.close_all()
|