teams-transcripts 0.5.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.
Files changed (42) hide show
  1. legacy/__init__.py +0 -0
  2. legacy/transcript_download.py +3969 -0
  3. teams_transcripts/__init__.py +1 -0
  4. teams_transcripts/__main__.py +288 -0
  5. teams_transcripts/adapters/__init__.py +1 -0
  6. teams_transcripts/adapters/cdp/__init__.py +1 -0
  7. teams_transcripts/adapters/cdp/browser.py +196 -0
  8. teams_transcripts/adapters/cdp/connection.py +168 -0
  9. teams_transcripts/adapters/cdp/helpers.py +449 -0
  10. teams_transcripts/adapters/cdp/teams.py +463 -0
  11. teams_transcripts/adapters/vertex.py +274 -0
  12. teams_transcripts/domain/__init__.py +1 -0
  13. teams_transcripts/domain/chat.py +115 -0
  14. teams_transcripts/domain/html.py +126 -0
  15. teams_transcripts/domain/mcps.py +343 -0
  16. teams_transcripts/domain/models.py +531 -0
  17. teams_transcripts/domain/participants.py +292 -0
  18. teams_transcripts/domain/scoring.py +106 -0
  19. teams_transcripts/domain/speakers.py +130 -0
  20. teams_transcripts/domain/summary.py +266 -0
  21. teams_transcripts/domain/timestamps.py +350 -0
  22. teams_transcripts/domain/vtt.py +444 -0
  23. teams_transcripts/infrastructure/__init__.py +1 -0
  24. teams_transcripts/infrastructure/config.py +987 -0
  25. teams_transcripts/infrastructure/errors.py +120 -0
  26. teams_transcripts/infrastructure/logging.py +69 -0
  27. teams_transcripts/infrastructure/signals.py +143 -0
  28. teams_transcripts/mcp_server.py +818 -0
  29. teams_transcripts/ports/__init__.py +1 -0
  30. teams_transcripts/ports/agent.py +58 -0
  31. teams_transcripts/ports/browser.py +126 -0
  32. teams_transcripts/services/__init__.py +1 -0
  33. teams_transcripts/services/downloader.py +1283 -0
  34. teams_transcripts/services/extraction.py +1603 -0
  35. teams_transcripts/services/listing.py +826 -0
  36. teams_transcripts/services/navigation.py +1721 -0
  37. teams_transcripts/services/output.py +84 -0
  38. teams_transcripts/services/tenant.py +684 -0
  39. teams_transcripts-0.5.0.dist-info/METADATA +1438 -0
  40. teams_transcripts-0.5.0.dist-info/RECORD +42 -0
  41. teams_transcripts-0.5.0.dist-info/WHEEL +4 -0
  42. teams_transcripts-0.5.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,444 @@
1
+ """Pure VTT generation, merging, and post-processing functions.
2
+
3
+ All functions in this module are pure: no I/O, no network, no logging,
4
+ no global state. They take string content in and return string content out.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import typing
11
+ from html import escape as html_escape
12
+ from html import unescape as html_unescape
13
+
14
+ from teams_transcripts.domain.models import TranscriptCue
15
+ from teams_transcripts.domain.timestamps import (
16
+ format_vtt_timestamp,
17
+ parse_meeting_times,
18
+ parse_vtt_timestamp,
19
+ )
20
+
21
+ if typing.TYPE_CHECKING:
22
+ from teams_transcripts.domain.models import MeetingParticipants, RosterMember
23
+
24
+
25
+ def merge_parts(vtt_contents: list[str]) -> str:
26
+ """Merge multiple VTT file contents into a single VTT.
27
+
28
+ Each part's timestamps are offset so that Part 2 continues from
29
+ Part 1's last cue end time, and so on. Cues are renumbered
30
+ sequentially across all parts.
31
+
32
+ Args:
33
+ vtt_contents: List of VTT file contents (strings), one per part.
34
+
35
+ Returns:
36
+ Merged VTT content as a single string.
37
+ """
38
+ timestamp_re = re.compile(r"(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})")
39
+
40
+ all_cues: list[tuple[float, float, list[str]]] = []
41
+ cumulative_offset = 0.0
42
+
43
+ for vtt_text in vtt_contents:
44
+ lines = vtt_text.split("\n")
45
+ part_cues: list[tuple[float, float, list[str]]] = []
46
+
47
+ i = 0
48
+ while i < len(lines):
49
+ line = lines[i].strip()
50
+ ts_match = timestamp_re.match(line)
51
+ if ts_match:
52
+ start = parse_vtt_timestamp(ts_match.group(1))
53
+ end = parse_vtt_timestamp(ts_match.group(2))
54
+ # Collect cue content lines (everything until next blank line)
55
+ content_lines = []
56
+ i += 1
57
+ while i < len(lines) and lines[i].strip():
58
+ content_lines.append(lines[i])
59
+ i += 1
60
+ part_cues.append(
61
+ (start + cumulative_offset, end + cumulative_offset, content_lines)
62
+ )
63
+ else:
64
+ i += 1
65
+
66
+ # Update offset: next part starts after this part's last cue end
67
+ if part_cues:
68
+ last_end = max(end for _, end, _ in part_cues)
69
+ cumulative_offset = last_end
70
+
71
+ all_cues.extend(part_cues)
72
+
73
+ # Build merged VTT
74
+ result = ["WEBVTT", ""]
75
+ for idx, (start, end, content_lines) in enumerate(all_cues, 1):
76
+ result.append(str(idx))
77
+ result.append(f"{format_vtt_timestamp(start)} --> {format_vtt_timestamp(end)}")
78
+ result.extend(content_lines)
79
+ result.append("")
80
+
81
+ return "\n".join(result)
82
+
83
+
84
+ def prepend_metadata(
85
+ content: str,
86
+ config: dict,
87
+ *,
88
+ participants: MeetingParticipants | None = None,
89
+ ) -> str:
90
+ """Insert a VTT NOTE block with meeting metadata after the WEBVTT header.
91
+
92
+ The NOTE block contains the meeting title, participant information,
93
+ and start/end times.
94
+
95
+ When ``participants`` is provided, the block uses categorised lines
96
+ (Organiser, Invited, Speakers, Attendees). Otherwise it falls back
97
+ to the legacy roster/speaker-map format.
98
+
99
+ Args:
100
+ content: Raw VTT file content.
101
+ config: Meeting configuration dict with keys ``"meeting"``,
102
+ ``"date"``, ``"time"``, and optionally ``"roster_members"``,
103
+ ``"speaker_map"``, ``"meeting_time_text"``.
104
+ participants: Optional :class:`MeetingParticipants` with
105
+ categorised participant lists.
106
+
107
+ Returns:
108
+ VTT content with the metadata NOTE block inserted.
109
+ """
110
+ # Build the NOTE block
111
+ lines = ["NOTE Meeting Metadata"]
112
+ lines.append(f"Meeting: {config['meeting']}")
113
+
114
+ if participants is not None:
115
+ _append_participant_lines(lines, participants)
116
+ else:
117
+ _append_legacy_roster_lines(lines, config)
118
+
119
+ # Start/end times
120
+ time_text = config.get("meeting_time_text", "")
121
+ if time_text:
122
+ start_dt, end_dt = parse_meeting_times(time_text, config["date"])
123
+ else:
124
+ start_dt = config["date"]
125
+ if config.get("time"):
126
+ start_dt = f"{config['date']}T{config['time']}"
127
+ end_dt = ""
128
+
129
+ lines.append(f"Start: {start_dt}")
130
+ if end_dt:
131
+ lines.append(f"End: {end_dt}")
132
+
133
+ note_block = "\n".join(lines)
134
+
135
+ # Insert after the WEBVTT header line
136
+ if content.startswith("WEBVTT"):
137
+ content = content.replace("WEBVTT\n\n", f"WEBVTT\n\n{note_block}\n\n", 1)
138
+ else:
139
+ content = f"WEBVTT\n\n{note_block}\n\n{content}"
140
+
141
+ return content
142
+
143
+
144
+ _INLINE_NAME_THRESHOLD = 10
145
+ """Maximum number of members/attendees to list by name inline.
146
+
147
+ Above this threshold, a count is shown instead (e.g. "127 members").
148
+ """
149
+
150
+
151
+ def _append_participant_lines(lines: list[str], participants: MeetingParticipants) -> None:
152
+ """Append categorised participant lines to the NOTE block."""
153
+ # Organiser
154
+ if participants.organiser:
155
+ org = participants.organiser[0]
156
+ if org.email:
157
+ lines.append(f"Organiser: {org.name} <{org.email}>")
158
+ else:
159
+ lines.append(f"Organiser: {org.name}")
160
+
161
+ # Invited
162
+ if participants.invited:
163
+ count = len(participants.invited)
164
+ if count <= _INLINE_NAME_THRESHOLD:
165
+ names = ", ".join(m.name for m in participants.invited)
166
+ lines.append(f"Invited: {names}")
167
+ else:
168
+ rsvp_summary = _rsvp_summary(participants.invited)
169
+ if rsvp_summary:
170
+ lines.append(f"Invited: {count} members ({rsvp_summary})")
171
+ else:
172
+ lines.append(f"Invited: {count} members")
173
+
174
+ # Speakers
175
+ if participants.speakers:
176
+ names = ", ".join(m.name for m in participants.speakers)
177
+ lines.append(f"Speakers: {names}")
178
+
179
+ # Attendees
180
+ if participants.attendees:
181
+ count = len(participants.attendees)
182
+ if count <= _INLINE_NAME_THRESHOLD:
183
+ names = ", ".join(m.name for m in participants.attendees)
184
+ lines.append(f"Attendees: {names}")
185
+ else:
186
+ lines.append(f"Attendees: {count} participants")
187
+
188
+
189
+ def _rsvp_summary(members: list[RosterMember]) -> str:
190
+ """Build a compact RSVP summary string for the NOTE block.
191
+
192
+ Returns a string like ``"50 accepted, 5 tentative, 2 declined, 33 no response"``
193
+ or empty string if no RSVP data is available.
194
+
195
+ Args:
196
+ members: List of roster members (may have empty ``rsvp_status``).
197
+
198
+ Returns:
199
+ Compact summary string, or empty string when no RSVP data present.
200
+ """
201
+ from collections import Counter
202
+
203
+ counts: Counter[str] = Counter()
204
+ for m in members:
205
+ if m.rsvp_status:
206
+ counts[m.rsvp_status] += 1
207
+
208
+ if not counts:
209
+ return ""
210
+
211
+ # Display order: accepted, tentative, declined, organizer, none
212
+ parts: list[str] = []
213
+ display_order = ["accepted", "tentative", "declined", "organizer", "none"]
214
+ for status in display_order:
215
+ if status in counts:
216
+ label = "no response" if status == "none" else status
217
+ parts.append(f"{counts[status]} {label}")
218
+
219
+ # Any remaining statuses not in the display order
220
+ for status, count in sorted(counts.items()):
221
+ if status not in display_order:
222
+ parts.append(f"{count} {status}")
223
+
224
+ return ", ".join(parts)
225
+
226
+
227
+ def _append_legacy_roster_lines(lines: list[str], config: dict) -> None:
228
+ """Append legacy roster/speaker lines to the NOTE block.
229
+
230
+ Used when no :class:`MeetingParticipants` is available (backward
231
+ compatibility).
232
+ """
233
+ roster = config.get("roster_members")
234
+ if roster:
235
+ organiser_names = sorted(m["name"] for m in roster if m["role"] == "organiser")
236
+ member_names = sorted(m["name"] for m in roster)
237
+ lines.append(f"Members: {', '.join(member_names)}")
238
+ if organiser_names:
239
+ lines.append(f"Organiser: {', '.join(organiser_names)}")
240
+
241
+ # Email addresses for all members that have one
242
+ email_entries = sorted((m["name"], m["email"]) for m in roster if m.get("email"))
243
+ if email_entries:
244
+ email_strs = [f"{name} <{email}>" for name, email in email_entries]
245
+ lines.append(f"Emails: {', '.join(email_strs)}")
246
+ else:
247
+ # Fallback: speakers only (from Recap)
248
+ speaker_map = config.get("speaker_map")
249
+ if speaker_map:
250
+ names = sorted(speaker_map.values())
251
+ lines.append(f"Speakers: {', '.join(names)}")
252
+
253
+
254
+ def ensure_trailing_newline(content: str) -> str:
255
+ """Ensure the VTT content ends with a trailing newline.
256
+
257
+ Args:
258
+ content: VTT file content.
259
+
260
+ Returns:
261
+ Content with a trailing newline appended if missing.
262
+ """
263
+ if content and not content.endswith("\n"):
264
+ return content + "\n"
265
+ return content
266
+
267
+
268
+ def generate_vtt_from_segments(segments: list[dict]) -> str:
269
+ """Generate VTT content from Approach B DOM-extracted segments.
270
+
271
+ Each segment dict must have ``"speaker"``, ``"ts_sec"`` (int seconds),
272
+ and ``"text"`` keys. Timestamps must already be filled (no ``None``
273
+ values).
274
+
275
+ Cue end times are set to the next segment's start time. The last
276
+ cue gets a 10-second duration. If a cue's end time is not greater
277
+ than its start, 10 seconds is added.
278
+
279
+ Args:
280
+ segments: Ordered list of segment dicts.
281
+
282
+ Returns:
283
+ Complete VTT content string.
284
+ """
285
+ from teams_transcripts.domain.timestamps import sec_to_vtt_int
286
+
287
+ vtt = ["WEBVTT", ""]
288
+ for i, seg in enumerate(segments):
289
+ ss = seg["ts_sec"]
290
+ es = segments[i + 1]["ts_sec"] if i + 1 < len(segments) else ss + 10
291
+ if es <= ss:
292
+ es = ss + 10
293
+ vtt.extend(
294
+ [
295
+ str(i + 1),
296
+ f"{sec_to_vtt_int(ss)} --> {sec_to_vtt_int(es)}",
297
+ f"<v {_escape_vtt_text(seg['speaker'])}>{_escape_vtt_text(seg['text'])}</v>",
298
+ "",
299
+ ]
300
+ )
301
+
302
+ return "\n".join(vtt)
303
+
304
+
305
+ def _escape_vtt_text(value: object) -> str:
306
+ """Escape user-controlled text for a VTT cue or voice tag."""
307
+ return html_escape(str(value), quote=False)
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # VTT parsing: content string -> structured cues
312
+ # ---------------------------------------------------------------------------
313
+
314
+ _VOICE_TAG_RE = re.compile(r"^<v ([^>]+)>(.*?)(?:</v>)?$", re.DOTALL)
315
+ """Match a VTT voice tag: ``<v Speaker Name>text</v>`` or ``<v Speaker Name>text``."""
316
+
317
+
318
+ def parse_cues(content: str) -> list[TranscriptCue]:
319
+ """Parse VTT content into a list of structured cue objects.
320
+
321
+ Extracts each cue's ID, timestamps, speaker (from ``<v>`` voice tags),
322
+ and plain text content. NOTE blocks and the WEBVTT header are skipped.
323
+
324
+ Cues without a ``<v>`` voice tag have ``speaker=None``.
325
+
326
+ Args:
327
+ content: Complete VTT file content (must start with ``WEBVTT``).
328
+
329
+ Returns:
330
+ Ordered list of :class:`TranscriptCue` objects.
331
+ """
332
+ timestamp_re = re.compile(r"(\d{2}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}\.\d{3})")
333
+
334
+ lines = content.split("\n")
335
+ cues: list[TranscriptCue] = []
336
+ i = 0
337
+
338
+ while i < len(lines):
339
+ line = lines[i].strip()
340
+
341
+ # Skip NOTE blocks (single or multi-line)
342
+ if line.startswith("NOTE"):
343
+ i += 1
344
+ while i < len(lines) and lines[i].strip():
345
+ i += 1
346
+ i += 1 # skip the blank line after NOTE
347
+ continue
348
+
349
+ # Skip WEBVTT header
350
+ if line == "WEBVTT":
351
+ i += 1
352
+ continue
353
+
354
+ # Skip blank lines
355
+ if not line:
356
+ i += 1
357
+ continue
358
+
359
+ # Try to match a cue ID (numeric line before timestamp)
360
+ cue_id: int | None = None
361
+ if line.isdigit():
362
+ cue_id = int(line)
363
+ i += 1
364
+ if i >= len(lines):
365
+ break
366
+ line = lines[i].strip()
367
+
368
+ # Try to match a timestamp line
369
+ ts_match = timestamp_re.match(line)
370
+ if not ts_match:
371
+ i += 1
372
+ continue
373
+
374
+ start_str = ts_match.group(1)
375
+ end_str = ts_match.group(2)
376
+ start_sec = parse_vtt_timestamp(start_str)
377
+ end_sec = parse_vtt_timestamp(end_str)
378
+
379
+ # Collect content lines until blank line or end
380
+ i += 1
381
+ content_lines: list[str] = []
382
+ while i < len(lines) and lines[i].strip():
383
+ content_lines.append(lines[i].rstrip())
384
+ i += 1
385
+
386
+ # Parse speaker and text from content
387
+ raw_text = "\n".join(content_lines)
388
+ speaker, text = _extract_speaker_and_text(raw_text)
389
+
390
+ cues.append(
391
+ TranscriptCue(
392
+ id=cue_id if cue_id is not None else len(cues) + 1,
393
+ start=start_str,
394
+ end=end_str,
395
+ start_sec=start_sec,
396
+ end_sec=end_sec,
397
+ speaker=speaker,
398
+ text=text,
399
+ )
400
+ )
401
+
402
+ i += 1 # skip blank line after cue
403
+
404
+ return cues
405
+
406
+
407
+ def _extract_speaker_and_text(raw: str) -> tuple[str | None, str]:
408
+ """Extract speaker name and plain text from cue content.
409
+
410
+ Handles:
411
+ - ``<v Speaker Name>text</v>`` — returns (Speaker Name, text)
412
+ - ``<v Speaker Name>text`` (no closing tag) — returns (Speaker Name, text)
413
+ - Plain text without voice tag — returns (None, text)
414
+ - Multi-line content where the first line has the voice tag
415
+
416
+ Args:
417
+ raw: Raw cue content (may be multi-line).
418
+
419
+ Returns:
420
+ Tuple of (speaker_or_None, plain_text).
421
+ """
422
+ # Try matching the first line for a voice tag
423
+ first_line_end = raw.find("\n")
424
+ if first_line_end == -1:
425
+ first_line = raw
426
+ rest = ""
427
+ else:
428
+ first_line = raw[:first_line_end]
429
+ rest = raw[first_line_end + 1 :]
430
+
431
+ m = _VOICE_TAG_RE.match(first_line)
432
+ if m:
433
+ speaker = html_unescape(m.group(1))
434
+ text = html_unescape(m.group(2))
435
+ if rest:
436
+ text = text + "\n" + html_unescape(rest)
437
+ # Strip any remaining </v> tags from text
438
+ text = text.replace("</v>", "")
439
+ return speaker, text.strip()
440
+
441
+ # No voice tag — entire content is plain text
442
+ # Still strip any stray </v> tags
443
+ text = html_unescape(raw.replace("</v>", "").strip())
444
+ return None, text
@@ -0,0 +1 @@
1
+ """Infrastructure -- cross-cutting concerns: logging, signals, errors, config."""