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,274 @@
1
+ """Vertex AI agent adapter -- AnthropicVertex client and query functions.
2
+
3
+ Extracted from ``transcript_download.py`` lines 656-664
4
+ (``create_agent_client``), 667-698 (``agent_vision_query``), and
5
+ 701-716 (``agent_text_query``).
6
+
7
+ Provides both standalone functions (used by helpers like
8
+ ``find_iframe_target``) and a ``VertexAgentAdapter`` class that
9
+ implements the ``AgentPort`` protocol for service-layer injection.
10
+
11
+ Schema version: 1.0.0
12
+ Created: 2026-04-25
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import typing
21
+
22
+ if typing.TYPE_CHECKING:
23
+ from teams_transcripts.domain.models import MeetingConfig
24
+ from teams_transcripts.infrastructure.logging import Logger
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # JSON extraction helper
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ def extract_json_from_response(text: str) -> dict | None:
33
+ """Extract first JSON object from response text.
34
+
35
+ Handles plain JSON, markdown-fenced JSON, or JSON embedded in prose.
36
+
37
+ Args:
38
+ text: Raw response text from the LLM.
39
+
40
+ Returns:
41
+ Parsed JSON dict, or ``None`` if no valid JSON found.
42
+ """
43
+ if not text:
44
+ return None
45
+ json_match = re.search(r"\{[^}]+\}", text)
46
+ if json_match:
47
+ try:
48
+ return json.loads(json_match.group())
49
+ except json.JSONDecodeError:
50
+ return None
51
+ return None
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Standalone query functions (used by helpers.find_iframe_target etc.)
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ def agent_text_query(
60
+ client: typing.Any,
61
+ model: str,
62
+ prompt: str,
63
+ *,
64
+ logger: Logger | None = None,
65
+ ) -> dict | None:
66
+ """Send a single-turn text query to the agent.
67
+
68
+ Args:
69
+ client: AnthropicVertex client instance.
70
+ model: Model identifier.
71
+ prompt: Text prompt.
72
+ logger: Optional logger for error reporting.
73
+
74
+ Returns:
75
+ Parsed JSON dict, or ``None`` on failure.
76
+ """
77
+ try:
78
+ response = client.messages.create(
79
+ model=model,
80
+ max_tokens=512,
81
+ messages=[{"role": "user", "content": prompt}],
82
+ )
83
+ text = response.content[0].text.strip()
84
+ return extract_json_from_response(text)
85
+ except Exception as exc:
86
+ if logger is not None:
87
+ logger.log("agent", f"Text query failed: {exc}")
88
+ return None
89
+
90
+
91
+ def agent_vision_query(
92
+ client: typing.Any,
93
+ model: str,
94
+ image_b64: str,
95
+ prompt: str,
96
+ *,
97
+ logger: Logger | None = None,
98
+ ) -> dict | None:
99
+ """Send a single-turn vision query to the agent.
100
+
101
+ Args:
102
+ client: AnthropicVertex client instance.
103
+ model: Model identifier.
104
+ image_b64: Base64-encoded PNG image data.
105
+ prompt: Text prompt accompanying the image.
106
+ logger: Optional logger for error reporting.
107
+
108
+ Returns:
109
+ Parsed JSON dict, or ``None`` on failure.
110
+ """
111
+ try:
112
+ response = client.messages.create(
113
+ model=model,
114
+ max_tokens=512,
115
+ messages=[
116
+ {
117
+ "role": "user",
118
+ "content": [
119
+ {
120
+ "type": "image",
121
+ "source": {
122
+ "type": "base64",
123
+ "media_type": "image/png",
124
+ "data": image_b64,
125
+ },
126
+ },
127
+ {"type": "text", "text": prompt},
128
+ ],
129
+ }
130
+ ],
131
+ )
132
+ text = response.content[0].text.strip()
133
+ return extract_json_from_response(text)
134
+ except Exception as exc:
135
+ if logger is not None:
136
+ logger.log("agent", f"Vision query failed: {exc}")
137
+ return None
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Client factory
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def create_agent_client(config: MeetingConfig) -> typing.Any:
146
+ """Create an AnthropicVertex client from resolved config.
147
+
148
+ Sets ``GOOGLE_APPLICATION_CREDENTIALS`` in the environment and
149
+ returns a configured client instance.
150
+
151
+ Args:
152
+ config: Meeting configuration with ``adc``, ``gcp_project``,
153
+ and ``gcp_region`` fields.
154
+
155
+ Returns:
156
+ An ``AnthropicVertex`` client instance.
157
+ """
158
+ os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = config.adc
159
+ from anthropic.lib.vertex import AnthropicVertex
160
+
161
+ return AnthropicVertex(
162
+ project_id=config.gcp_project,
163
+ region=config.gcp_region,
164
+ )
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Lazy import removed -- AnthropicVertex imported at call site above
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # VertexAgentAdapter (implements AgentPort)
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ class VertexAgentAdapter:
178
+ """AgentPort implementation using Anthropic SDK via Vertex AI.
179
+
180
+ Wraps an AnthropicVertex client and model identifier, delegating
181
+ to the standalone query functions.
182
+
183
+ Args:
184
+ client: AnthropicVertex client instance.
185
+ model: Model identifier (e.g. ``"claude-haiku-4-5"``).
186
+ logger: Logger instance for error reporting.
187
+ """
188
+
189
+ def __init__(
190
+ self,
191
+ *,
192
+ client: typing.Any,
193
+ model: str,
194
+ logger: Logger,
195
+ ) -> None:
196
+ self._client = client
197
+ self._model = model
198
+ self._logger = logger
199
+
200
+ @property
201
+ def client(self) -> typing.Any:
202
+ """The underlying AnthropicVertex client instance."""
203
+ return self._client
204
+
205
+ def text_query(
206
+ self,
207
+ prompt: str,
208
+ *,
209
+ max_tokens: int = 512,
210
+ ) -> dict | None:
211
+ """Send a text-only prompt to the agent.
212
+
213
+ Args:
214
+ prompt: Text prompt.
215
+ max_tokens: Maximum tokens in the response.
216
+
217
+ Returns:
218
+ Parsed JSON dict, or ``None`` on failure.
219
+ """
220
+ try:
221
+ response = self._client.messages.create(
222
+ model=self._model,
223
+ max_tokens=max_tokens,
224
+ messages=[{"role": "user", "content": prompt}],
225
+ )
226
+ text = response.content[0].text.strip()
227
+ return extract_json_from_response(text)
228
+ except Exception as exc:
229
+ self._logger.log("agent", f"Text query failed: {exc}")
230
+ return None
231
+
232
+ def vision_query(
233
+ self,
234
+ image_b64: str,
235
+ prompt: str,
236
+ *,
237
+ max_tokens: int = 512,
238
+ ) -> dict | None:
239
+ """Send an image + text prompt to the agent.
240
+
241
+ Args:
242
+ image_b64: Base64-encoded PNG image data.
243
+ prompt: Text prompt.
244
+ max_tokens: Maximum tokens in the response.
245
+
246
+ Returns:
247
+ Parsed JSON dict, or ``None`` on failure.
248
+ """
249
+ try:
250
+ response = self._client.messages.create(
251
+ model=self._model,
252
+ max_tokens=max_tokens,
253
+ messages=[
254
+ {
255
+ "role": "user",
256
+ "content": [
257
+ {
258
+ "type": "image",
259
+ "source": {
260
+ "type": "base64",
261
+ "media_type": "image/png",
262
+ "data": image_b64,
263
+ },
264
+ },
265
+ {"type": "text", "text": prompt},
266
+ ],
267
+ }
268
+ ],
269
+ )
270
+ text = response.content[0].text.strip()
271
+ return extract_json_from_response(text)
272
+ except Exception as exc:
273
+ self._logger.log("agent", f"Vision query failed: {exc}")
274
+ return None
@@ -0,0 +1 @@
1
+ """Domain layer -- pure business logic with no external dependencies."""
@@ -0,0 +1,115 @@
1
+ """Pure chat message formatting functions.
2
+
3
+ All functions in this module are pure: no I/O, no network, no logging,
4
+ no global state.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from datetime import datetime
11
+
12
+
13
+ def build_chat_note_block(messages: list[dict], meeting_start: str) -> str:
14
+ """Build a VTT NOTE Chat block from chat messages.
15
+
16
+ Converts absolute timestamps to meeting-start-relative offsets in
17
+ ``HH:MM:SS.mmm`` format. The output is a ``NOTE Chat`` block
18
+ containing a JSON array.
19
+
20
+ Args:
21
+ messages: Chat messages with absolute ``"timestamp"`` values.
22
+ meeting_start: Meeting start time as ISO 8601 (e.g.
23
+ ``"2026-04-22T11:15+01:00"``).
24
+
25
+ Returns:
26
+ The ``NOTE Chat`` block string (without surrounding blank lines).
27
+ Returns empty string when *messages* is empty.
28
+ """
29
+ if not messages:
30
+ return ""
31
+
32
+ try:
33
+ start_dt = datetime.fromisoformat(meeting_start)
34
+ except (ValueError, TypeError):
35
+ start_dt = None
36
+
37
+ offset_messages = []
38
+ for msg in messages:
39
+ entry = dict(msg)
40
+ if start_dt is not None:
41
+ try:
42
+ msg_dt = datetime.fromisoformat(entry["timestamp"])
43
+ delta = msg_dt - start_dt
44
+ total_ms = int(delta.total_seconds() * 1000)
45
+ if total_ms < 0:
46
+ total_ms = 0
47
+ hours = total_ms // 3_600_000
48
+ minutes = (total_ms % 3_600_000) // 60_000
49
+ seconds = (total_ms % 60_000) // 1000
50
+ millis = total_ms % 1000
51
+ entry["offset"] = f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
52
+ except (ValueError, TypeError):
53
+ entry["offset"] = entry["timestamp"]
54
+ else:
55
+ entry["offset"] = entry["timestamp"]
56
+ del entry["timestamp"]
57
+ offset_messages.append(entry)
58
+
59
+ chat_json = json.dumps(offset_messages, indent=2, ensure_ascii=False)
60
+ return f"NOTE Chat\n{chat_json}"
61
+
62
+
63
+ def compute_chat_offset(message_timestamp: str, meeting_start: str) -> str:
64
+ """Compute a single message's offset from meeting start.
65
+
66
+ Args:
67
+ message_timestamp: Message time as ISO 8601.
68
+ meeting_start: Meeting start time as ISO 8601.
69
+
70
+ Returns:
71
+ Offset as ``HH:MM:SS.mmm``, or the original timestamp on failure.
72
+ """
73
+ try:
74
+ start_dt = datetime.fromisoformat(meeting_start)
75
+ msg_dt = datetime.fromisoformat(message_timestamp)
76
+ delta = msg_dt - start_dt
77
+ total_ms = int(delta.total_seconds() * 1000)
78
+ if total_ms < 0:
79
+ total_ms = 0
80
+ hours = total_ms // 3_600_000
81
+ minutes = (total_ms % 3_600_000) // 60_000
82
+ seconds = (total_ms % 60_000) // 1000
83
+ millis = total_ms % 1000
84
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
85
+ except (ValueError, TypeError):
86
+ return message_timestamp
87
+
88
+
89
+ def insert_chat_block(content: str, chat_block: str) -> str:
90
+ """Insert a NOTE Chat block into VTT content at the correct position.
91
+
92
+ The chat block is placed after the existing NOTE Meeting Metadata
93
+ block (if present), or after the WEBVTT header.
94
+
95
+ Args:
96
+ content: Raw VTT file content.
97
+ chat_block: The ``NOTE Chat`` block string.
98
+
99
+ Returns:
100
+ VTT content with the chat block inserted.
101
+ """
102
+ if not chat_block:
103
+ return content
104
+
105
+ # Insert after the existing NOTE block (metadata) if present.
106
+ note_end = content.find("\n\nNOTE")
107
+ if note_end != -1:
108
+ after_note = content.find("\n\n", note_end + 2)
109
+ if after_note != -1:
110
+ insert_pos = after_note + 2
111
+ return content[:insert_pos] + chat_block + "\n\n" + content[insert_pos:]
112
+ return content + "\n\n" + chat_block + "\n"
113
+ if content.startswith("WEBVTT"):
114
+ return content.replace("WEBVTT\n\n", f"WEBVTT\n\n{chat_block}\n\n", 1)
115
+ return f"WEBVTT\n\n{chat_block}\n\n{content}"
@@ -0,0 +1,126 @@
1
+ """Pure HTML-to-text conversion for Teams chat messages.
2
+
3
+ All functions in this module are pure: no I/O, no network, no logging,
4
+ no global state.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import html as html_mod
10
+ import re
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Reply quote removal
14
+ # ---------------------------------------------------------------------------
15
+
16
+ _REPLY_OPEN_RE = re.compile(
17
+ r'<(\w+)\b[^>]*?itemtype\s*=\s*"http://schema\.skype\.com/Reply"[^>]*>',
18
+ re.IGNORECASE,
19
+ )
20
+
21
+
22
+ def _remove_reply_quotes(html: str) -> str:
23
+ """Remove Teams inline reply/quote blocks from *html*.
24
+
25
+ Teams wraps quoted content inside a container element (typically
26
+ ``<div>`` or ``<blockquote>``) carrying
27
+ ``itemtype="http://schema.skype.com/Reply"``. This function strips the
28
+ entire container and its contents so that only the user's own reply
29
+ text remains.
30
+
31
+ The removal is nesting-aware: if the container tag name (e.g. ``div``)
32
+ appears nested inside the quote, inner open/close pairs are tracked and
33
+ the correct outer closing tag is matched.
34
+ """
35
+ while True:
36
+ match = _REPLY_OPEN_RE.search(html)
37
+ if not match:
38
+ break
39
+
40
+ tag_name = match.group(1)
41
+ start = match.start()
42
+ pos = match.end()
43
+
44
+ open_re = re.compile(rf"<{tag_name}\b", re.IGNORECASE)
45
+ close_re = re.compile(rf"</{tag_name}\s*>", re.IGNORECASE)
46
+
47
+ depth = 1
48
+ end = len(html) # fallback: remove to end of string
49
+ while depth > 0 and pos < len(html):
50
+ next_open = open_re.search(html, pos)
51
+ next_close = close_re.search(html, pos)
52
+
53
+ if next_close is None:
54
+ # No matching close tag — remove rest of string.
55
+ break
56
+
57
+ if next_open and next_open.start() < next_close.start():
58
+ depth += 1
59
+ pos = next_open.end()
60
+ else:
61
+ depth -= 1
62
+ if depth == 0:
63
+ end = next_close.end()
64
+ pos = next_close.end()
65
+
66
+ html = html[:start] + html[end:]
67
+
68
+ return html
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Public API
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def strip_html_to_text(html: str) -> str:
77
+ """Convert HTML message content to plain text.
78
+
79
+ Handles common Teams message markup: ``<p>``, ``<br>``, ``<div>``,
80
+ ``<a href="...">``, ``<span>``, and HTML entities. Reply quote blocks
81
+ (``itemtype="http://schema.skype.com/Reply"``) are removed entirely so
82
+ that only the author's own text is returned.
83
+
84
+ Returns empty string for blank or whitespace-only content.
85
+
86
+ Args:
87
+ html: Raw HTML string from a Teams chat message.
88
+
89
+ Returns:
90
+ Plain text with reply quotes removed, block elements converted to
91
+ newlines, links shown as ``text (URL)``, entities decoded, and
92
+ consecutive newlines collapsed to a single newline.
93
+ """
94
+ if not html:
95
+ return ""
96
+
97
+ text = html
98
+
99
+ # Strip reply/quote blocks before any tag processing so that quoted
100
+ # author names and message text are not included in the output.
101
+ text = _remove_reply_quotes(text)
102
+
103
+ # Convert block-level elements to newlines
104
+ text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
105
+ text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
106
+ text = re.sub(r"</div>", "\n", text, flags=re.IGNORECASE)
107
+ text = re.sub(r"</li>", "\n", text, flags=re.IGNORECASE)
108
+
109
+ # Extract link text with URL: <a href="URL">text</a> -> text (URL)
110
+ text = re.sub(
111
+ r'<a\s[^>]*href="([^"]*)"[^>]*>([^<]*)</a>',
112
+ r"\2 (\1)",
113
+ text,
114
+ flags=re.IGNORECASE,
115
+ )
116
+
117
+ # Remove all remaining HTML tags
118
+ text = re.sub(r"<[^>]+>", "", text)
119
+
120
+ # Decode HTML entities
121
+ text = html_mod.unescape(text)
122
+
123
+ # Collapse consecutive newlines into a single newline.
124
+ text = re.sub(r"\n{2,}", "\n", text)
125
+
126
+ return text.strip()