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.
- legacy/__init__.py +0 -0
- legacy/transcript_download.py +3969 -0
- teams_transcripts/__init__.py +1 -0
- teams_transcripts/__main__.py +288 -0
- teams_transcripts/adapters/__init__.py +1 -0
- teams_transcripts/adapters/cdp/__init__.py +1 -0
- teams_transcripts/adapters/cdp/browser.py +196 -0
- teams_transcripts/adapters/cdp/connection.py +168 -0
- teams_transcripts/adapters/cdp/helpers.py +449 -0
- teams_transcripts/adapters/cdp/teams.py +463 -0
- teams_transcripts/adapters/vertex.py +274 -0
- teams_transcripts/domain/__init__.py +1 -0
- teams_transcripts/domain/chat.py +115 -0
- teams_transcripts/domain/html.py +126 -0
- teams_transcripts/domain/mcps.py +343 -0
- teams_transcripts/domain/models.py +531 -0
- teams_transcripts/domain/participants.py +292 -0
- teams_transcripts/domain/scoring.py +106 -0
- teams_transcripts/domain/speakers.py +130 -0
- teams_transcripts/domain/summary.py +266 -0
- teams_transcripts/domain/timestamps.py +350 -0
- teams_transcripts/domain/vtt.py +444 -0
- teams_transcripts/infrastructure/__init__.py +1 -0
- teams_transcripts/infrastructure/config.py +987 -0
- teams_transcripts/infrastructure/errors.py +120 -0
- teams_transcripts/infrastructure/logging.py +69 -0
- teams_transcripts/infrastructure/signals.py +143 -0
- teams_transcripts/mcp_server.py +818 -0
- teams_transcripts/ports/__init__.py +1 -0
- teams_transcripts/ports/agent.py +58 -0
- teams_transcripts/ports/browser.py +126 -0
- teams_transcripts/services/__init__.py +1 -0
- teams_transcripts/services/downloader.py +1283 -0
- teams_transcripts/services/extraction.py +1603 -0
- teams_transcripts/services/listing.py +826 -0
- teams_transcripts/services/navigation.py +1721 -0
- teams_transcripts/services/output.py +84 -0
- teams_transcripts/services/tenant.py +684 -0
- teams_transcripts-0.5.0.dist-info/METADATA +1438 -0
- teams_transcripts-0.5.0.dist-info/RECORD +42 -0
- teams_transcripts-0.5.0.dist-info/WHEEL +4 -0
- teams_transcripts-0.5.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Pure participant categorisation functions.
|
|
2
|
+
|
|
3
|
+
Parses attendance data from Teams Event/Call ``<partlist>`` XML,
|
|
4
|
+
Tracking/RSVP data from the Details tab, and combines it with roster
|
|
5
|
+
and speaker data to build categorised
|
|
6
|
+
:class:`~teams_transcripts.domain.models.MeetingParticipants`.
|
|
7
|
+
|
|
8
|
+
All functions in this module are pure: no I/O, no network, no logging,
|
|
9
|
+
no global state.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
from teams_transcripts.domain.models import MeetingParticipants, RosterMember
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_partlist_xml(content: str) -> list[dict]:
|
|
20
|
+
"""Parse ``<partlist>`` XML from an Event/Call message content string.
|
|
21
|
+
|
|
22
|
+
The ``<partlist>`` element is embedded in the ``content`` field of
|
|
23
|
+
Teams Event/Call messages (``messageType == "Event/Call"``). Each
|
|
24
|
+
``<part>`` child represents a call participant with their MRI
|
|
25
|
+
identity, display name, and duration in seconds.
|
|
26
|
+
|
|
27
|
+
Note: Teams may declare a ``count`` attribute larger than the actual
|
|
28
|
+
number of ``<part>`` elements (the XML is truncated server-side).
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
content: Raw HTML/XML content string from an Event/Call message.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
List of dicts, each with keys:
|
|
35
|
+
- ``identity`` (str): Full MRI string (e.g. ``"8:orgid:abc-123"``).
|
|
36
|
+
- ``display_name`` (str): Participant display name.
|
|
37
|
+
- ``duration_seconds`` (int): Duration in the call in seconds.
|
|
38
|
+
"""
|
|
39
|
+
# Find the <partlist>...</partlist> block
|
|
40
|
+
partlist_match = re.search(r"<partlist[^>]*>(.*?)</partlist>", content, re.DOTALL)
|
|
41
|
+
if not partlist_match:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
partlist_body = partlist_match.group(1)
|
|
45
|
+
if not partlist_body.strip():
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
# Extract each <part> element
|
|
49
|
+
parts: list[dict] = []
|
|
50
|
+
for part_match in re.finditer(
|
|
51
|
+
r'<part\s+identity="([^"]*)">(.*?)</part>',
|
|
52
|
+
partlist_body,
|
|
53
|
+
re.DOTALL,
|
|
54
|
+
):
|
|
55
|
+
identity = part_match.group(1)
|
|
56
|
+
part_body = part_match.group(2)
|
|
57
|
+
|
|
58
|
+
# Extract <displayName>
|
|
59
|
+
dn_match = re.search(r"<displayName>(.*?)</displayName>", part_body)
|
|
60
|
+
display_name = _decode_xml_entities(dn_match.group(1)) if dn_match else ""
|
|
61
|
+
|
|
62
|
+
# Extract <duration>
|
|
63
|
+
dur_match = re.search(r"<duration>(\d+)</duration>", part_body)
|
|
64
|
+
duration = int(dur_match.group(1)) if dur_match else 0
|
|
65
|
+
|
|
66
|
+
parts.append(
|
|
67
|
+
{
|
|
68
|
+
"identity": identity,
|
|
69
|
+
"display_name": display_name,
|
|
70
|
+
"duration_seconds": duration,
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return parts
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Tracking/RSVP parsing
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
# Known RSVP status values in the Teams Tracking panel aria-labels.
|
|
82
|
+
# The regex captures the status word immediately before the comma+position
|
|
83
|
+
# suffix (e.g. ", 1 of 15, ...").
|
|
84
|
+
_TRACKING_STATUSES = {"Accepted", "Tentative", "Declined", "None", "Organizer"}
|
|
85
|
+
|
|
86
|
+
# Pattern: "<Name> <Status>, <N> of <M>, in a list of <M> items, Dialogue Pop-up Button"
|
|
87
|
+
# The status word is one of the known values. The name may contain
|
|
88
|
+
# parentheses, hyphens, etc.
|
|
89
|
+
_TRACKING_LABEL_RE = re.compile(
|
|
90
|
+
r"^(.+?)\s+(Accepted|Tentative|Declined|None|Organizer)"
|
|
91
|
+
r",\s+\d+\s+of\s+\d+,\s+in a list of \d+ items",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_tracking_entries(aria_labels: list[str]) -> list[dict]:
|
|
96
|
+
"""Parse aria-label strings from the Teams Tracking panel.
|
|
97
|
+
|
|
98
|
+
Each label has the format:
|
|
99
|
+
``"Name Status, N of M, in a list of M items, Dialogue Pop-up Button"``
|
|
100
|
+
|
|
101
|
+
where *Status* is one of ``Accepted``, ``Tentative``, ``Declined``,
|
|
102
|
+
``None`` (no response), or ``Organizer``.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
aria_labels: Raw aria-label strings collected from the Tracking
|
|
106
|
+
panel list items.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
List of dicts, each with keys:
|
|
110
|
+
- ``name`` (str): Display name.
|
|
111
|
+
- ``rsvp_status`` (str): Normalised status (lowercase).
|
|
112
|
+
"""
|
|
113
|
+
entries: list[dict] = []
|
|
114
|
+
for label in aria_labels:
|
|
115
|
+
m = _TRACKING_LABEL_RE.match(label)
|
|
116
|
+
if not m:
|
|
117
|
+
continue
|
|
118
|
+
name = m.group(1).strip()
|
|
119
|
+
status = m.group(2).lower()
|
|
120
|
+
entries.append({"name": name, "rsvp_status": status})
|
|
121
|
+
return entries
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def enrich_with_rsvp(
|
|
125
|
+
members: list[RosterMember],
|
|
126
|
+
tracking: list[dict],
|
|
127
|
+
) -> list[RosterMember]:
|
|
128
|
+
"""Enrich roster members with RSVP status from tracking data.
|
|
129
|
+
|
|
130
|
+
Matches tracking entries to members by normalised display name
|
|
131
|
+
(case-insensitive, whitespace-collapsed). Unmatched tracking
|
|
132
|
+
entries (e.g. distribution lists) are silently ignored. Unmatched
|
|
133
|
+
members retain an empty ``rsvp_status``.
|
|
134
|
+
|
|
135
|
+
Since :class:`RosterMember` is frozen, new instances are created
|
|
136
|
+
for members whose status changes.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
members: Existing roster members (may have empty ``rsvp_status``).
|
|
140
|
+
tracking: Parsed tracking entries from :func:`parse_tracking_entries`.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
New list of :class:`RosterMember` with ``rsvp_status`` populated
|
|
144
|
+
where a match was found.
|
|
145
|
+
"""
|
|
146
|
+
if not tracking:
|
|
147
|
+
return list(members)
|
|
148
|
+
|
|
149
|
+
# Build lookup: normalised name -> rsvp_status
|
|
150
|
+
status_by_name: dict[str, str] = {}
|
|
151
|
+
for entry in tracking:
|
|
152
|
+
key = _normalise_name(entry["name"])
|
|
153
|
+
status_by_name[key] = entry["rsvp_status"]
|
|
154
|
+
|
|
155
|
+
result: list[RosterMember] = []
|
|
156
|
+
for m in members:
|
|
157
|
+
key = _normalise_name(m.name)
|
|
158
|
+
status = status_by_name.get(key, "")
|
|
159
|
+
if status and status != m.rsvp_status:
|
|
160
|
+
result.append(
|
|
161
|
+
RosterMember(
|
|
162
|
+
name=m.name,
|
|
163
|
+
email=m.email,
|
|
164
|
+
object_id=m.object_id,
|
|
165
|
+
role=m.role,
|
|
166
|
+
rsvp_status=status,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
result.append(m)
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def build_meeting_participants(
|
|
175
|
+
*,
|
|
176
|
+
all_members: list[RosterMember],
|
|
177
|
+
speaker_map: dict[str, str],
|
|
178
|
+
attendance: list[dict],
|
|
179
|
+
organizer_id: str,
|
|
180
|
+
tracking: list[dict] | None = None,
|
|
181
|
+
) -> MeetingParticipants:
|
|
182
|
+
"""Build categorised participant lists from all data sources.
|
|
183
|
+
|
|
184
|
+
Cross-references the full roster (from the participant popover),
|
|
185
|
+
the Recap speaker mapping, the Event/Call attendance list, and
|
|
186
|
+
optionally the Tracking/RSVP data to produce four categories:
|
|
187
|
+
organiser, invited, speakers, attendees.
|
|
188
|
+
|
|
189
|
+
When ``tracking`` is provided, all members are enriched with RSVP
|
|
190
|
+
status before categorisation, so organiser, speakers, and attendees
|
|
191
|
+
also carry the status.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
all_members: Complete roster from the participant popover.
|
|
195
|
+
Each member has ``object_id`` (bare GUID), ``name``, ``email``.
|
|
196
|
+
speaker_map: GUID-to-displayName mapping from the Recap
|
|
197
|
+
Speakers tab.
|
|
198
|
+
attendance: List of attendance dicts from :func:`parse_partlist_xml`.
|
|
199
|
+
Each has ``identity`` (MRI), ``display_name``, ``duration_seconds``.
|
|
200
|
+
organizer_id: Bare GUID of the meeting organiser (from
|
|
201
|
+
``meetingInformation.organizerId``), or empty string.
|
|
202
|
+
tracking: Optional list of tracking dicts from
|
|
203
|
+
:func:`parse_tracking_entries`. Each has ``name`` and
|
|
204
|
+
``rsvp_status``.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
:class:`MeetingParticipants` with all four categories populated.
|
|
208
|
+
"""
|
|
209
|
+
# Enrich members with RSVP status before categorisation
|
|
210
|
+
enriched = enrich_with_rsvp(all_members, tracking) if tracking else list(all_members)
|
|
211
|
+
|
|
212
|
+
# Index enriched members by objectId for fast lookup
|
|
213
|
+
members_by_id: dict[str, RosterMember] = {}
|
|
214
|
+
for m in enriched:
|
|
215
|
+
if m.object_id:
|
|
216
|
+
members_by_id[m.object_id] = m
|
|
217
|
+
|
|
218
|
+
# Organiser
|
|
219
|
+
organiser: list[RosterMember] = []
|
|
220
|
+
if organizer_id and organizer_id in members_by_id:
|
|
221
|
+
organiser = [members_by_id[organizer_id]]
|
|
222
|
+
|
|
223
|
+
# Invited = all enriched members, sorted by name
|
|
224
|
+
invited = sorted(enriched, key=lambda m: m.name.lower())
|
|
225
|
+
|
|
226
|
+
# Speakers = roster members whose objectId is in the speaker_map
|
|
227
|
+
speakers = [members_by_id[guid] for guid in speaker_map if guid in members_by_id]
|
|
228
|
+
speakers.sort(key=lambda m: m.name.lower())
|
|
229
|
+
|
|
230
|
+
# Attendees = roster members whose objectId matches an attendance
|
|
231
|
+
# identity MRI. Bots (identity starting with "28:") are excluded.
|
|
232
|
+
# Deduplicate by objectId (multiple partlists may list the same person).
|
|
233
|
+
seen_attendee_ids: set[str] = set()
|
|
234
|
+
attendees: list[RosterMember] = []
|
|
235
|
+
for att in attendance:
|
|
236
|
+
identity = att["identity"]
|
|
237
|
+
# Skip bots
|
|
238
|
+
if identity.startswith("28:"):
|
|
239
|
+
continue
|
|
240
|
+
# Extract bare GUID from MRI "8:orgid:{guid}"
|
|
241
|
+
guid = _extract_guid_from_mri(identity)
|
|
242
|
+
if not guid or guid in seen_attendee_ids:
|
|
243
|
+
continue
|
|
244
|
+
if guid in members_by_id:
|
|
245
|
+
attendees.append(members_by_id[guid])
|
|
246
|
+
seen_attendee_ids.add(guid)
|
|
247
|
+
attendees.sort(key=lambda m: m.name.lower())
|
|
248
|
+
|
|
249
|
+
return MeetingParticipants(
|
|
250
|
+
organiser=organiser,
|
|
251
|
+
invited=invited,
|
|
252
|
+
speakers=speakers,
|
|
253
|
+
attendees=attendees,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# Internal helpers
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _extract_guid_from_mri(mri: str) -> str:
|
|
263
|
+
"""Extract the bare GUID from a Teams MRI string.
|
|
264
|
+
|
|
265
|
+
MRI format: ``"8:orgid:{guid}"`` or ``"28:{bot-id}"``.
|
|
266
|
+
Returns the last colon-separated segment, or the original string
|
|
267
|
+
if no colons are present.
|
|
268
|
+
"""
|
|
269
|
+
if ":" in mri:
|
|
270
|
+
return mri.split(":")[-1]
|
|
271
|
+
return mri
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _normalise_name(name: str) -> str:
|
|
275
|
+
"""Normalise a display name for case-insensitive, whitespace-tolerant matching.
|
|
276
|
+
|
|
277
|
+
Lowercases, strips leading/trailing whitespace, and collapses internal
|
|
278
|
+
runs of whitespace to a single space.
|
|
279
|
+
"""
|
|
280
|
+
return " ".join(name.lower().split())
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _decode_xml_entities(text: str) -> str:
|
|
284
|
+
"""Decode common XML entities in a string.
|
|
285
|
+
|
|
286
|
+
Handles ``&``, ``<``, ``>``, ``"``, ``'``.
|
|
287
|
+
"""
|
|
288
|
+
text = text.replace("&", "&")
|
|
289
|
+
text = text.replace("<", "<")
|
|
290
|
+
text = text.replace(">", ">")
|
|
291
|
+
text = text.replace(""", '"')
|
|
292
|
+
return text.replace("'", "'")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Pure search result scoring and occurrence matching 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 re
|
|
10
|
+
from datetime import date
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def score_search_result(meeting_name: str, result_text: str) -> float:
|
|
14
|
+
"""Score a search result against the meeting name using word overlap.
|
|
15
|
+
|
|
16
|
+
The algorithm:
|
|
17
|
+
1. Tokenise both strings into lowercase word sets.
|
|
18
|
+
2. Count exact word overlaps.
|
|
19
|
+
3. For each meeting-name word not in the exact overlap, check if it
|
|
20
|
+
appears as a substring of the result text (handles concatenated
|
|
21
|
+
words like ``"CallAdrian"``).
|
|
22
|
+
4. Subtract 0.5 if the result text starts with a ``Name:`` pattern
|
|
23
|
+
(indicating a personal message rather than a meeting entry).
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
meeting_name: The meeting search string.
|
|
27
|
+
result_text: The text content of a search result.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Numeric score (higher is better).
|
|
31
|
+
"""
|
|
32
|
+
name_words = set(re.findall(r"\w+", meeting_name.lower()))
|
|
33
|
+
result_text_lower = result_text.lower()
|
|
34
|
+
result_words = set(re.findall(r"\w+", result_text_lower))
|
|
35
|
+
|
|
36
|
+
# Exact word overlap
|
|
37
|
+
overlap = len(name_words & result_words)
|
|
38
|
+
|
|
39
|
+
# Substring match for words not in exact overlap
|
|
40
|
+
for nw in name_words - result_words:
|
|
41
|
+
if nw in result_text_lower:
|
|
42
|
+
overlap += 1
|
|
43
|
+
|
|
44
|
+
# Tiebreaker: penalise personal message results
|
|
45
|
+
score = float(overlap)
|
|
46
|
+
if re.match(r"^[A-Z][a-z]+:", result_text):
|
|
47
|
+
score -= 0.5
|
|
48
|
+
|
|
49
|
+
return score
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def best_result_index(meeting_name: str, results: list[str]) -> int:
|
|
53
|
+
"""Return the index of the best-scoring search result.
|
|
54
|
+
|
|
55
|
+
Ties are broken by preferring the earlier (lower-index) result.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
meeting_name: The meeting search string.
|
|
59
|
+
results: List of search result text strings.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Zero-based index of the best result.
|
|
63
|
+
"""
|
|
64
|
+
best_idx, best_score = 0, 0.0
|
|
65
|
+
for i, text in enumerate(results):
|
|
66
|
+
s = score_search_result(meeting_name, text)
|
|
67
|
+
if s > best_score:
|
|
68
|
+
best_score = s
|
|
69
|
+
best_idx = i
|
|
70
|
+
return best_idx
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def occurrence_matches(
|
|
74
|
+
text: str,
|
|
75
|
+
target_date: str,
|
|
76
|
+
target_time: str | None,
|
|
77
|
+
) -> bool:
|
|
78
|
+
"""Check whether an occurrence dropdown text matches the target date/time.
|
|
79
|
+
|
|
80
|
+
The text has the form ``"22 April 2026 11:15 - 12:00"``. The match
|
|
81
|
+
requires that the day number, month name, and year all appear in the
|
|
82
|
+
text. If *target_time* is provided, it must appear before the dash
|
|
83
|
+
separator (i.e. it must be the start time, not the end time).
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
text: Raw occurrence dropdown text.
|
|
87
|
+
target_date: Target date as ``YYYY-MM-DD``.
|
|
88
|
+
target_time: Target time as ``HH:MM``, or ``None`` for date-only.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
``True`` if the text matches the target.
|
|
92
|
+
"""
|
|
93
|
+
dt = date.fromisoformat(target_date)
|
|
94
|
+
date_pattern = re.compile(
|
|
95
|
+
rf"(?<!\d){dt.day}\s+{re.escape(dt.strftime('%B'))}\s+{dt.year}(?!\d)",
|
|
96
|
+
re.IGNORECASE,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if not date_pattern.search(text):
|
|
100
|
+
return False
|
|
101
|
+
if target_time is None:
|
|
102
|
+
return True
|
|
103
|
+
sep_idx = text.find("-")
|
|
104
|
+
if sep_idx == -1:
|
|
105
|
+
return target_time in text
|
|
106
|
+
return target_time in text[:sep_idx]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Pure speaker resolution and mapping 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 re
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve_chat_author(
|
|
13
|
+
from_user_id: str,
|
|
14
|
+
im_display_name: str,
|
|
15
|
+
speaker_map: dict[str, str] | None,
|
|
16
|
+
) -> str:
|
|
17
|
+
"""Resolve a chat message author name using the speaker mapping.
|
|
18
|
+
|
|
19
|
+
Extracts the bare GUID from ``fromUserId`` (format ``8:orgid:{GUID}``)
|
|
20
|
+
and checks the speaker_map for a cleaner display name. Falls back to
|
|
21
|
+
``imDisplayName`` from the message props, then to ``"Unknown"``.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
from_user_id: Teams user ID string (e.g. ``"8:orgid:abc-123"``).
|
|
25
|
+
im_display_name: Fallback display name from the message.
|
|
26
|
+
speaker_map: Mapping of GUID to display name, or ``None``.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Best available human-readable name.
|
|
30
|
+
"""
|
|
31
|
+
if speaker_map and from_user_id and ":" in from_user_id:
|
|
32
|
+
guid = from_user_id.split(":")[-1]
|
|
33
|
+
mapped = speaker_map.get(guid)
|
|
34
|
+
if mapped:
|
|
35
|
+
return mapped
|
|
36
|
+
|
|
37
|
+
return im_display_name or "Unknown"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def apply_speaker_mapping(content: str, speaker_map: dict[str, str]) -> str:
|
|
41
|
+
"""Replace speaker names in VTT content using the Recap speaker mapping.
|
|
42
|
+
|
|
43
|
+
Native VTT downloads (Approach A) contain speaker names in
|
|
44
|
+
``<v SPEAKER_NAME>`` tags. This function matches each VTT speaker
|
|
45
|
+
name to a Recap display name and replaces it.
|
|
46
|
+
|
|
47
|
+
The mapping keys are GUIDs, but VTT files don't contain GUIDs --
|
|
48
|
+
they contain display names. Matching is done by:
|
|
49
|
+
|
|
50
|
+
1. ``@N`` pattern: Recap shows ``Speaker N``.
|
|
51
|
+
2. Substring match: VTT has ``v-Gabor Veres (Metosin)``, Recap has
|
|
52
|
+
``Gabor Veres``. The Recap name is a substring of the VTT name.
|
|
53
|
+
The longest matching Recap name wins.
|
|
54
|
+
3. Exact match: names are already identical -- no replacement needed.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
content: Raw VTT file content.
|
|
58
|
+
speaker_map: Mapping of GUID to Recap display name.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
VTT content with speaker names replaced.
|
|
62
|
+
"""
|
|
63
|
+
# Extract all unique speaker names from the VTT.
|
|
64
|
+
vtt_names = set(re.findall(r"<v ([^>]+)>", content))
|
|
65
|
+
recap_names = set(speaker_map.values())
|
|
66
|
+
|
|
67
|
+
replacements: dict[str, str] = {} # old_vtt_name -> recap_name
|
|
68
|
+
|
|
69
|
+
for vtt_name in vtt_names:
|
|
70
|
+
if vtt_name in recap_names:
|
|
71
|
+
# Already matches a Recap name exactly -- no change needed.
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# 1. @N pattern: look for "Speaker N" in Recap names.
|
|
75
|
+
at_match = re.match(r"^@(\d+)$", vtt_name)
|
|
76
|
+
if at_match:
|
|
77
|
+
speaker_label = f"Speaker {at_match.group(1)}"
|
|
78
|
+
if speaker_label in recap_names:
|
|
79
|
+
replacements[vtt_name] = speaker_label
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# 2. Substring match: a Recap name is contained in the VTT name.
|
|
83
|
+
# Pick the longest matching Recap name to avoid false positives.
|
|
84
|
+
best_match = None
|
|
85
|
+
best_len = 0
|
|
86
|
+
for recap_name in recap_names:
|
|
87
|
+
if recap_name in vtt_name and len(recap_name) > best_len:
|
|
88
|
+
best_match = recap_name
|
|
89
|
+
best_len = len(recap_name)
|
|
90
|
+
if best_match:
|
|
91
|
+
replacements[vtt_name] = best_match
|
|
92
|
+
|
|
93
|
+
if not replacements:
|
|
94
|
+
return content
|
|
95
|
+
|
|
96
|
+
for old_name, new_name in replacements.items():
|
|
97
|
+
old_tag = f"<v {old_name}>"
|
|
98
|
+
new_tag = f"<v {new_name}>"
|
|
99
|
+
content = content.replace(old_tag, new_tag)
|
|
100
|
+
|
|
101
|
+
return content
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_speaker_map_from_roster(
|
|
105
|
+
roster_members: list[dict],
|
|
106
|
+
speaker_map: dict[str, str] | None,
|
|
107
|
+
) -> dict[str, str]:
|
|
108
|
+
"""Build a combined GUID-to-name map from the roster and speaker_map.
|
|
109
|
+
|
|
110
|
+
The roster covers all chat members (including non-speakers), which
|
|
111
|
+
lets us resolve reaction authors and chat authors that the speaker_map
|
|
112
|
+
alone cannot. Speaker map entries take priority (Recap canonical names).
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
roster_members: List of roster member dicts with ``"objectId"``
|
|
116
|
+
and ``"name"`` keys.
|
|
117
|
+
speaker_map: Existing GUID-to-name mapping from Recap, or ``None``.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Combined GUID-to-name mapping.
|
|
121
|
+
"""
|
|
122
|
+
member_map: dict[str, str] = {}
|
|
123
|
+
for rm in roster_members:
|
|
124
|
+
oid = rm.get("objectId", "")
|
|
125
|
+
if oid and rm.get("name"):
|
|
126
|
+
member_map[oid] = rm["name"]
|
|
127
|
+
# Speaker map entries take priority (Recap canonical names).
|
|
128
|
+
if speaker_map:
|
|
129
|
+
member_map.update(speaker_map)
|
|
130
|
+
return member_map
|