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,585 @@
1
+ """Fireflies adapter for fetching meeting transcript context.
2
+
3
+ This adapter connects to the Fireflies.ai GraphQL API to fetch meeting
4
+ transcripts and search for relevant discussions related to a task.
5
+
6
+ This adapter implements the Adapter interface for the plugin system.
7
+
8
+ Example:
9
+ config = FirefliesConfig(api_key="your-api-key", enabled=True)
10
+ adapter = FirefliesAdapter(config)
11
+ context = await adapter.fetch_task_context("PROJ-123")
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from datetime import UTC, datetime
18
+ from typing import TYPE_CHECKING, Any, ClassVar
19
+
20
+ import httpx
21
+
22
+ from devscontext.constants import (
23
+ ADAPTER_FIREFLIES,
24
+ DEFAULT_HTTP_TIMEOUT_SECONDS,
25
+ FIREFLIES_API_URL,
26
+ FIREFLIES_CONTEXT_WINDOW,
27
+ FIREFLIES_SEARCH_LIMIT,
28
+ SOURCE_TYPE_MEETING,
29
+ )
30
+ from devscontext.logging import get_logger
31
+ from devscontext.models import ContextData, FirefliesConfig, MeetingContext, MeetingExcerpt
32
+ from devscontext.plugins.base import Adapter, SearchResult, SourceContext
33
+
34
+ if TYPE_CHECKING:
35
+ from devscontext.models import JiraTicket
36
+
37
+ logger = get_logger(__name__)
38
+
39
+ # GraphQL query to search transcripts
40
+ SEARCH_TRANSCRIPTS_QUERY = """
41
+ query SearchTranscripts($query: String!, $limit: Int!) {
42
+ transcripts(filter_string: $query, limit: $limit) {
43
+ id
44
+ title
45
+ date
46
+ participants
47
+ summary {
48
+ overview
49
+ action_items
50
+ keywords
51
+ }
52
+ sentences {
53
+ text
54
+ speaker_name
55
+ }
56
+ }
57
+ }
58
+ """
59
+
60
+
61
+ class FirefliesAdapter(Adapter):
62
+ """Adapter for fetching context from Fireflies meeting transcripts.
63
+
64
+ Implements the Adapter interface for the plugin system.
65
+ Connects to Fireflies.ai to search for meeting transcripts
66
+ that mention a specific task ID or keywords.
67
+
68
+ Class Attributes:
69
+ name: Adapter identifier ("fireflies").
70
+ source_type: Source category ("meeting").
71
+ config_schema: Configuration model (FirefliesConfig).
72
+ """
73
+
74
+ # Adapter class attributes
75
+ name: ClassVar[str] = ADAPTER_FIREFLIES
76
+ source_type: ClassVar[str] = SOURCE_TYPE_MEETING
77
+ config_schema: ClassVar[type[FirefliesConfig]] = FirefliesConfig
78
+
79
+ def __init__(self, config: FirefliesConfig) -> None:
80
+ """Initialize the Fireflies adapter.
81
+
82
+ Args:
83
+ config: Fireflies configuration with API key.
84
+ """
85
+ self._config = config
86
+ self._client: httpx.AsyncClient | None = None
87
+
88
+ def _get_client(self) -> httpx.AsyncClient:
89
+ """Get or create the HTTP client."""
90
+ if self._client is None:
91
+ self._client = httpx.AsyncClient(
92
+ headers={
93
+ "Authorization": f"Bearer {self._config.api_key}",
94
+ "Content-Type": "application/json",
95
+ },
96
+ timeout=DEFAULT_HTTP_TIMEOUT_SECONDS,
97
+ )
98
+ return self._client
99
+
100
+ async def close(self) -> None:
101
+ """Close the HTTP client."""
102
+ if self._client is not None:
103
+ await self._client.aclose()
104
+ self._client = None
105
+
106
+ async def search_transcripts(self, query: str) -> list[dict[str, Any]]:
107
+ """Search Fireflies transcripts by query string.
108
+
109
+ Args:
110
+ query: Search query (e.g., ticket ID or keywords).
111
+
112
+ Returns:
113
+ List of transcript data dictionaries.
114
+ """
115
+ client = self._get_client()
116
+
117
+ try:
118
+ response = await client.post(
119
+ FIREFLIES_API_URL,
120
+ json={
121
+ "query": SEARCH_TRANSCRIPTS_QUERY,
122
+ "variables": {"query": query, "limit": FIREFLIES_SEARCH_LIMIT},
123
+ },
124
+ )
125
+ response.raise_for_status()
126
+ data = response.json()
127
+
128
+ if "errors" in data:
129
+ logger.warning(
130
+ "Fireflies GraphQL errors",
131
+ extra={"errors": data["errors"], "query": query},
132
+ )
133
+ return []
134
+
135
+ transcripts = data.get("data", {}).get("transcripts") or []
136
+ logger.info(
137
+ "Fireflies search completed",
138
+ extra={"query": query, "count": len(transcripts)},
139
+ )
140
+ return transcripts
141
+
142
+ except httpx.HTTPStatusError as e:
143
+ logger.warning(
144
+ "Fireflies API error",
145
+ extra={"status_code": e.response.status_code, "query": query},
146
+ )
147
+ return []
148
+
149
+ except httpx.RequestError as e:
150
+ logger.warning(
151
+ "Fireflies network error",
152
+ extra={"error": str(e), "query": query},
153
+ )
154
+ return []
155
+
156
+ def _extract_relevant_excerpts(
157
+ self,
158
+ sentences: list[dict[str, Any]],
159
+ search_terms: list[str],
160
+ ) -> str:
161
+ """Extract relevant sentences from transcript with surrounding context.
162
+
163
+ Finds sentences mentioning search terms and includes ±N surrounding
164
+ sentences for context.
165
+
166
+ Args:
167
+ sentences: List of sentence dictionaries with text and speaker_name.
168
+ search_terms: Terms to search for in sentences.
169
+
170
+ Returns:
171
+ Formatted excerpt string with speaker names.
172
+ """
173
+ if not sentences or not search_terms:
174
+ return ""
175
+
176
+ # Find indices of sentences containing search terms
177
+ matching_indices: set[int] = set()
178
+ search_pattern = re.compile(
179
+ "|".join(re.escape(term) for term in search_terms),
180
+ re.IGNORECASE,
181
+ )
182
+
183
+ for i, sentence in enumerate(sentences):
184
+ text = sentence.get("text", "")
185
+ if search_pattern.search(text):
186
+ matching_indices.add(i)
187
+
188
+ if not matching_indices:
189
+ return ""
190
+
191
+ # Expand to include surrounding context
192
+ indices_to_include: set[int] = set()
193
+ for idx in matching_indices:
194
+ for offset in range(-FIREFLIES_CONTEXT_WINDOW, FIREFLIES_CONTEXT_WINDOW + 1):
195
+ new_idx = idx + offset
196
+ if 0 <= new_idx < len(sentences):
197
+ indices_to_include.add(new_idx)
198
+
199
+ # Build excerpt from consecutive ranges
200
+ sorted_indices = sorted(indices_to_include)
201
+ excerpt_parts: list[str] = []
202
+ current_range: list[int] = []
203
+
204
+ for idx in sorted_indices:
205
+ if not current_range or idx == current_range[-1] + 1:
206
+ current_range.append(idx)
207
+ else:
208
+ # Output current range and start new one
209
+ if current_range:
210
+ excerpt_parts.append(self._format_sentence_range(sentences, current_range))
211
+ current_range = [idx]
212
+
213
+ # Don't forget the last range
214
+ if current_range:
215
+ excerpt_parts.append(self._format_sentence_range(sentences, current_range))
216
+
217
+ return "\n\n[...]\n\n".join(excerpt_parts)
218
+
219
+ def _format_sentence_range(
220
+ self,
221
+ sentences: list[dict[str, Any]],
222
+ indices: list[int],
223
+ ) -> str:
224
+ """Format a range of sentences with speaker names.
225
+
226
+ Args:
227
+ sentences: All sentences from the transcript.
228
+ indices: Indices of sentences to include.
229
+
230
+ Returns:
231
+ Formatted string with speaker attributions.
232
+ """
233
+ parts: list[str] = []
234
+ current_speaker: str | None = None
235
+
236
+ for idx in indices:
237
+ sentence = sentences[idx]
238
+ speaker = sentence.get("speaker_name", "Unknown")
239
+ text = sentence.get("text", "").strip()
240
+
241
+ if not text:
242
+ continue
243
+
244
+ if speaker != current_speaker:
245
+ if parts:
246
+ parts.append("") # Add blank line between speakers
247
+ parts.append(f"**{speaker}:** {text}")
248
+ current_speaker = speaker
249
+ else:
250
+ parts.append(text)
251
+
252
+ return "\n".join(parts)
253
+
254
+ def _parse_transcript(
255
+ self,
256
+ transcript: dict[str, Any],
257
+ search_terms: list[str],
258
+ ) -> MeetingExcerpt | None:
259
+ """Parse a transcript into a MeetingExcerpt.
260
+
261
+ Args:
262
+ transcript: Raw transcript data from Fireflies API.
263
+ search_terms: Terms used for searching (for excerpt extraction).
264
+
265
+ Returns:
266
+ MeetingExcerpt if valid, None otherwise.
267
+ """
268
+ try:
269
+ # Parse date
270
+ date_str = transcript.get("date")
271
+ if date_str:
272
+ try:
273
+ meeting_date = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
274
+ if meeting_date.tzinfo is None:
275
+ meeting_date = meeting_date.replace(tzinfo=UTC)
276
+ except ValueError:
277
+ meeting_date = datetime.now(UTC)
278
+ else:
279
+ meeting_date = datetime.now(UTC)
280
+
281
+ # Get participants
282
+ participants = transcript.get("participants") or []
283
+ if isinstance(participants, str):
284
+ participants = [p.strip() for p in participants.split(",")]
285
+
286
+ # Get summary data
287
+ summary = transcript.get("summary") or {}
288
+ overview = summary.get("overview") or ""
289
+ action_items_raw = summary.get("action_items") or []
290
+ if isinstance(action_items_raw, str):
291
+ action_items = [
292
+ item.strip() for item in action_items_raw.split("\n") if item.strip()
293
+ ]
294
+ else:
295
+ action_items = list(action_items_raw) if action_items_raw else []
296
+
297
+ # Extract relevant excerpts from sentences
298
+ sentences = transcript.get("sentences") or []
299
+ excerpt = self._extract_relevant_excerpts(sentences, search_terms)
300
+
301
+ # If no relevant excerpt found but we have an overview, use that
302
+ if not excerpt and overview:
303
+ excerpt = f"**Summary:** {overview}"
304
+ elif not excerpt:
305
+ return None # No useful content
306
+
307
+ return MeetingExcerpt(
308
+ meeting_title=transcript.get("title") or "Untitled Meeting",
309
+ meeting_date=meeting_date,
310
+ participants=participants,
311
+ excerpt=excerpt,
312
+ action_items=action_items,
313
+ decisions=[], # Fireflies doesn't provide decisions directly
314
+ )
315
+
316
+ except Exception as e:
317
+ logger.warning(
318
+ "Failed to parse Fireflies transcript",
319
+ extra={"error": str(e), "transcript_id": transcript.get("id")},
320
+ )
321
+ return None
322
+
323
+ async def get_meeting_context(self, task_id: str) -> MeetingContext:
324
+ """Get meeting context for a task ID.
325
+
326
+ Searches for transcripts mentioning the task ID and extracts
327
+ relevant excerpts.
328
+
329
+ Args:
330
+ task_id: The task identifier to search for.
331
+
332
+ Returns:
333
+ MeetingContext with relevant meeting excerpts.
334
+ """
335
+ if not self._config.enabled:
336
+ logger.debug("Fireflies adapter is disabled")
337
+ return MeetingContext()
338
+
339
+ if not self._config.api_key:
340
+ logger.warning("Fireflies adapter missing API key")
341
+ return MeetingContext()
342
+
343
+ # Search by task ID
344
+ search_terms = [task_id]
345
+ transcripts = await self.search_transcripts(task_id)
346
+
347
+ # Parse transcripts into excerpts
348
+ excerpts: list[MeetingExcerpt] = []
349
+ for transcript in transcripts:
350
+ excerpt = self._parse_transcript(transcript, search_terms)
351
+ if excerpt:
352
+ excerpts.append(excerpt)
353
+
354
+ logger.info(
355
+ "Fireflies context assembled",
356
+ extra={"task_id": task_id, "meeting_count": len(excerpts)},
357
+ )
358
+
359
+ return MeetingContext(meetings=excerpts)
360
+
361
+ async def fetch_task_context(
362
+ self,
363
+ task_id: str,
364
+ ticket: JiraTicket | None = None,
365
+ ) -> SourceContext:
366
+ """Fetch context from Fireflies meeting transcripts.
367
+
368
+ Implements the Adapter interface. Searches for meetings mentioning
369
+ the task ID or keywords from the ticket.
370
+
371
+ Args:
372
+ task_id: The task identifier to search for in transcripts.
373
+ ticket: Optional Jira ticket for keyword extraction.
374
+
375
+ Returns:
376
+ SourceContext with MeetingContext data.
377
+ """
378
+ if not self._config.enabled:
379
+ logger.debug("Fireflies adapter is disabled")
380
+ return SourceContext(
381
+ source_name=self.name,
382
+ source_type=self.source_type,
383
+ data=None,
384
+ raw_text="",
385
+ )
386
+
387
+ meeting_context = await self.get_meeting_context(task_id)
388
+
389
+ if not meeting_context.meetings:
390
+ return SourceContext(
391
+ source_name=self.name,
392
+ source_type=self.source_type,
393
+ data=meeting_context,
394
+ raw_text="",
395
+ metadata={"task_id": task_id, "meeting_count": 0},
396
+ )
397
+
398
+ raw_text = self._format_meeting_context(meeting_context)
399
+
400
+ return SourceContext(
401
+ source_name=self.name,
402
+ source_type=self.source_type,
403
+ data=meeting_context,
404
+ raw_text=raw_text,
405
+ metadata={
406
+ "task_id": task_id,
407
+ "meeting_count": len(meeting_context.meetings),
408
+ },
409
+ )
410
+
411
+ def _format_meeting_context(self, meeting_context: MeetingContext) -> str:
412
+ """Format meeting context as raw text for synthesis."""
413
+ parts: list[str] = []
414
+
415
+ for meeting in meeting_context.meetings:
416
+ content_parts = [f"## {meeting.meeting_title}"]
417
+ content_parts.append(f"\n**Date:** {meeting.meeting_date.strftime('%Y-%m-%d')}")
418
+
419
+ if meeting.participants:
420
+ content_parts.append(f"**Participants:** {', '.join(meeting.participants)}")
421
+
422
+ content_parts.append(f"\n### Relevant Discussion\n\n{meeting.excerpt}")
423
+
424
+ if meeting.action_items:
425
+ content_parts.append("\n### Action Items")
426
+ for item in meeting.action_items:
427
+ content_parts.append(f"- {item}")
428
+
429
+ if meeting.decisions:
430
+ content_parts.append("\n### Decisions")
431
+ for decision in meeting.decisions:
432
+ content_parts.append(f"- {decision}")
433
+
434
+ parts.append("\n".join(content_parts))
435
+
436
+ return "\n\n---\n\n".join(parts)
437
+
438
+ async def search(
439
+ self,
440
+ query: str,
441
+ max_results: int = 10,
442
+ ) -> list[SearchResult]:
443
+ """Search Fireflies transcripts for items matching the query.
444
+
445
+ Implements the Adapter interface.
446
+
447
+ Args:
448
+ query: Search terms to find in transcripts.
449
+ max_results: Maximum number of results to return.
450
+
451
+ Returns:
452
+ List of SearchResult items.
453
+ """
454
+ if not self._config.enabled:
455
+ return []
456
+
457
+ transcripts = await self.search_transcripts(query)
458
+
459
+ results: list[SearchResult] = []
460
+ for transcript in transcripts[:max_results]:
461
+ title = transcript.get("title") or "Untitled Meeting"
462
+ date_str = transcript.get("date", "")
463
+
464
+ # Get overview from summary
465
+ summary = transcript.get("summary") or {}
466
+ excerpt = summary.get("overview") or ""
467
+ if not excerpt:
468
+ # Fall back to first few sentences
469
+ sentences = transcript.get("sentences") or []
470
+ if sentences:
471
+ excerpt = " ".join(s.get("text", "") for s in sentences[:3])
472
+
473
+ results.append(
474
+ SearchResult(
475
+ source_name=self.name,
476
+ source_type=self.source_type,
477
+ title=title,
478
+ excerpt=excerpt[:500] if excerpt else "No excerpt available",
479
+ metadata={
480
+ "date": date_str,
481
+ "participants": transcript.get("participants") or [],
482
+ },
483
+ )
484
+ )
485
+
486
+ return results
487
+
488
+ async def fetch_context(self, task_id: str) -> list[ContextData]:
489
+ """Fetch context from Fireflies (legacy Adapter interface).
490
+
491
+ This method is kept for backward compatibility.
492
+
493
+ Args:
494
+ task_id: The task identifier to search for in transcripts.
495
+
496
+ Returns:
497
+ List of ContextData items, one per relevant meeting.
498
+ """
499
+ source_context = await self.fetch_task_context(task_id)
500
+
501
+ if source_context.is_empty():
502
+ return []
503
+
504
+ meeting_context = source_context.data
505
+ if not isinstance(meeting_context, MeetingContext):
506
+ return []
507
+
508
+ # Convert each meeting excerpt to ContextData
509
+ results: list[ContextData] = []
510
+ for meeting in meeting_context.meetings:
511
+ content_parts = [f"## {meeting.meeting_title}"]
512
+ content_parts.append(f"\n**Date:** {meeting.meeting_date.strftime('%Y-%m-%d')}")
513
+
514
+ if meeting.participants:
515
+ content_parts.append(f"**Participants:** {', '.join(meeting.participants)}")
516
+
517
+ content_parts.append(f"\n### Relevant Discussion\n\n{meeting.excerpt}")
518
+
519
+ if meeting.action_items:
520
+ content_parts.append("\n### Action Items")
521
+ for item in meeting.action_items:
522
+ content_parts.append(f"- {item}")
523
+
524
+ if meeting.decisions:
525
+ content_parts.append("\n### Decisions")
526
+ for decision in meeting.decisions:
527
+ content_parts.append(f"- {decision}")
528
+
529
+ content = "\n".join(content_parts)
530
+
531
+ results.append(
532
+ ContextData(
533
+ source=f"fireflies:{meeting.meeting_date.strftime('%Y-%m-%d')}",
534
+ source_type=self.source_type,
535
+ title=meeting.meeting_title,
536
+ content=content,
537
+ metadata={
538
+ "date": meeting.meeting_date.isoformat(),
539
+ "participants": meeting.participants,
540
+ "action_item_count": len(meeting.action_items),
541
+ },
542
+ relevance_score=0.8,
543
+ )
544
+ )
545
+
546
+ return results
547
+
548
+ async def health_check(self) -> bool:
549
+ """Check if Fireflies is configured and accessible.
550
+
551
+ Returns:
552
+ True if healthy or disabled, False if there's an issue.
553
+ """
554
+ if not self._config.enabled:
555
+ return True
556
+
557
+ if not self._config.api_key:
558
+ logger.warning("Fireflies adapter missing API key")
559
+ return False
560
+
561
+ try:
562
+ # Simple query to verify API access
563
+ client = self._get_client()
564
+ response = await client.post(
565
+ FIREFLIES_API_URL,
566
+ json={
567
+ "query": "query { user { email } }",
568
+ },
569
+ )
570
+
571
+ if response.status_code == 200:
572
+ data = response.json()
573
+ if "errors" not in data:
574
+ logger.info("Fireflies health check passed")
575
+ return True
576
+
577
+ logger.warning(
578
+ "Fireflies health check failed",
579
+ extra={"status_code": response.status_code},
580
+ )
581
+ return False
582
+
583
+ except Exception as e:
584
+ logger.warning("Fireflies health check error", extra={"error": str(e)})
585
+ return False