-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtranscript_search.py
More file actions
309 lines (257 loc) · 10.4 KB
/
Copy pathtranscript_search.py
File metadata and controls
309 lines (257 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
"""Search across timestamped transcripts with a lightweight inverted index.
This module answers the archival question "find where we discussed X":
given one or many transcripts made of timestamped segments (for example the
``.json`` sidecars produced by the transcription pipeline), it builds an
in-memory inverted index and returns every segment that matches a query,
each with its recording id and start/end timestamps.
Design notes
------------
* **Stdlib only.** No third-party dependencies; safe to vendor anywhere.
* **Duck-typed segments.** :meth:`TranscriptIndex.add` accepts any object
exposing ``start``, ``end``, and ``text`` attributes, or an equivalent
mapping with those keys — so it composes with the transcription sidecar's
JSON output (via :func:`load_transcript_json`) without importing it.
* **AND semantics.** A multi-word query matches only segments containing
*every* query term; results are scored by summed term frequency and
sorted by score (descending), then by recording id and start time.
Known limitation (documented honestly)
--------------------------------------
Tokenization uses a Unicode word regex split on non-word boundaries, which
works well for whitespace-delimited languages. CJK text (Korean, Japanese,
Chinese) is only split on whitespace/punctuation, **not** morphologically
segmented — so a Korean query term matches only when the same
space-delimited token appears in the transcript. Proper CJK support would
require a morphological analyzer, which is out of scope for a
stdlib-only module.
"""
from __future__ import annotations
import json
import re
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Mapping
__all__ = [
"Match",
"Segment",
"TranscriptIndex",
"load_transcript_json",
"tokenize",
]
# Unicode-aware word tokenizer: runs of word characters (letters, digits,
# underscore). Case is folded by the caller; punctuation is stripped by
# construction because it never matches ``\w+``.
_WORD_RE = re.compile(r"\w+", re.UNICODE)
def tokenize(text: str) -> list[str]:
"""Split *text* into lowercase word tokens.
Uses a Unicode ``\\w+`` regex, so punctuation is dropped and tokens are
case-folded (``"Hello, World!"`` -> ``["hello", "world"]``).
Note:
CJK text is split only on whitespace/punctuation boundaries — see
the module docstring for the honest limitation statement.
Args:
text: Arbitrary text to tokenize.
Returns:
List of lowercase tokens (possibly empty).
"""
lower = text.lower()
if lower.isalnum():
return [lower]
return _WORD_RE.findall(lower)
@dataclass(frozen=True)
class Segment:
"""One timestamped chunk of a transcript.
Attributes:
start: Segment start time in seconds.
end: Segment end time in seconds.
text: Spoken text of the segment.
"""
start: float
end: float
text: str
@dataclass(frozen=True)
class Match:
"""A single search hit inside a recording.
Attributes:
recording_id: Identifier of the recording the hit belongs to
(as passed to :meth:`TranscriptIndex.add`).
start: Start timestamp (seconds) of the matching segment.
end: End timestamp (seconds) of the matching segment.
text: Original (unnormalized) text of the matching segment.
score: Relevance score — the summed term frequency of all query
terms within the segment. Higher is more relevant.
"""
recording_id: str
start: float
end: float
text: str
score: int
@dataclass(frozen=True)
class _Entry:
"""Internal indexed segment: original fields plus its token counts."""
recording_id: str
start: float
end: float
text: str
counts: Counter = field(compare=False)
def _read_attr(segment: Any, name: str) -> Any:
"""Fetch *name* from a duck-typed segment (attribute or mapping key).
Args:
segment: Object with ``start``/``end``/``text`` attributes, or a
mapping with those keys.
name: Field name to read.
Returns:
The field value.
Raises:
TypeError: If the segment exposes the field neither as an
attribute nor as a mapping key.
"""
if isinstance(segment, Mapping):
try:
return segment[name]
except KeyError:
raise TypeError(
f"segment mapping is missing required key {name!r}"
) from None
try:
return getattr(segment, name)
except AttributeError:
raise TypeError(
f"segment object is missing required attribute {name!r}"
) from None
class TranscriptIndex:
"""Inverted index over timestamped transcript segments.
Add one or many recordings with :meth:`add`, then query with
:meth:`search`. The index is in-memory and append-only; re-adding a
recording id simply indexes more segments under the same id.
Example:
>>> idx = TranscriptIndex()
>>> idx.add("standup-01", [Segment(0.0, 4.0, "codec budget review")])
>>> [m.recording_id for m in idx.search("codec")]
['standup-01']
"""
def __init__(self) -> None:
"""Create an empty index."""
# token -> set of entry positions in self._entries
self._postings: dict[str, set[int]] = {}
self._entries: list[_Entry] = []
def __len__(self) -> int:
"""Return the number of indexed segments."""
return len(self._entries)
def add(self, recording_id: str, segments: Iterable[Any]) -> int:
"""Index the *segments* of one recording.
Args:
recording_id: Stable identifier for the recording (e.g. the
source filename); echoed back on every :class:`Match`.
segments: Iterable of duck-typed segments — each must expose
``start``, ``end`` and ``text`` as attributes (e.g.
:class:`Segment` or the transcription sidecar's segment
objects) or as mapping keys.
Returns:
The number of segments indexed from this call.
Raises:
TypeError: If a segment lacks one of the required fields.
"""
added = 0
for segment in segments:
entry = _Entry(
recording_id=recording_id,
start=float(_read_attr(segment, "start")),
end=float(_read_attr(segment, "end")),
text=str(_read_attr(segment, "text")),
counts=Counter(tokenize(str(_read_attr(segment, "text")))),
)
position = len(self._entries)
self._entries.append(entry)
for token in entry.counts:
self._postings.setdefault(token, set()).add(position)
added += 1
return added
def search(self, query: str) -> list[Match]:
"""Find segments containing **all** words of *query*.
Matching is case-insensitive and punctuation-insensitive (both the
query and the indexed text pass through :func:`tokenize`).
Multi-word queries use AND semantics: only segments containing
every query term are returned.
Args:
query: One or more words to look for.
Returns:
Matches sorted by ``score`` descending, then by
``recording_id`` and ``start`` ascending. Empty list when
nothing matches.
Raises:
ValueError: If the query is empty or contains no indexable
words (e.g. punctuation only).
"""
terms = tokenize(query)
if not terms:
raise ValueError("query must contain at least one word")
# Intersect postings lists (AND semantics), rarest term first so
# the working set shrinks as fast as possible.
unique_terms = sorted(
set(terms), key=lambda t: len(self._postings.get(t, ()))
)
candidates: set[int] | None = None
for term in unique_terms:
postings = self._postings.get(term)
if not postings:
return []
candidates = (
set(postings) if candidates is None else candidates & postings
)
if not candidates:
return []
matches = []
for position in candidates or ():
entry = self._entries[position]
score = sum(entry.counts[term] for term in unique_terms)
matches.append(
Match(
recording_id=entry.recording_id,
start=entry.start,
end=entry.end,
text=entry.text,
score=score,
)
)
matches.sort(key=lambda m: (-m.score, m.recording_id, m.start))
return matches
def load_transcript_json(path: str | Path) -> list[Segment]:
"""Load segments from a transcription sidecar JSON file.
Reads the sidecar shape ``{"segments": [{"start": .., "end": ..,
"text": ..}, ...]}`` and returns :class:`Segment` objects ready for
:meth:`TranscriptIndex.add`. This mirrors the transcription
pipeline's output format without importing that module, so the two
features compose while remaining independent.
Args:
path: Path to the ``.json`` sidecar file.
Returns:
List of :class:`Segment` in file order.
Raises:
ValueError: If the file is not a JSON object with a ``"segments"``
list, or a segment entry is missing ``start``/``end``/``text``.
OSError: If the file cannot be read.
json.JSONDecodeError: If the file is not valid JSON.
"""
raw = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(raw, dict) or not isinstance(raw.get("segments"), list):
raise ValueError(
f"{path}: expected a JSON object with a 'segments' list"
)
segments = []
for i, item in enumerate(raw["segments"]):
if not isinstance(item, dict):
raise ValueError(f"{path}: segments[{i}] is not an object")
try:
segments.append(
Segment(
start=float(item["start"]),
end=float(item["end"]),
text=str(item["text"]),
)
)
except KeyError as exc:
raise ValueError(
f"{path}: segments[{i}] is missing key {exc.args[0]!r}"
) from None
return segments