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,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()